Files
pansy/internal/api/errors.go
T
steve f208da94d6
Build image / build-and-push (push) Successful in 5s
Revision history: change sets + revisions + revert — the undo substrate (#48) (#61)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:07:30 +00:00

96 lines
4.3 KiB
Go

package api
import (
"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,
})
}
// 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
}