feat: manage fruits — full CRUD with images and synonyms (story #02)

Backend: domain structs, FruitRepository interface + pg implementation,
9 Echo v4 handlers (list/get/create/update/delete, image sub-resources),
migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images),
route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving.

Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList
(paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image
gallery). 25 backend unit tests + integration test; 18 frontend tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:18:01 +02:00
co-authored by Claude Sonnet 4.6
parent a9100fc7d0
commit 1b889ac112
17 changed files with 2507 additions and 15 deletions
+276
View File
@@ -0,0 +1,276 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"osdb/internal/domain"
)
var (
ErrNotFound = errors.New("not found")
ErrDuplicateOSDBNumber = errors.New("duplicate osdb_number")
)
// FruitRepository is the consumer-defined interface the handler depends on.
// The pg implementation in the repository package satisfies this structurally.
type FruitRepository interface {
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error)
Get(ctx context.Context, id int) (domain.Fruit, error)
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
Delete(ctx context.Context, id int) error
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error)
AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error)
GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error)
DeleteImage(ctx context.Context, fruitID, imageID int) error
}
var validFruitTypes = map[string]struct{}{
"Apfelsorten": {},
"Birnensorten": {},
"Quittensorten": {},
"Aprikosen": {},
"Pfirsiche": {},
"Mirabellen": {},
"Renekloden": {},
"Pflaumen": {},
"Zwetschen": {},
"Sauerkirschen": {},
"Süßkirschen": {},
"Brombeeren": {},
"Erdbeeren": {},
"Himbeeren": {},
"Johannisbeeren": {},
"Stachelbeeren": {},
"Wein": {},
}
type FruitHandler struct {
repo FruitRepository
}
func NewFruitHandler(repo FruitRepository) *FruitHandler {
return &FruitHandler{repo: repo}
}
func validateFruit(dto domain.FruitWriteDTO) []string {
var errs []string
if dto.Name == "" {
errs = append(errs, "name is required")
}
if dto.OSDBNumber == "" {
errs = append(errs, "osdb_number is required")
}
if _, ok := validFruitTypes[dto.FruitType]; !ok {
errs = append(errs, "fruit_type is invalid")
}
return errs
}
func mapRepoError(c echo.Context, err error) error {
switch {
case errors.Is(err, ErrNotFound):
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrDuplicateOSDBNumber):
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_number already exists"})
default:
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
func parseID(c echo.Context, param string) (int, error) {
id, err := strconv.Atoi(c.Param(param))
if err != nil {
return 0, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
return id, nil
}
func (h *FruitHandler) List(c echo.Context) error {
limit := 50
offset := 0
if v := c.QueryParam("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 200 {
limit = n
}
}
if v := c.QueryParam("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if fruits == nil {
fruits = []domain.Fruit{}
}
return c.JSON(http.StatusOK, domain.FruitListResponse{
Items: fruits,
Total: total,
Limit: limit,
Offset: offset,
})
}
func (h *FruitHandler) Get(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
fruit, err := h.repo.Get(c.Request().Context(), id)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusOK, fruit)
}
func (h *FruitHandler) Create(c echo.Context) error {
var dto domain.FruitWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validateFruit(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
if dto.Synonyms == nil {
dto.Synonyms = []string{}
}
fruit, err := h.repo.Create(c.Request().Context(), dto)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusCreated, fruit)
}
func (h *FruitHandler) Update(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
var dto domain.FruitWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validateFruit(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
if dto.Synonyms == nil {
dto.Synonyms = []string{}
}
fruit, err := h.repo.Update(c.Request().Context(), id, dto)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusOK, fruit)
}
func (h *FruitHandler) Delete(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
if err := h.repo.Delete(c.Request().Context(), id); err != nil {
return mapRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *FruitHandler) ListImages(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
images, err := h.repo.ListImages(c.Request().Context(), id)
if err != nil {
return mapRepoError(c, err)
}
if images == nil {
images = []domain.FruitImage{}
}
return c.JSON(http.StatusOK, images)
}
func (h *FruitHandler) UploadImage(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageType := c.FormValue("image_type")
if _, ok := map[string]struct{}{"fruit": {}, "flower": {}, "tree": {}}[imageType]; !ok {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": []string{"image_type must be fruit, flower, or tree"}})
}
file, err := c.FormFile("image")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data := make([]byte, file.Size)
if _, err := src.Read(data); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
var filename *string
if file.Filename != "" {
s := file.Filename
filename = &s
}
titleStr := c.FormValue("title")
var title *string
if titleStr != "" {
title = &titleStr
}
img, err := h.repo.AddImage(c.Request().Context(), fruitID, filename, data, imageType, title)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusCreated, img)
}
func (h *FruitHandler) ServeImage(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageID, err := parseID(c, "imageId")
if err != nil {
return err
}
data, err := h.repo.GetImageData(c.Request().Context(), fruitID, imageID)
if err != nil {
return mapRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *FruitHandler) DeleteImage(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageID, err := parseID(c, "imageId")
if err != nil {
return err
}
if err := h.repo.DeleteImage(c.Request().Context(), fruitID, imageID); err != nil {
return mapRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}