Skip to content

Commit 1909b26

Browse files
authored
feat: RFC 9457 compatible (#10)
* feat: RFC 9457 compatible * fix: test content-type (resolve: discussion_r1936938314) * fix: validation as package var (resolve: discussion_r1936947547) * fix: handle response encode error (resolve discussion_r1936947554) * feat: expanding problem details * fix: adjust typo and handle mutex * fix: use default title based on status code * fix: mu.RLock for getters
1 parent f081d27 commit 1909b26

File tree

15 files changed

+580
-129
lines changed

15 files changed

+580
-129
lines changed

examples/chi/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
1616
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
1717
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1818
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
19+
github.com/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
20+
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
1921
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
2022
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2123
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=

examples/chi/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ func main() {
5252
r.Use(middleware.Logger)
5353
r.Use(middleware.Recoverer)
5454

55+
// Define the ProblemBaseURL - doesn't create the handlers
56+
httpsuite.SetProblemBaseURL("http://localhost:8080")
57+
5558
// Define the endpoint POST
5659
r.Post("/submit/{id}", func(w http.ResponseWriter, r *http.Request) {
5760
// Using the function for parameter extraction to the ParseRequest

examples/gorillamux/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
1616
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
1717
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1818
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
19+
github.com/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
20+
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
1921
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
2022
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2123
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=

examples/gorillamux/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ func main() {
4242
// Creating the router with Gorilla Mux
4343
r := mux.NewRouter()
4444

45+
// Define the ProblemBaseURL - doesn't create the handlers
46+
httpsuite.SetProblemBaseURL("http://localhost:8080")
47+
4548
r.HandleFunc("/submit/{id}", func(w http.ResponseWriter, r *http.Request) {
4649
// Using the function for parameter extraction to the ParseRequest
4750
req, err := httpsuite.ParseRequest[*SampleRequest](w, r, GorillaMuxParamExtractor, "id")

examples/stdmux/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module stdmux_example
22

33
go 1.23
44

5-
require github.com/rluders/httpsuite/v2 v2.0.0
5+
require github.com/rluders/httpsuite/v2 v2.0.0
66

77
require (
88
github.com/gabriel-vasile/mimetype v1.4.8 // indirect

examples/stdmux/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
1414
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
1515
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1616
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17+
github.com/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
18+
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
1719
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
1820
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
1921
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=

examples/stdmux/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ func main() {
4646
// Creating the router using the Go standard mux
4747
mux := http.NewServeMux()
4848

49+
// Define the ProblemBaseURL - doesn't create the handlers
50+
httpsuite.SetProblemBaseURL("http://localhost:8080")
51+
4952
// Define the endpoint POST
5053
mux.HandleFunc("/submit/", func(w http.ResponseWriter, r *http.Request) {
5154
// Using the function for parameter extraction to the ParseRequest

problem_details.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package httpsuite
2+
3+
import (
4+
"net/http"
5+
"sync"
6+
)
7+
8+
const BlankUrl = "about:blank"
9+
10+
var (
11+
mu sync.RWMutex
12+
problemBaseURL = BlankUrl
13+
errorTypePaths = map[string]string{
14+
"validation_error": "/errors/validation-error",
15+
"not_found_error": "/errors/not-found",
16+
"server_error": "/errors/server-error",
17+
"bad_request_error": "/errors/bad-request",
18+
}
19+
)
20+
21+
// ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs.
22+
type ProblemDetails struct {
23+
Type string `json:"type"` // A URI reference identifying the problem type.
24+
Title string `json:"title"` // A short, human-readable summary of the problem.
25+
Status int `json:"status"` // The HTTP status code.
26+
Detail string `json:"detail,omitempty"` // Detailed explanation of the problem.
27+
Instance string `json:"instance,omitempty"` // A URI reference identifying the specific instance of the problem.
28+
Extensions map[string]interface{} `json:"extensions,omitempty"` // Custom fields for additional details.
29+
}
30+
31+
// NewProblemDetails creates a ProblemDetails instance with standard fields.
32+
func NewProblemDetails(status int, problemType, title, detail string) *ProblemDetails {
33+
if problemType == "" {
34+
problemType = BlankUrl
35+
}
36+
if title == "" {
37+
title = http.StatusText(status)
38+
if title == "" {
39+
title = "Unknown error"
40+
}
41+
}
42+
return &ProblemDetails{
43+
Type: problemType,
44+
Title: title,
45+
Status: status,
46+
Detail: detail,
47+
}
48+
}
49+
50+
// SetProblemBaseURL configures the base URL used in the "type" field for ProblemDetails.
51+
//
52+
// This function allows applications using httpsuite to provide a custom domain and structure
53+
// for error documentation URLs. By setting this base URL, the library can generate meaningful
54+
// and discoverable problem types.
55+
//
56+
// Parameters:
57+
// - baseURL: The base URL where error documentation is hosted (e.g., "https://api.mycompany.com").
58+
//
59+
// Example usage:
60+
//
61+
// httpsuite.SetProblemBaseURL("https://api.mycompany.com")
62+
//
63+
// Once configured, generated ProblemDetails will include a "type" such as:
64+
//
65+
// "https://api.mycompany.com/errors/validation-error"
66+
//
67+
// If the base URL is not set, the default value for the "type" field will be "about:blank".
68+
func SetProblemBaseURL(baseURL string) {
69+
mu.Lock()
70+
defer mu.Unlock()
71+
problemBaseURL = baseURL
72+
}
73+
74+
// SetProblemErrorTypePath sets or updates the path for a specific error type.
75+
//
76+
// This allows applications to define custom paths for error documentation.
77+
//
78+
// Parameters:
79+
// - errorType: The unique key identifying the error type (e.g., "validation_error").
80+
// - path: The path under the base URL where the error documentation is located.
81+
//
82+
// Example usage:
83+
//
84+
// httpsuite.SetProblemErrorTypePath("validation_error", "/errors/validation-error")
85+
//
86+
// After setting this path, the generated problem type for "validation_error" will be:
87+
//
88+
// "https://api.mycompany.com/errors/validation-error"
89+
func SetProblemErrorTypePath(errorType, path string) {
90+
mu.Lock()
91+
defer mu.Unlock()
92+
errorTypePaths[errorType] = path
93+
}
94+
95+
// SetProblemErrorTypePaths sets or updates multiple paths for different error types.
96+
//
97+
// This allows applications to define multiple custom paths at once.
98+
//
99+
// Parameters:
100+
// - paths: A map of error types to paths (e.g., {"validation_error": "/errors/validation-error"}).
101+
//
102+
// Example usage:
103+
//
104+
// paths := map[string]string{
105+
// "validation_error": "/errors/validation-error",
106+
// "not_found_error": "/errors/not-found",
107+
// }
108+
// httpsuite.SetProblemErrorTypePaths(paths)
109+
//
110+
// This method overwrites any existing paths with the same keys.
111+
func SetProblemErrorTypePaths(paths map[string]string) {
112+
mu.Lock()
113+
defer mu.Unlock()
114+
for errorType, path := range paths {
115+
errorTypePaths[errorType] = path
116+
}
117+
}
118+
119+
// GetProblemTypeURL get the full problem type URL based on the error type.
120+
//
121+
// If the error type is not found in the predefined paths, it returns a default unknown error path.
122+
//
123+
// Parameters:
124+
// - errorType: The unique key identifying the error type (e.g., "validation_error").
125+
//
126+
// Example usage:
127+
//
128+
// problemTypeURL := GetProblemTypeURL("validation_error")
129+
func GetProblemTypeURL(errorType string) string {
130+
mu.RLock()
131+
defer mu.RUnlock()
132+
if path, exists := errorTypePaths[errorType]; exists {
133+
return getProblemBaseURL() + path
134+
}
135+
136+
return BlankUrl
137+
}
138+
139+
// getProblemBaseURL just return the baseURL if it isn't "about:blank"
140+
func getProblemBaseURL() string {
141+
mu.RLock()
142+
defer mu.RUnlock()
143+
if problemBaseURL == BlankUrl {
144+
return ""
145+
}
146+
return problemBaseURL
147+
}

problem_details_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package httpsuite
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func Test_SetProblemBaseURL(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
expected string
14+
}{
15+
{
16+
name: "Set valid base URL",
17+
input: "https://api.example.com",
18+
expected: "https://api.example.com",
19+
},
20+
{
21+
name: "Set base URL to blank",
22+
input: BlankUrl,
23+
expected: BlankUrl,
24+
},
25+
}
26+
27+
for _, tt := range tests {
28+
t.Run(tt.name, func(t *testing.T) {
29+
SetProblemBaseURL(tt.input)
30+
assert.Equal(t, tt.expected, problemBaseURL)
31+
})
32+
}
33+
}
34+
35+
func Test_SetProblemErrorTypePath(t *testing.T) {
36+
tests := []struct {
37+
name string
38+
errorKey string
39+
path string
40+
expected string
41+
}{
42+
{
43+
name: "Set custom error path",
44+
errorKey: "custom_error",
45+
path: "/errors/custom-error",
46+
expected: "/errors/custom-error",
47+
},
48+
{
49+
name: "Override existing path",
50+
errorKey: "validation_error",
51+
path: "/errors/new-validation-error",
52+
expected: "/errors/new-validation-error",
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
SetProblemErrorTypePath(tt.errorKey, tt.path)
59+
assert.Equal(t, tt.expected, errorTypePaths[tt.errorKey])
60+
})
61+
}
62+
}
63+
64+
func Test_GetProblemTypeURL(t *testing.T) {
65+
// Setup initial state
66+
SetProblemBaseURL("https://api.example.com")
67+
SetProblemErrorTypePath("validation_error", "/errors/validation-error")
68+
69+
tests := []struct {
70+
name string
71+
errorType string
72+
expectedURL string
73+
}{
74+
{
75+
name: "Valid error type",
76+
errorType: "validation_error",
77+
expectedURL: "https://api.example.com/errors/validation-error",
78+
},
79+
{
80+
name: "Unknown error type",
81+
errorType: "unknown_error",
82+
expectedURL: BlankUrl,
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
result := GetProblemTypeURL(tt.errorType)
89+
assert.Equal(t, tt.expectedURL, result)
90+
})
91+
}
92+
}
93+
94+
func Test_getProblemBaseURL(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
baseURL string
98+
expectedResult string
99+
}{
100+
{
101+
name: "Base URL is set",
102+
baseURL: "https://api.example.com",
103+
expectedResult: "https://api.example.com",
104+
},
105+
{
106+
name: "Base URL is about:blank",
107+
baseURL: BlankUrl,
108+
expectedResult: "",
109+
},
110+
}
111+
112+
for _, tt := range tests {
113+
t.Run(tt.name, func(t *testing.T) {
114+
problemBaseURL = tt.baseURL
115+
assert.Equal(t, tt.expectedResult, getProblemBaseURL())
116+
})
117+
}
118+
}
119+
120+
func Test_NewProblemDetails(t *testing.T) {
121+
tests := []struct {
122+
name string
123+
status int
124+
problemType string
125+
title string
126+
detail string
127+
expectedType string
128+
}{
129+
{
130+
name: "All fields provided",
131+
status: 400,
132+
problemType: "https://api.example.com/errors/validation-error",
133+
title: "Validation Error",
134+
detail: "Invalid input",
135+
expectedType: "https://api.example.com/errors/validation-error",
136+
},
137+
{
138+
name: "Empty problem type",
139+
status: 404,
140+
problemType: "",
141+
title: "Not Found",
142+
detail: "The requested resource was not found",
143+
expectedType: BlankUrl,
144+
},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
details := NewProblemDetails(tt.status, tt.problemType, tt.title, tt.detail)
150+
assert.Equal(t, tt.status, details.Status)
151+
assert.Equal(t, tt.title, details.Title)
152+
assert.Equal(t, tt.detail, details.Detail)
153+
assert.Equal(t, tt.expectedType, details.Type)
154+
})
155+
}
156+
}

0 commit comments

Comments
 (0)