problem.go

v0.3.0
Doc Versions Source
1
// Package httpproblem implements RFC 7807 "Problem Details for HTTP APIs"
2
// with a bridge to culpa errors.
3
//
4
// The package provides a typed [Problem] struct, a [FromError] mapper that
5
// extracts [culpa.CodeDetail] and [culpa.PublicDetail] from the error chain,
6
// and a small set of sentinel constructors for common HTTP statuses.
7
//
8
// The package is intentionally a subpackage of culpa rather than a top-level
9
// auxilia package so that culpa core remains free of the net/http import.
10
// Importers who need HTTP responses opt in by importing this package.
11
package httpproblem
12
13
import (
14
	"encoding/json"
15
	"errors"
16
	"net/http"
17
18
	"go.bigb.es/auxilia/culpa"
19
)
20
21
// ContentType is the media type for RFC 7807 problem documents.
22
const ContentType = "application/problem+json"
23
24
// defaultType is the placeholder URI per RFC 7807 §4.2 when no Type is set.
25
const defaultType = "about:blank"
26
27
// Problem is RFC 7807 Problem Details for HTTP APIs.
28
//
29
// A Problem is itself an error so that handlers can return it through the
30
// usual error chain and rely on [FromError] to pass it through unchanged.
31
type Problem struct {
32
	// Type is a URI identifying the problem. Defaults to "about:blank".
33
	Type string `json:"type"`
34
	// Title is a short human-readable summary.
35
	Title string `json:"title"`
36
	// Status is the HTTP status code.
37
	Status int `json:"status"`
38
	// Detail is a user-safe explanation. Empty for opaque internal errors.
39
	Detail string `json:"detail,omitempty"`
40
	// Code is a machine-readable slug.
41
	Code string `json:"code,omitempty"`
42
}
43
44
// Error implements the error interface.
45
func (p Problem) Error() string {
46
	if p.Detail != "" {
47
		return p.Title + ": " + p.Detail
48
	}
49
	return p.Title
50
}
51
52
// Write serializes the Problem as JSON, sets the
53
// Content-Type to application/problem+json, and writes the status code.
54
//
55
// If Type is empty, it is replaced with "about:blank" in the response body
56
// without mutating the receiver.
57
//
58
// Encoding errors are silently dropped: by the time encoding fails the
59
// response has already been partially written and there is nothing useful
60
// to do.
61
func (p Problem) Write(w http.ResponseWriter) {
62
	out := p
63
	if out.Type == "" {
64
		out.Type = defaultType
65
	}
66
	w.Header().Set("Content-Type", ContentType)
67
	w.WriteHeader(out.Status)
68
	_ = json.NewEncoder(w).Encode(out)
69
}
70
71
// FromError maps any error onto a [Problem].
72
//
73
// Resolution order:
74
//
75
//  1. If the error chain contains a [Problem], that value is used as the
76
//     base (so callers can return one directly even when wrapped).
77
//  2. If the chain contains one of the typed sentinel errors
78
//     ([NotFoundError], [BadRequestError], ...), the matching status and
79
//     code are used.
80
//  3. Anything else collapses to a Status 500 problem with Title
81
//     "Internal Server Error" and empty Detail/Code so internal error
82
//     messages do not leak into responses.
83
//
84
// After picking a base, FromError applies culpa overrides discovered in the
85
// chain:
86
//
87
//   - [culpa.CodeDetail] (when Code is a non-empty string) overrides Code.
88
//   - [culpa.PublicDetail] (when Message is non-empty) overrides Detail.
89
//
90
// The override pass also runs for the default 500 case but only sets fields
91
// that are still zero, so a [culpa.PublicDetail] message can opt in to
92
// surfacing a user-safe string while the inner error text still does not
93
// leak.
94
//
95
// FromError(nil) returns the zero Problem (Status 0). Callers should not
96
// invoke it on a nil error.
97
func FromError(err error) Problem {
98
	if err == nil {
99
		return Problem{}
100
	}
101
102
	var p Problem
103
	matched := false
104
105
	// 1. Pre-existing Problem in the chain.
106
	var existing Problem
107
	if errors.As(err, &existing) {
108
		p = existing
109
		matched = true
110
	}
111
112
	// 2. Typed sentinels.
113
	if !matched {
114
		matched = matchSentinel(err, &p)
115
	}
116
117
	// 3. Default to opaque 500.
118
	if !matched {
119
		p = Problem{
120
			Status: http.StatusInternalServerError,
121
			Title:  "Internal Server Error",
122
		}
123
	}
124
125
	// Overrides from culpa details. The override pass never widens what is
126
	// already set on a sentinel-derived problem (Code/Detail can be
127
	// replaced) but for the opaque 500 case Detail starts empty so only an
128
	// explicit PublicDetail can populate it.
129
	for _, code := range culpa.FindDetails[culpa.CodeDetail](err) {
130
		s, ok := code.Code.(string)
131
		if !ok || s == "" {
132
			continue
133
		}
134
		p.Code = s
135
		break
136
	}
137
	for _, pub := range culpa.FindDetails[culpa.PublicDetail](err) {
138
		if pub.Message == "" {
139
			continue
140
		}
141
		p.Detail = pub.Message
142
		break
143
	}
144
145
	return p
146
}
147
148
// matchSentinel checks the chain for one of the typed sentinel errors and,
149
// if found, populates p with the corresponding status/title/code.
150
func matchSentinel(err error, p *Problem) bool {
151
	var (
152
		nf  NotFoundError
153
		br  BadRequestError
154
		cf  ConflictError
155
		un  UnauthorizedError
156
		fb  ForbiddenError
157
		up  UnprocessableError
158
		tmr TooManyRequestsError
159
		in  InternalError
160
	)
161
162
	switch {
163
	case errors.As(err, &nf):
164
		*p = Problem{Status: http.StatusNotFound, Title: "Not Found", Code: "NOT_FOUND", Detail: nf.detail()}
165
	case errors.As(err, &br):
166
		*p = Problem{Status: http.StatusBadRequest, Title: "Bad Request", Code: "BAD_REQUEST", Detail: br.Detail}
167
	case errors.As(err, &cf):
168
		*p = Problem{Status: http.StatusConflict, Title: "Conflict", Code: "CONFLICT", Detail: cf.Detail}
169
	case errors.As(err, &un):
170
		*p = Problem{Status: http.StatusUnauthorized, Title: "Unauthorized", Code: "UNAUTHORIZED", Detail: un.Detail}
171
	case errors.As(err, &fb):
172
		*p = Problem{Status: http.StatusForbidden, Title: "Forbidden", Code: "FORBIDDEN", Detail: fb.Detail}
173
	case errors.As(err, &up):
174
		*p = Problem{Status: http.StatusUnprocessableEntity, Title: "Unprocessable Entity", Code: "UNPROCESSABLE", Detail: up.Detail}
175
	case errors.As(err, &tmr):
176
		*p = Problem{Status: http.StatusTooManyRequests, Title: "Too Many Requests", Code: "TOO_MANY_REQUESTS", Detail: tmr.Detail}
177
	case errors.As(err, &in):
178
		// InternalError carries a detail string but FromError still
179
		// blanks it out: 500 responses must not leak internals unless
180
		// an explicit PublicDetail is attached. We keep the code so the
181
		// caller can switch on it.
182
		*p = Problem{Status: http.StatusInternalServerError, Title: "Internal Server Error", Code: "INTERNAL"}
183
	default:
184
		return false
185
	}
186
	return true
187
}
188

Source Files