package api import ( "errors" "log/slog" "net/http" "strconv" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // gardenCreateRequest / gardenUpdateRequest are the JSON bodies for the gardens // endpoints. Dimensions are centimeters (the API is metric-only; imperial is a // display concern). On create, omitted dimensions default server-side; on // update, version is required and every field is replaced. type gardenCreateRequest struct { Name string `json:"name" binding:"required"` WidthCM float64 `json:"widthCm"` HeightCM float64 `json:"heightCm"` UnitPref string `json:"unitPref"` Notes string `json:"notes"` } type gardenUpdateRequest struct { Name string `json:"name" binding:"required"` WidthCM float64 `json:"widthCm"` HeightCM float64 `json:"heightCm"` UnitPref string `json:"unitPref"` Notes string `json:"notes"` Version int64 `json:"version" binding:"required"` } func (r gardenCreateRequest) toInput() service.GardenInput { return service.GardenInput{Name: r.Name, WidthCM: r.WidthCM, HeightCM: r.HeightCM, UnitPref: r.UnitPref, Notes: r.Notes} } func (r gardenUpdateRequest) toInput() service.GardenInput { return service.GardenInput{Name: r.Name, WidthCM: r.WidthCM, HeightCM: r.HeightCM, UnitPref: r.UnitPref, Notes: r.Notes} } // listGardens returns the actor's gardens as a JSON array (always an array, // never null). func (h *handlers) listGardens(c *gin.Context) { gardens, err := h.svc.ListGardens(c.Request.Context(), mustActor(c).ID) if err != nil { writeResourceError(c, err) return } c.JSON(http.StatusOK, gardens) } func (h *handlers) createGarden(c *gin.Context) { var req gardenCreateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a garden name is required") return } g, err := h.svc.CreateGarden(c.Request.Context(), mustActor(c).ID, req.toInput()) if err != nil { writeResourceError(c, err) return } c.JSON(http.StatusCreated, g) } func (h *handlers) getGarden(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } g, err := h.svc.GetGarden(c.Request.Context(), mustActor(c).ID, id) if err != nil { writeResourceError(c, err) return } c.JSON(http.StatusOK, g) } func (h *handlers) updateGarden(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } var req gardenUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "name and a current version are required") return } g, err := h.svc.UpdateGarden(c.Request.Context(), mustActor(c).ID, id, req.toInput(), req.Version) if err != nil { // On a version conflict the service returns the current row so the client // can rebase; everything else is a plain error. if errors.Is(err, domain.ErrVersionConflict) { writeVersionConflict(c, g) return } writeResourceError(c, err) return } c.JSON(http.StatusOK, g) } func (h *handlers) deleteGarden(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } if err := h.svc.DeleteGarden(c.Request.Context(), mustActor(c).ID, id); err != nil { writeResourceError(c, err) return } c.Status(http.StatusNoContent) } // parseIDParam reads a positive int64 path parameter, writing a 400 and // returning ok=false on a malformed value. func parseIDParam(c *gin.Context, name string) (int64, bool) { id, err := strconv.ParseInt(c.Param(name), 10, 64) if err != nil || id < 1 { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid id") return 0, false } return id, true } // writeVersionConflict writes the 409 envelope for an optimistic-concurrency // failure: the standard error object plus the current server row under // "current", so the client can rebase its edit onto the fresh version and retry. // This shape is the contract for every version-guarded (mutable) resource. func writeVersionConflict(c *gin.Context, current any) { c.JSON(http.StatusConflict, gin.H{ "error": gin.H{"code": "VERSION_CONFLICT", "message": "the resource was modified; refetch and retry"}, "current": current, }) } // writeResourceError maps the service-layer sentinel errors shared by every // resource to pansy's JSON error envelope. ErrNotFound is used both for a // genuinely missing row and for one the actor may not see (existence is masked). func writeResourceError(c *gin.Context, err error) { switch { case errors.Is(err, domain.ErrNotFound): writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "not found") case errors.Is(err, domain.ErrForbidden): writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "you don't have access") case errors.Is(err, domain.ErrInvalidInput): writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") case errors.Is(err, domain.ErrVersionConflict): // Reached only if a caller forgot to special-case the conflict (which // needs the current row); still return a coherent 409. writeAPIError(c, http.StatusConflict, "VERSION_CONFLICT", "the resource was modified; refetch and retry") default: slog.Error("api: unhandled service error", "error", err) writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") } }