package api import ( "encoding/json" "errors" "log/slog" "net/http" "strconv" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // writeServiceError maps a service-layer sentinel error to pansy's JSON error // envelope ({"error":{"code","message"}}). One mapper serves every handler // (auth and resources) so the status/code for a given sentinel is defined once. // // ErrNotFound covers both a genuinely missing row and one the actor may not see // (existence is masked). ErrVersionConflict here is a fallback that omits the // current row — handlers that can produce one special-case it with // writeVersionConflict before falling through here. Login failures never leak // which of email/password was wrong. func writeServiceError(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.ErrVersionConflict): writeAPIError(c, http.StatusConflict, "VERSION_CONFLICT", "the resource was modified; refetch and retry") case errors.Is(err, domain.ErrPlantInUse): writeAPIError(c, http.StatusConflict, "PLANT_IN_USE", "this plant is used by plantings and can't be deleted") case errors.Is(err, domain.ErrShareUserNotFound): writeAPIError(c, http.StatusNotFound, "SHARE_USER_NOT_FOUND", "no account with that email") case errors.Is(err, domain.ErrCannotShareWithSelf): writeAPIError(c, http.StatusBadRequest, "CANNOT_SHARE_WITH_SELF", "you can't share a garden with yourself") case errors.Is(err, domain.ErrShareExists): writeAPIError(c, http.StatusConflict, "SHARE_EXISTS", "this garden is already shared with that user") case errors.Is(err, domain.ErrInvalidCredentials): writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password") case errors.Is(err, domain.ErrEmailTaken): writeAPIError(c, http.StatusConflict, "EMAIL_TAKEN", "an account with that email already exists") case errors.Is(err, domain.ErrRegistrationClosed): writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed") case errors.Is(err, domain.ErrLocalAuthDisabled): writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled") case errors.Is(err, domain.ErrOIDCNoEmail): writeAPIError(c, http.StatusBadRequest, "OIDC_NO_EMAIL", "the identity provider returned no email") case errors.Is(err, domain.ErrOIDCEmailUnverified): writeAPIError(c, http.StatusForbidden, "OIDC_EMAIL_UNVERIFIED", "the identity provider's email is not verified") case errors.Is(err, domain.ErrOIDCIdentityConflict): writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account") case errors.Is(err, domain.ErrInvalidInput): writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") default: slog.Error("api: unhandled service error", "error", err) writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") } } // 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, }) } // parseNullable decodes any JSON value into (value, present) for a nullable // column: absent → (nil, false); explicit null → (nil, true); anything else → // the decoded value. That three-way distinction is what lets a PATCH tell "clear // this to NULL" apart from "leave it alone", and every nullable field in the API // needs it, so it lives here rather than in whichever file happened to want it // first. func parseNullable[T any](raw json.RawMessage) (*T, bool, error) { if len(raw) == 0 { return nil, false, nil } if string(raw) == "null" { return nil, true, nil } var v T if err := json.Unmarshal(raw, &v); err != nil { return nil, false, err } return &v, true, nil } // intQuery reads a non-negative integer query parameter, falling back to def on // an absent or malformed value. func intQuery(c *gin.Context, name string, def int) int { raw := c.Query(name) if raw == "" { return def } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return def } return v } // 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 }