// Package httpapi contains the HTTP transport: routing, middleware and // handlers. // // The API is split into two surfaces with deliberately different postures: // // - /api/v1 is the administrative API. It requires authentication, it is never // cached, and every mutation is audited. // - /rt/v1 is the runtime API. It is what browsers and applications fetch, it // is designed to be cached aggressively at the CDN, and it is public by // default because a theming service that requires a credential cannot be // consumed from a stylesheet link tag. // // Handlers contain no business logic. They decode a request, call the store, // encode a response and translate errors. Anything that is worth reasoning about // lives in the domain or tokenengine packages where it can be tested without an // HTTP server. package httpapi import ( "errors" "net/http" "strconv" "strings" "github.com/urpi/go-bananas/internal/domain" ) // APIError is the uniform error body returned by the API. // // A stable machine-readable code is what lets a client react without parsing // prose, and the human message exists for the log line and the terminal. type APIError struct { // Code is a stable, machine-readable identifier. Code string `json:"code"` // Message is a human-readable explanation. Message string `json:"message"` // Details carries per-field validation problems, when applicable. Details []FieldError `json:"details,omitempty"` // RequestID correlates the response with server logs. RequestID string `json:"requestId,omitempty"` } // FieldError is one validation problem. type FieldError struct { // Field is the JSON path or dotted token path that failed. Field string `json:"field"` // Message explains the problem. Message string `json:"message"` } // Error codes. These are part of the API contract: a client may branch on them, // so they must not change meaning between releases. const ( CodeBadRequest = "bad_request" CodeValidation = "validation_failed" CodeUnauthorized = "unauthorized" CodeForbidden = "forbidden" CodeNotFound = "not_found" CodeConflict = "conflict" CodeImmutable = "immutable" CodeRateLimited = "rate_limited" CodeInternal = "internal_error" CodeNotConfigured = "not_configured" CodeUnsupported = "unsupported_media_type" CodePayloadTooBig = "payload_too_large" ) // writeJSON encodes a value as JSON with the given status. func writeJSON(w http.ResponseWriter, r *http.Request, status int, payload any) { body, err := marshalJSON(payload) if err != nil { // Encoding failed, which means the handler produced something // unserialisable. Report it as an internal error rather than panicking // on the request goroutine. writeError(w, r, status, CodeInternal, "response could not be encoded", nil) return } header := w.Header() header.Set("Content-Type", "application/json; charset=utf-8") // Admin responses are per-user and change on every write; they must never be // cached by an intermediary. header.Set("Cache-Control", "no-store") w.WriteHeader(status) if r.Method == http.MethodHead { return } //nolint:gosec // G705: body is a marshalled APIError with no user-controlled // string fields. The Content-Type above is application/json, so it is not // interpreted as a document by the browser even if a field were. _, _ = w.Write(body) } // writeError emits a uniform error body. func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string, details []FieldError) { payload := APIError{Code: code, Message: message, Details: details} if id := requestIDFrom(r.Context()); id != "" { payload.RequestID = id } writeJSON(w, r, status, payload) } // writeProblem translates a domain sentinel into an HTTP status and error code. // // This is the single mapping point between the domain's error vocabulary and the // wire. Handlers therefore never choose status codes themselves, which is what // keeps the API consistent. func writeProblem(w http.ResponseWriter, r *http.Request, err error) { switch { case err == nil: return case errors.Is(err, domain.ErrInvalid): writeError(w, r, http.StatusBadRequest, CodeValidation, cleanError(err), nil) case errors.Is(err, domain.ErrUnauthorized): writeError(w, r, http.StatusUnauthorized, CodeUnauthorized, cleanError(err), nil) case errors.Is(err, domain.ErrForbidden): writeError(w, r, http.StatusForbidden, CodeForbidden, cleanError(err), nil) case errors.Is(err, domain.ErrNotFound): writeError(w, r, http.StatusNotFound, CodeNotFound, cleanError(err), nil) case errors.Is(err, domain.ErrConflict): writeError(w, r, http.StatusConflict, CodeConflict, cleanError(err), nil) case errors.Is(err, domain.ErrImmutable): writeError(w, r, http.StatusConflict, CodeImmutable, cleanError(err), nil) default: writeError(w, r, http.StatusInternalServerError, CodeInternal, cleanError(err), nil) } } // cleanError strips the sentinel prefix from an error message so the client sees // the useful part, and truncates anything pathologically long. func cleanError(err error) string { if err == nil { return "" } msg := err.Error() // The sentinels are phrased as "invalid input: "; the prefix adds // nothing once the status code has been chosen. for _, prefix := range []string{ "invalid input: ", "conflict: ", "not found: ", "forbidden: ", "unauthorized: ", "resource is immutable: ", } { msg = strings.TrimPrefix(msg, prefix) } if len(msg) > 512 { msg = msg[:512] + "…" } return msg } // queryInt reads an integer query parameter with bounds. // // The bounds are named lowest and highest rather than min and max because the // latter are builtins since Go 1.21, and shadowing them here would silently // replace the language function with a parameter everywhere in this body. func queryInt(r *http.Request, name string, fallback, lowest, highest int) int { raw := strings.TrimSpace(r.URL.Query().Get(name)) if raw == "" { return fallback } value, err := strconv.Atoi(raw) if err != nil { return fallback } if value < lowest { return lowest } if value > highest { return highest } return value } // requireRole returns middleware that refuses callers below the given role. // // Roles are checked against the highest role the identity carries, so an admin // may always call an editor or viewer route. The check happens in the server // rather than the UI, because a hidden button is not an authorisation control. func requireRole(minimum domain.Role) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { identity, err := identityFrom(r.Context()) if err != nil || identity == nil { writeError(w, r, http.StatusUnauthorized, CodeUnauthorized, "authentication is required", nil) return } if !identity.HighestRole().AtLeast(minimum) { writeError(w, r, http.StatusForbidden, CodeForbidden, "this action requires the "+string(minimum)+" role", nil) return } next.ServeHTTP(w, r) }) } }