// Package apierror is the single boundary between culpa-coded errors that // flow through the lethe server and the RFC 7807 `application/problem+json` // documents the HTTP layer hands back to clients. // // Render is the only exported entry point: it inspects the error's culpa // CodeDetail, maps the code to an HTTP status, builds a Problem document // from any attached PublicDetail, and writes it. 5xx responses log the full // stack trace via scribe.Err first, then redact the body so internal details // never leak to the client. package apierror import ( "encoding/json" "log/slog" "net/http" "go.bigb.es/auxilia/culpa" "go.bigb.es/auxilia/scribe" ) // Problem is the RFC 7807 representation. The custom `code` extension holds // the machine-readable culpa code for client-side error handling, and // `errors` is reserved for per-line / per-field validation errors that the // ingest handler (Phase 7) produces. type Problem struct { Type string `json:"type,omitempty"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail,omitempty"` Code string `json:"code,omitempty"` Instance string `json:"instance,omitempty"` Errors []ProblemError `json:"errors,omitempty"` } // ProblemError is one element of the `errors` extension, used for batched // validation reports (e.g. NDJSON ingest line failures). type ProblemError struct { Line int `json:"line,omitempty"` Field string `json:"field,omitempty"` Code string `json:"code,omitempty"` Message string `json:"message"` } // codeStatus is the authoritative culpa-code → HTTP-status map. Any code // not listed here falls through to 500 and gets the sanitized treatment. var codeStatus = map[string]int{ "NOT_FOUND": http.StatusNotFound, "METHOD_NOT_ALLOWED": http.StatusMethodNotAllowed, "INVALID": http.StatusBadRequest, "VALIDATION": http.StatusBadRequest, "UNAUTHORIZED": http.StatusUnauthorized, "FORBIDDEN": http.StatusForbidden, "CONFLICT": http.StatusConflict, "TOO_LARGE": http.StatusRequestEntityTooLarge, } // Render is the single boundary that translates a culpa-wrapped error into // an RFC 7807 response. Callers must not mutate w after invoking Render. // // 5xx responses log the full error (with stack trace) via scribe.Err before // redacting the body, so operators can diagnose without leaking specifics // to clients. 4xx responses surface the culpa PublicDetail as `detail` and // the CodeDetail as `code`. func Render(w http.ResponseWriter, r *http.Request, err error) { status, code := lookup(err) p := Problem{ Title: http.StatusText(status), Status: status, Instance: r.URL.Path, } if status >= 500 { // Log the full chain (with stack) before sanitizing the response. slog.Default().ErrorContext(r.Context(), "internal error", scribe.Err(err)) p.Detail = "internal server error" } else { p.Code = code var pub culpa.PublicDetail if culpa.FindDetail(err, &pub) { p.Detail = pub.Message } } w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(status) // Best-effort encode; if the client connection is gone there is nothing // useful to do beyond logging. if encErr := json.NewEncoder(w).Encode(p); encErr != nil { slog.Default().ErrorContext(r.Context(), "encode problem document", scribe.Err(encErr), slog.Int("status", status), ) } } // lookup walks the culpa chain for a CodeDetail and returns the matched // (status, code) pair. Missing or unknown codes default to (500, ""). func lookup(err error) (int, string) { var cd culpa.CodeDetail if !culpa.FindDetail(err, &cd) { return http.StatusInternalServerError, "" } codeStr, ok := cd.Code.(string) if !ok { return http.StatusInternalServerError, "" } if status, ok := codeStatus[codeStr]; ok { return status, codeStr } return http.StatusInternalServerError, codeStr }