~bigbes/lethe

ref: d6bca49b6562e12a3491b37bd464d667d306e696 lethe/internal/pkg/apierror/apierror.go -rw-r--r-- 4.1 KiB
d6bca49b — Eugene Blikh docs(lethe-web-ui-palette-savedsearch): plan + execute deviations a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// 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,
	// UNSUPPORTED_MEDIA_TYPE is used by the ingest handler when the request's
	// Content-Type is not the canonical NDJSON media type. Added in Phase 7.
	"UNSUPPORTED_MEDIA_TYPE": http.StatusUnsupportedMediaType,
}

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