-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproblem.go
More file actions
238 lines (200 loc) · 6.78 KB
/
Copy pathproblem.go
File metadata and controls
238 lines (200 loc) · 6.78 KB
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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// Package problemjson implements RFC 7807 Problem Details for HTTP APIs.
//
// A [Problem] describes an error response in a machine-readable, structured way.
// Use [New] to construct one from scratch, or [From] to derive one from a reusable
// base. Pre-built constructors for every standard HTTP error status are available
// in this package (e.g. [NotFound], [InternalServerError]).
//
// A Problem can be written to an [http.ResponseWriter] via [Problem.Write], or
// used directly as an [http.Handler] via [Problem.ServeHTTP].
package problemjson
import (
"encoding/json"
"errors"
"fmt"
"net/http"
)
var (
// ErrFailedMarshalling is returned by [Problem.Write] when the problem
// cannot be marshalled to JSON. Because the error occurs before any bytes
// are written, the caller can still send a fallback HTTP response.
ErrFailedMarshalling = errors.New("failed marshalling problem")
// ErrFailedWriting is returned by [Problem.Write] when the JSON payload
// could not be written to the response body. The HTTP status has already
// been committed at this point, so no further HTTP-level recovery is
// possible.
ErrFailedWriting = errors.New("failed writing problem")
)
// Problem is an RFC 7807 Problem Details object.
//
// Set Type to a URI that identifies the problem type. If Type is empty,
// "about:blank" is used when marshalling, as required by the spec.
// Extensions holds arbitrary extra members that are included in the JSON output.
type Problem struct {
Type string
Status int
Title string
Detail string
Instance string
Extensions []Extension
}
// Extension is an additional member included in the problem JSON object.
// RFC 7807 allows problem types to define their own extension members.
type Extension struct {
Key string
Value any
}
// New creates a Problem and applies the given options.
func New(options ...Option) *Problem {
p := &Problem{}
for _, opt := range options {
opt(p)
}
return p
}
// From creates a shallow copy of base and applies the given options on top.
// Extensions are deep-copied so that modifications to the new problem do not
// affect the original.
func From(base *Problem, options ...Option) *Problem {
p := *base
if len(base.Extensions) > 0 {
p.Extensions = make([]Extension, len(base.Extensions))
copy(p.Extensions, base.Extensions)
}
for _, opt := range options {
opt(&p)
}
return &p
}
// MarshalJSON encodes the problem as an RFC 7807 JSON object.
// When Type is empty, "about:blank" is used per the specification.
func (p *Problem) MarshalJSON() ([]byte, error) {
m := make(map[string]any, 5+len(p.Extensions))
if p.Type != "" {
m["type"] = p.Type
} else {
m["type"] = "about:blank"
m["instance"] = p.Instance
}
m["status"] = p.Status
m["title"] = p.Title
if p.Detail != "" {
m["detail"] = p.Detail
}
if p.Instance != "" {
m["instance"] = p.Instance
}
for _, ext := range p.Extensions {
m[ext.Key] = ext.Value
}
return json.Marshal(m)
}
// Write serialises the problem to JSON, sets the Content-Type header to
// "application/problem+json", writes the HTTP status code, and sends the body.
// It returns [ErrFailedMarshalling] if JSON encoding fails (before any bytes are
// sent), or [ErrFailedWriting] if writing the body fails.
func (p *Problem) Write(w http.ResponseWriter) error {
b, err := json.Marshal(p)
if err != nil {
return fmt.Errorf("%w: %w", ErrFailedMarshalling, err)
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(p.Status)
if _, err = w.Write(b); err != nil {
return fmt.Errorf("%w: %w", ErrFailedWriting, err)
}
return nil
}
// ServeHTTP implements [http.Handler], allowing a Problem to be used directly
// as a handler or middleware return value. When Instance is not set, it is
// automatically populated from the request path.
func (p *Problem) ServeHTTP(w http.ResponseWriter, r *http.Request) {
out := p
if p.Instance == "" {
cp := *p
cp.Instance = r.URL.Path
out = &cp
}
if err := out.Write(w); err != nil {
if errors.Is(err, ErrFailedMarshalling) {
// Marshal failed before headers were written; we can still respond.
http.Error(w, "failed to write problem response: "+err.Error(), http.StatusInternalServerError)
}
// ErrFailedWriting: status is already committed, no HTTP-level recovery possible.
// Use Write directly if you need to observe or log this error.
}
}
// =====================================================================
// Functional Composability Options
// =====================================================================
// Option is a functional option that configures a [Problem].
// Options are applied in order by [New] and [From].
type Option func(*Problem)
// Title sets the human-readable summary of the problem type.
func Title(title string) Option {
return func(p *Problem) {
p.Title = title
}
}
// Titlef sets the title using a format string, analogous to [fmt.Sprintf].
func Titlef(title string, values ...any) Option {
return func(p *Problem) {
p.Title = fmt.Sprintf(title, values...)
}
}
// Status sets the HTTP status code of the problem.
func Status(status int) Option {
return func(p *Problem) {
p.Status = status
}
}
// Detail sets a human-readable explanation specific to this occurrence of the problem.
func Detail(detail string) Option {
return func(p *Problem) {
p.Detail = detail
}
}
// Detailf sets the detail using a format string, analogous to [fmt.Sprintf].
func Detailf(detail string, values ...any) Option {
return func(p *Problem) {
p.Detail = fmt.Sprintf(detail, values...)
}
}
// Instance sets the URI reference that identifies the specific occurrence of the problem.
func Instance(instance string) Option {
return func(p *Problem) {
p.Instance = instance
}
}
// InstanceFromRequest sets Instance to the path of the incoming request.
func InstanceFromRequest(r *http.Request) Option {
return func(p *Problem) {
p.Instance = r.URL.Path
}
}
// Type sets the URI that identifies the problem type.
func Type(typeString string) Option {
return func(p *Problem) {
p.Type = typeString
}
}
// Typef sets the problem type URI using a format string, analogous to [fmt.Sprintf].
func Typef(typeString string, values ...any) Option {
return func(p *Problem) {
p.Type = fmt.Sprintf(typeString, values...)
}
}
// With adds an extension member with the given key and value to the problem.
// Extension members are included as top-level fields in the JSON output.
func With(key string, value any) Option {
return func(p *Problem) {
p.Extensions = append(p.Extensions, Extension{Key: key, Value: value})
}
}
// Error adds the error's message as a "reason" extension field.
// This is a convenience wrapper around [With]("reason", err.Error()).
func Error(err error) Option {
return func(p *Problem) {
p.Extensions = append(p.Extensions, Extension{Key: "reason", Value: err.Error()})
}
}