// 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
}