Skip to content

Commit 90bc8c2

Browse files
committed
feat: RFC 9457 compatible
1 parent f081d27 commit 90bc8c2

File tree

10 files changed

+234
-127
lines changed

10 files changed

+234
-127
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/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/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=

request.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,37 +61,46 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request,
6161
var empty T
6262
defer func() { _ = r.Body.Close() }()
6363

64+
// Decode JSON body if present
6465
if r.Body != http.NoBody {
6566
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
66-
SendResponse[any](w, http.StatusBadRequest, nil, []Error{{Code: http.StatusBadRequest, Message: "Invalid JSON format"}}, nil)
67+
problem := NewProblemDetails(http.StatusBadRequest, "Invalid Request", err.Error())
68+
SendResponse[any](w, http.StatusBadRequest, nil, problem, nil)
6769
return empty, err
6870
}
6971
}
7072

73+
// Ensure request object is properly initialized
7174
if isRequestNil(request) {
7275
request = reflect.New(reflect.TypeOf(request).Elem()).Interface().(T)
7376
}
7477

78+
// Extract and set URL parameters
7579
for _, key := range pathParams {
7680
value := paramExtractor(r, key)
7781
if value == "" {
78-
SendResponse[any](w, http.StatusBadRequest, nil, []Error{{Code: http.StatusBadRequest, Message: "Parameter " + key + " not found in request"}}, nil)
82+
problem := NewProblemDetails(http.StatusBadRequest, "Missing Parameter", "Parameter "+key+" not found in request")
83+
SendResponse[any](w, http.StatusBadRequest, nil, problem, nil)
7984
return empty, errors.New("missing parameter: " + key)
8085
}
8186
if err := request.SetParam(key, value); err != nil {
82-
SendResponse[any](w, http.StatusInternalServerError, nil, []Error{{Code: http.StatusInternalServerError, Message: "Failed to set field " + key, Details: err.Error()}}, nil)
87+
problem := NewProblemDetails(http.StatusInternalServerError, "Parameter Error", "Failed to set field "+key)
88+
problem.Extensions = map[string]interface{}{"error": err.Error()}
89+
SendResponse[any](w, http.StatusInternalServerError, nil, problem, nil)
8390
return empty, err
8491
}
8592
}
8693

94+
// Validate the request
8795
if validationErr := IsRequestValid(request); validationErr != nil {
88-
SendResponse[any](w, http.StatusBadRequest, nil, []Error{{Code: http.StatusBadRequest, Message: "Validation error", Details: validationErr}}, nil)
96+
SendResponse[any](w, http.StatusBadRequest, nil, validationErr, nil)
8997
return empty, errors.New("validation error")
9098
}
9199

92100
return request, nil
93101
}
94102

103+
// isRequestNil checks if a request object is nil or an uninitialized pointer.
95104
func isRequestNil(i interface{}) bool {
96105
return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil())
97106
}

request_test.go

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import (
1313
"testing"
1414
)
1515

16-
// TestRequest includes custom type annotation for UUID type
16+
// TestRequest includes custom type annotation for UUID type.
1717
type TestRequest struct {
18-
ID int `json:"id" validate:"required"`
18+
ID int `json:"id" validate:"required,gt=0"`
1919
Name string `json:"name" validate:"required"`
2020
}
2121

@@ -33,14 +33,9 @@ func (r *TestRequest) SetParam(fieldName, value string) error {
3333
return nil
3434
}
3535

36-
// This implementation extracts parameters from the path, assuming the request URL follows a pattern
37-
// like "/test/{id}", where "id" is a path parameter.
36+
// MyParamExtractor extracts parameters from the path, assuming the request URL follows a pattern like "/test/{id}".
3837
func MyParamExtractor(r *http.Request, key string) string {
39-
// Here, we can extract parameters directly from the URL path for simplicity.
40-
// Example: for "/test/123", if key is "ID", we want to capture "123".
4138
pathSegments := strings.Split(r.URL.Path, "/")
42-
43-
// You should know how the path is structured; in this case, we expect the ID to be the second segment.
4439
if len(pathSegments) > 2 && key == "ID" {
4540
return pathSegments[2]
4641
}
@@ -54,10 +49,11 @@ func Test_ParseRequest(t *testing.T) {
5449
pathParams []string
5550
}
5651
type testCase[T any] struct {
57-
name string
58-
args args
59-
want *TestRequest
60-
wantErr assert.ErrorAssertionFunc
52+
name string
53+
args args
54+
want *TestRequest
55+
wantErr assert.ErrorAssertionFunc
56+
wantDetail *ProblemDetails
6157
}
6258

6359
tests := []testCase[TestRequest]{
@@ -73,8 +69,9 @@ func Test_ParseRequest(t *testing.T) {
7369
}(),
7470
pathParams: []string{"ID"},
7571
},
76-
want: &TestRequest{ID: 123, Name: "Test"},
77-
wantErr: assert.NoError,
72+
want: &TestRequest{ID: 123, Name: "Test"},
73+
wantErr: assert.NoError,
74+
wantDetail: nil,
7875
},
7976
{
8077
name: "Missing body",
@@ -83,8 +80,9 @@ func Test_ParseRequest(t *testing.T) {
8380
r: httptest.NewRequest("POST", "/test/123", nil),
8481
pathParams: []string{"ID"},
8582
},
86-
want: nil,
87-
wantErr: assert.Error,
83+
want: nil,
84+
wantErr: assert.Error,
85+
wantDetail: NewProblemDetails(http.StatusBadRequest, "Validation Error", "One or more fields failed validation."),
8886
},
8987
{
9088
name: "Invalid JSON Body",
@@ -97,18 +95,36 @@ func Test_ParseRequest(t *testing.T) {
9795
}(),
9896
pathParams: []string{"ID"},
9997
},
100-
want: nil,
101-
wantErr: assert.Error,
98+
want: nil,
99+
wantErr: assert.Error,
100+
wantDetail: NewProblemDetails(http.StatusBadRequest, "Invalid Request", "invalid character 'i' looking for beginning of object key string"),
102101
},
103102
}
104103

105104
for _, tt := range tests {
106105
t.Run(tt.name, func(t *testing.T) {
107-
got, err := ParseRequest[*TestRequest](tt.args.w, tt.args.r, MyParamExtractor, tt.args.pathParams...)
106+
// Call the function under test.
107+
w := tt.args.w
108+
got, err := ParseRequest[*TestRequest](w, tt.args.r, MyParamExtractor, tt.args.pathParams...)
109+
110+
// Validate the error response if applicable.
108111
if !tt.wantErr(t, err, fmt.Sprintf("parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)) {
109112
return
110113
}
111-
assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)
114+
115+
// Check ProblemDetails if an error was expected.
116+
if tt.wantDetail != nil {
117+
rec := w.(*httptest.ResponseRecorder)
118+
var pd ProblemDetails
119+
decodeErr := json.NewDecoder(rec.Body).Decode(&pd)
120+
assert.NoError(t, decodeErr, "Failed to decode problem details response")
121+
assert.Equal(t, tt.wantDetail.Title, pd.Title, "Problem detail title mismatch")
122+
assert.Equal(t, tt.wantDetail.Status, pd.Status, "Problem detail status mismatch")
123+
assert.Contains(t, pd.Detail, tt.wantDetail.Detail, "Problem detail message mismatch")
124+
}
125+
126+
// Validate successful response.
127+
assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", w, tt.args.r, tt.args.pathParams)
112128
})
113129
}
114130
}

response.go

Lines changed: 49 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,75 +10,80 @@ import (
1010
// Response represents the structure of an HTTP response, including a status code, message, and optional body.
1111
// T represents the type of the `Data` field, allowing this structure to be used flexibly across different endpoints.
1212
type Response[T any] struct {
13-
Data T `json:"data,omitempty"`
14-
Errors []Error `json:"errors,omitempty"`
15-
Meta *Meta `json:"meta,omitempty"`
16-
}
17-
18-
// Error represents an error in the aPI response, with a structured format to describe issues in a consistent manner.
19-
type Error struct {
20-
// Code unique error code or HTTP status code for categorizing the error
21-
Code int `json:"code"`
22-
// Message user-friendly message describing the error.
23-
Message string `json:"message"`
24-
// Details additional details about the error, often used for validation errors.
25-
Details interface{} `json:"details,omitempty"`
13+
Data T `json:"data,omitempty"`
14+
Meta *Meta `json:"meta,omitempty"`
2615
}
2716

2817
// Meta provides additional information about the response, such as pagination details.
29-
// This is particularly useful for endpoints returning lists of data.
3018
type Meta struct {
31-
// Page the current page number
32-
Page int `json:"page,omitempty"`
33-
// PageSize the number of items per page
34-
PageSize int `json:"page_size,omitempty"`
35-
// TotalPages the total number of pages available.
19+
Page int `json:"page,omitempty"`
20+
PageSize int `json:"page_size,omitempty"`
3621
TotalPages int `json:"total_pages,omitempty"`
37-
// TotalItems the total number of items across all pages.
3822
TotalItems int `json:"total_items,omitempty"`
3923
}
4024

41-
// SendResponse sends a JSON response to the client, using a unified structure for both success and error responses.
42-
// T represents the type of the `data` payload. This function automatically adapts the response structure
43-
// based on whether `data` or `errors` is provided, promoting a consistent API format.
25+
// ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs.
26+
type ProblemDetails struct {
27+
Type string `json:"type"` // A URI reference identifying the problem type.
28+
Title string `json:"title"` // A short, human-readable summary of the problem.
29+
Status int `json:"status"` // The HTTP status code.
30+
Detail string `json:"detail,omitempty"` // Detailed explanation of the problem.
31+
Instance string `json:"instance,omitempty"` // A URI reference identifying the specific instance of the problem.
32+
Extensions map[string]interface{} `json:"extensions,omitempty"` // Custom fields for additional details.
33+
}
34+
35+
// NewProblemDetails creates a ProblemDetails instance with standard fields.
36+
func NewProblemDetails(status int, title, detail string) *ProblemDetails {
37+
return &ProblemDetails{
38+
Type: "about:blank", // Replace with a custom URI if desired.
39+
Title: title,
40+
Status: status,
41+
Detail: detail,
42+
}
43+
}
44+
45+
// SendResponse sends a JSON response to the client, supporting both success and error scenarios.
4446
//
4547
// Parameters:
4648
// - w: The http.ResponseWriter to send the response.
4749
// - code: HTTP status code to indicate success or failure.
48-
// - data: The main payload of the response. Use `nil` for error responses.
49-
// - errs: A slice of Error structs to describe issues. Use `nil` for successful responses.
50-
// - meta: Optional metadata, such as pagination information. Use `nil` if not needed.
51-
func SendResponse[T any](w http.ResponseWriter, code int, data T, errs []Error, meta *Meta) {
52-
w.Header().Set("Content-Type", "application/json; charset=utf-8")
50+
// - data: The main payload of the response (only for successful responses).
51+
// - problem: An optional ProblemDetails struct (used for error responses).
52+
// - meta: Optional metadata for successful responses (e.g., pagination details).
53+
func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *ProblemDetails, meta *Meta) {
5354

55+
// Handle error responses
56+
if code >= 400 && problem != nil {
57+
writeProblemDetail(w, code, problem)
58+
return
59+
}
60+
61+
// Construct and encode the success response
5462
response := &Response[T]{
55-
Data: data,
56-
Errors: errs,
57-
Meta: meta,
63+
Data: data,
64+
Meta: meta,
5865
}
5966

60-
// Attempt to encode the response as JSON
6167
var buffer bytes.Buffer
6268
if err := json.NewEncoder(&buffer).Encode(response); err != nil {
6369
log.Printf("Error writing response: %v", err)
6470

65-
w.WriteHeader(http.StatusInternalServerError)
66-
_ = json.NewEncoder(w).Encode(&Response[T]{
67-
Errors: []Error{{
68-
Code: http.StatusInternalServerError,
69-
Message: "Internal Server Error",
70-
Details: err.Error(),
71-
}},
72-
})
71+
// Internal server error fallback using ProblemDetails
72+
internalError := NewProblemDetails(http.StatusInternalServerError, "Internal Server Error", err.Error())
73+
writeProblemDetail(w, http.StatusInternalServerError, internalError)
7374
return
7475
}
7576

76-
// Set the status code after success encoding
77+
// Send the success response
78+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
7779
w.WriteHeader(code)
78-
79-
// Write the encoded response to the ResponseWriter
8080
if _, err := w.Write(buffer.Bytes()); err != nil {
81-
// Note: Cannot change status code here as headers are already sent
8281
log.Printf("Failed to write response body (status=%d): %v", code, err)
8382
}
8483
}
84+
85+
func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails) {
86+
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
87+
w.WriteHeader(problem.Status)
88+
_ = json.NewEncoder(w).Encode(problem)
89+
}

0 commit comments

Comments
 (0)