- Replace src.Read(data) with io.ReadAll(src) to prevent silent truncation on large uploads - Reject zero-byte file uploads with 400 rather than storing empty BYTEA - Add strings.TrimSpace to validateFruit so whitespace-only name/osdb_number returns 422 - Reset offset to 0 in fruitStore.create() so navigating back to list after create shows page 1 - Log t.Cleanup DELETE error in integration test to surface constraint failures clearly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
283 lines
7.5 KiB
Go
283 lines
7.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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 strings.TrimSpace(dto.Name) == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if strings.TrimSpace(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()
|
|
|
|
if file.Size == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is empty"})
|
|
}
|
|
|
|
data, err := io.ReadAll(src)
|
|
if 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)
|
|
}
|