196 lines
7 KiB
Go
196 lines
7 KiB
Go
|
|
// 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)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|