Skip to content

Commit c459205

Browse files
committed
feat: implement rfc 7807 core lib and tests
1 parent 6c89f1c commit c459205

5 files changed

Lines changed: 588 additions & 0 deletions

File tree

http.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package problem
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
)
7+
8+
// Write automatically writes the Problem Details as a JSON HTTP response
9+
// with the correct Application/Problem+JSON content type.
10+
func (p *Problem) Write(w http.ResponseWriter) error {
11+
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
12+
w.Header().Set("X-Content-Type-Options", "nosniff")
13+
w.WriteHeader(p.Status)
14+
15+
enc := json.NewEncoder(w)
16+
// Optionally prevent escaping HTML inside JSON to keep URI strings clean
17+
enc.SetEscapeHTML(false)
18+
19+
if err := enc.Encode(p); err != nil {
20+
return err
21+
}
22+
return nil
23+
}
24+
25+
// Handler is a generic middleware interface or handler concept, but realistically
26+
// you just need `problem.Write(w)`.
27+
// We can provide a functional wrapper if you wanted, but usually `Write` is enough.

options.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package problem
2+
3+
import "fmt"
4+
5+
// Option is a functional option for customizing a Problem details object.
6+
type Option func(*Problem)
7+
8+
// WithDetail sets the human-readable explanation specific to this occurrence of the problem.
9+
func WithDetail(detail string) Option {
10+
return func(p *Problem) {
11+
p.Detail = detail
12+
}
13+
}
14+
15+
// WithDetailf formats and sets the detail message.
16+
func WithDetailf(format string, args ...any) Option {
17+
return func(p *Problem) {
18+
p.Detail = fmt.Sprintf(format, args...)
19+
}
20+
}
21+
22+
// WithInstance sets the URI reference that identifies the specific occurrence of the problem.
23+
func WithInstance(instance string) Option {
24+
return func(p *Problem) {
25+
p.Instance = instance
26+
}
27+
}
28+
29+
// WithExtension sets a single extension field.
30+
// According to RFC 7807, extension members are additional members within the Problem Details object.
31+
func WithExtension(key string, value any) Option {
32+
return func(p *Problem) {
33+
if p.Extensions == nil {
34+
p.Extensions = make(map[string]any)
35+
}
36+
p.Extensions[key] = value
37+
}
38+
}
39+
40+
// WithExtensions merges multiple extension fields at once.
41+
func WithExtensions(ext map[string]any) Option {
42+
return func(p *Problem) {
43+
if p.Extensions == nil {
44+
p.Extensions = make(map[string]any)
45+
}
46+
for k, v := range ext {
47+
p.Extensions[k] = v
48+
}
49+
}
50+
}
51+
52+
// WithErr sets the underlying error that caused this problem.
53+
// This allows the Problem to wrap another error, supporting standard Go wrapping (errors.Is/As).
54+
func WithErr(err error) Option {
55+
return func(p *Problem) {
56+
p.err = err
57+
}
58+
}

problem.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package problem
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
)
8+
9+
// Problem represents an RFC 7807 Problem Details object.
10+
type Problem struct {
11+
// Type is a URI reference that identifies the problem type.
12+
Type string `json:"type"`
13+
14+
// Title is a short, human-readable summary of the problem type.
15+
Title string `json:"title"`
16+
17+
// Status is the HTTP status code for this occurrence of the problem.
18+
Status int `json:"status"`
19+
20+
// Detail is a human-readable explanation specific to this occurrence of the problem.
21+
Detail string `json:"detail,omitempty"`
22+
23+
// Instance is a URI reference that identifies the specific occurrence of the problem.
24+
Instance string `json:"instance,omitempty"`
25+
26+
// Extensions contains additional properties beyond the standard RFC 7807 fields.
27+
Extensions map[string]any `json:"-"`
28+
29+
// err is the underlying error that caused this problem, if any.
30+
// This makes Problem compatible with Go 1.13+ error wrapping.
31+
err error
32+
}
33+
34+
// MarshalJSON implements custom JSON marshaling to flatten the Extensions
35+
// into the top-level object, as required by RFC 7807.
36+
func (p Problem) MarshalJSON() ([]byte, error) {
37+
// Start with standard fields
38+
base := map[string]any{
39+
"type": p.Type,
40+
"title": p.Title,
41+
"status": p.Status,
42+
}
43+
44+
if p.Detail != "" {
45+
base["detail"] = p.Detail
46+
}
47+
if p.Instance != "" {
48+
base["instance"] = p.Instance
49+
}
50+
51+
// Merge extensions to the top level, avoiding overwriting standard fields
52+
protectedFields := map[string]bool{
53+
"type": true, "title": true, "status": true,
54+
"detail": true, "instance": true,
55+
}
56+
57+
for k, v := range p.Extensions {
58+
if !protectedFields[k] {
59+
base[k] = v
60+
}
61+
}
62+
63+
return json.Marshal(base)
64+
}
65+
66+
// UnmarshalJSON implements custom JSON unmarshaling to extract non-standard
67+
// fields into the Extensions map.
68+
func (p *Problem) UnmarshalJSON(data []byte) error {
69+
var raw map[string]any
70+
if err := json.Unmarshal(data, &raw); err != nil {
71+
return err
72+
}
73+
74+
p.Extensions = make(map[string]any)
75+
protectedFields := map[string]bool{
76+
"type": true, "title": true, "status": true,
77+
"detail": true, "instance": true,
78+
}
79+
80+
for k, v := range raw {
81+
switch k {
82+
case "type":
83+
if s, ok := v.(string); ok {
84+
p.Type = s
85+
}
86+
case "title":
87+
if s, ok := v.(string); ok {
88+
p.Title = s
89+
}
90+
case "status":
91+
if f, ok := v.(float64); ok { // JSON numbers decode to float64 by default
92+
p.Status = int(f)
93+
}
94+
case "detail":
95+
if s, ok := v.(string); ok {
96+
p.Detail = s
97+
}
98+
case "instance":
99+
if s, ok := v.(string); ok {
100+
p.Instance = s
101+
}
102+
default:
103+
if !protectedFields[k] {
104+
p.Extensions[k] = v
105+
}
106+
}
107+
}
108+
return nil
109+
}
110+
111+
// Error implements the standard Go `error` interface.
112+
func (p *Problem) Error() string {
113+
if p.err != nil {
114+
return fmt.Sprintf("[%d] %s: %s: %v", p.Status, p.Type, p.Title, p.err)
115+
}
116+
if p.Detail != "" {
117+
return fmt.Sprintf("[%d] %s: %s", p.Status, p.Type, p.Detail)
118+
}
119+
return fmt.Sprintf("[%d] %s: %s", p.Status, p.Type, p.Title)
120+
}
121+
122+
// Unwrap makes Problem compatible with Go 1.13+ error wrapping.
123+
// It returns the underlying error if one was optionally provided.
124+
func (p *Problem) Unwrap() error {
125+
return p.err
126+
}
127+
128+
// IsProblem checks if an error is or wraps a *Problem details object.
129+
func IsProblem(err error) (*Problem, bool) {
130+
var p *Problem
131+
if err != nil && errors.As(err, &p) {
132+
return p, true
133+
}
134+
return nil, false
135+
}

problem_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package problem_test
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/semmidev/problem"
12+
)
13+
14+
func TestProblemSerialization(t *testing.T) {
15+
t.Run("marshaling", func(t *testing.T) {
16+
p := problem.New(
17+
problem.BadRequest,
18+
problem.WithDetail("Invalid input provided"),
19+
problem.WithInstance("/api/v1/users"),
20+
problem.WithExtension("trace_id", "req-1234"),
21+
problem.WithExtension("balance", 5000),
22+
)
23+
24+
data, err := json.Marshal(p)
25+
if err != nil {
26+
t.Fatalf("failed to marshal: %v", err)
27+
}
28+
29+
var raw map[string]any
30+
if err := json.Unmarshal(data, &raw); err != nil {
31+
t.Fatalf("failed to unmarshal back to map: %v", err)
32+
}
33+
34+
// Ensure top-level fields match expectations
35+
if raw["type"] != "about:blank" {
36+
t.Errorf("expected type 'about:blank', got %v", raw["type"])
37+
}
38+
if raw["title"] != "Bad Request" {
39+
t.Errorf("expected title 'Bad Request', got %v", raw["title"])
40+
}
41+
if raw["status"].(float64) != float64(http.StatusBadRequest) {
42+
t.Errorf("expected status %v, got %v", http.StatusBadRequest, raw["status"])
43+
}
44+
if raw["detail"] != "Invalid input provided" {
45+
t.Errorf("expected detail, got %v", raw["detail"])
46+
}
47+
if raw["instance"] != "/api/v1/users" {
48+
t.Errorf("expected instance, got %v", raw["instance"])
49+
}
50+
if raw["trace_id"] != "req-1234" {
51+
t.Errorf("expected trace_id extension, got %v", raw["trace_id"])
52+
}
53+
if raw["balance"].(float64) != 5000 {
54+
t.Errorf("expected balance extension, got %v", raw["balance"])
55+
}
56+
})
57+
58+
t.Run("unmarshaling", func(t *testing.T) {
59+
rawJSON := []byte(`{
60+
"type": "https://example.com/probs/out-of-credit",
61+
"title": "You do not have enough credit.",
62+
"status": 403,
63+
"detail": "Your current balance is 30, but that costs 50.",
64+
"instance": "/account/12345/msgs/abc",
65+
"balance": 30,
66+
"accounts": ["/account/12345", "/account/67890"]
67+
}`)
68+
69+
var p problem.Problem
70+
if err := json.Unmarshal(rawJSON, &p); err != nil {
71+
t.Fatalf("failed to unmarshal: %v", err)
72+
}
73+
74+
if p.Type != "https://example.com/probs/out-of-credit" {
75+
t.Errorf("bad type: %s", p.Type)
76+
}
77+
if p.Title != "You do not have enough credit." {
78+
t.Errorf("bad title: %s", p.Title)
79+
}
80+
if p.Status != 403 {
81+
t.Errorf("bad status: %d", p.Status)
82+
}
83+
if p.Detail != "Your current balance is 30, but that costs 50." {
84+
t.Errorf("bad detail: %s", p.Detail)
85+
}
86+
if p.Instance != "/account/12345/msgs/abc" {
87+
t.Errorf("bad instance: %s", p.Instance)
88+
}
89+
90+
// Extensions
91+
if p.Extensions == nil {
92+
t.Fatal("expected extensions to be initialized")
93+
}
94+
if p.Extensions["balance"].(float64) != 30 {
95+
t.Errorf("bad extension balance: %v", p.Extensions["balance"])
96+
}
97+
98+
// check accounts list length
99+
accounts, ok := p.Extensions["accounts"].([]any)
100+
if !ok || len(accounts) != 2 {
101+
t.Errorf("bad extension accounts array: %v", p.Extensions["accounts"])
102+
}
103+
})
104+
}
105+
106+
func TestProblemErrorWrapping(t *testing.T) {
107+
origErr := errors.New("underlying generic network timeout")
108+
109+
p := problem.Wrap(origErr, problem.GatewayTimeout, problem.WithDetail("Upstream service failed"))
110+
111+
// Implement Standard Error()
112+
if p.Error() == "" {
113+
t.Error("error string should not be empty")
114+
}
115+
116+
// Unwrap
117+
if unwrapped := p.Unwrap(); unwrapped != origErr {
118+
t.Errorf("expected to unwrap origErr, got: %v", unwrapped)
119+
}
120+
121+
// errors.Is check
122+
if !errors.Is(p, origErr) {
123+
t.Error("errors.Is should return true for underlying error")
124+
}
125+
126+
// errors.As check (getting Problem back out of an error)
127+
var asErr *problem.Problem
128+
if !errors.As(p, &asErr) {
129+
t.Error("errors.As should extract Problem from itself")
130+
}
131+
132+
// Double-wrapping scenario (simulating fmt.Errorf)
133+
wrap2 := fmt.Errorf("adding more context: %w", p)
134+
if !errors.As(wrap2, &asErr) {
135+
t.Error("errors.As should extract Problem from a wrapped generic error")
136+
}
137+
138+
// IsProblem convenience
139+
if extracted, ok := problem.IsProblem(wrap2); !ok || extracted == nil {
140+
t.Error("IsProblem should successfully extract the Problem from a wrapped error")
141+
}
142+
}
143+
144+
func TestProblemHTTPWrite(t *testing.T) {
145+
p := problem.New(problem.InternalServerError, problem.WithDetail("DB connection failed"))
146+
147+
rec := httptest.NewRecorder()
148+
if err := p.Write(rec); err != nil {
149+
t.Fatalf("Write should not return error: %v", err)
150+
}
151+
152+
res := rec.Result()
153+
if res.StatusCode != 500 {
154+
t.Errorf("bad status code, expected 500 got %d", res.StatusCode)
155+
}
156+
157+
contentType := res.Header.Get("Content-Type")
158+
if contentType != "application/problem+json; charset=utf-8" {
159+
t.Errorf("bad content type: %s", contentType)
160+
}
161+
162+
if snif := res.Header.Get("X-Content-Type-Options"); snif != "nosniff" {
163+
t.Errorf("expected nosniff, got %v", snif)
164+
}
165+
}

0 commit comments

Comments
 (0)