Skip to content

Commit 03f137f

Browse files
committed
feat: expanding problem details
1 parent 0c031ff commit 03f137f

File tree

11 files changed

+350
-35
lines changed

11 files changed

+350
-35
lines changed

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

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

request.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,12 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request,
6464
// Decode JSON body if present
6565
if r.Body != http.NoBody {
6666
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
67-
problem := NewProblemDetails(http.StatusBadRequest, "Invalid Request", err.Error())
67+
problem := NewProblemDetails(
68+
http.StatusBadRequest,
69+
GetProblemTypeURL("bad_request_error"),
70+
"Invalid Request",
71+
err.Error(),
72+
)
6873
SendResponse[any](w, http.StatusBadRequest, nil, problem, nil)
6974
return empty, err
7075
}
@@ -79,12 +84,23 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request,
7984
for _, key := range pathParams {
8085
value := paramExtractor(r, key)
8186
if value == "" {
82-
problem := NewProblemDetails(http.StatusBadRequest, "Missing Parameter", "Parameter "+key+" not found in request")
87+
problem := NewProblemDetails(
88+
http.StatusBadRequest,
89+
GetProblemTypeURL("bad_request_error"),
90+
"Missing Parameter",
91+
"Parameter "+key+" not found in request",
92+
)
8393
SendResponse[any](w, http.StatusBadRequest, nil, problem, nil)
8494
return empty, errors.New("missing parameter: " + key)
8595
}
96+
8697
if err := request.SetParam(key, value); err != nil {
87-
problem := NewProblemDetails(http.StatusInternalServerError, "Parameter Error", "Failed to set field "+key)
98+
problem := NewProblemDetails(
99+
http.StatusInternalServerError,
100+
GetProblemTypeURL("sever_error"),
101+
"Parameter Error",
102+
"Failed to set field "+key,
103+
)
88104
problem.Extensions = map[string]interface{}{"error": err.Error()}
89105
SendResponse[any](w, http.StatusInternalServerError, nil, problem, nil)
90106
return empty, err

request_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func Test_ParseRequest(t *testing.T) {
8282
},
8383
want: nil,
8484
wantErr: assert.Error,
85-
wantDetail: NewProblemDetails(http.StatusBadRequest, "Validation Error", "One or more fields failed validation."),
85+
wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Validation Error", "One or more fields failed validation."),
8686
},
8787
{
8888
name: "Invalid JSON Body",
@@ -97,7 +97,7 @@ func Test_ParseRequest(t *testing.T) {
9797
},
9898
want: nil,
9999
wantErr: assert.Error,
100-
wantDetail: NewProblemDetails(http.StatusBadRequest, "Invalid Request", "invalid character 'i' looking for beginning of object key string"),
100+
wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Invalid Request", "invalid character 'i' looking for beginning of object key string"),
101101
},
102102
}
103103

0 commit comments

Comments
 (0)