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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
195
internal/httpapi/respond.go
Normal file
195
internal/httpapi/respond.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue