initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

195
internal/httpapi/respond.go Normal file
View file

@ -0,0 +1,195 @@
// 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: <detail>"; 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)
})
}
}