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, name string, types []string) ([]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 } } name := c.QueryParam("name") var types []string if typeParam := c.QueryParam("type"); typeParam != "" { if expanded, ok := domain.FruitTypeAliases[typeParam]; ok { types = expanded } else if _, ok := validFruitTypes[typeParam]; ok { types = []string{typeParam} } // unknown typeParam → types stays nil → no filter applied } fruits, total, err := h.repo.List(c.Request().Context(), limit, offset, name, types) 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) }