Skip to content

Commit

Permalink
feat: RFC 9457 compatible
Browse files Browse the repository at this point in the history
  • Loading branch information
rluders committed Jan 31, 2025
1 parent f081d27 commit 376d62b
Show file tree
Hide file tree
Showing 10 changed files with 235 additions and 129 deletions.
2 changes: 2 additions & 0 deletions examples/chi/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down
2 changes: 2 additions & 0 deletions examples/gorillamux/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down
2 changes: 1 addition & 1 deletion examples/stdmux/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module stdmux_example

go 1.23

require github.com/rluders/httpsuite/v2 v2.0.0
require github.com/rluders/httpsuite/v2 v2.0.0

require (
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
Expand Down
2 changes: 2 additions & 0 deletions examples/stdmux/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rluders/httpsuite/v2 v2.0.0 h1:/508/6wnNF4c45LrK1qaJUMCLqDK+WZPjPR2v2yAmeg=
github.com/rluders/httpsuite/v2 v2.0.0/go.mod h1:UuoMIslkPzDms8W83LlqAm7gINcYEZbMtiSsOWcSr1c=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down
17 changes: 13 additions & 4 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,37 +61,46 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request,
var empty T
defer func() { _ = r.Body.Close() }()

// Decode JSON body if present
if r.Body != http.NoBody {
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
SendResponse[any](w, http.StatusBadRequest, nil, []Error{{Code: http.StatusBadRequest, Message: "Invalid JSON format"}}, nil)
problem := NewProblemDetails(http.StatusBadRequest, "Invalid Request", err.Error())
SendResponse[any](w, http.StatusBadRequest, nil, problem, nil)
return empty, err
}
}

// Ensure request object is properly initialized
if isRequestNil(request) {
request = reflect.New(reflect.TypeOf(request).Elem()).Interface().(T)
}

// Extract and set URL parameters
for _, key := range pathParams {
value := paramExtractor(r, key)
if value == "" {
SendResponse[any](w, http.StatusBadRequest, nil, []Error{{Code: http.StatusBadRequest, Message: "Parameter " + key + " not found in request"}}, nil)
problem := NewProblemDetails(http.StatusBadRequest, "Missing Parameter", "Parameter "+key+" not found in request")
SendResponse[any](w, http.StatusBadRequest, nil, problem, nil)
return empty, errors.New("missing parameter: " + key)
}
if err := request.SetParam(key, value); err != nil {
SendResponse[any](w, http.StatusInternalServerError, nil, []Error{{Code: http.StatusInternalServerError, Message: "Failed to set field " + key, Details: err.Error()}}, nil)
problem := NewProblemDetails(http.StatusInternalServerError, "Parameter Error", "Failed to set field "+key)
problem.Extensions = map[string]interface{}{"error": err.Error()}
SendResponse[any](w, http.StatusInternalServerError, nil, problem, nil)
return empty, err
}
}

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

return request, nil
}

// isRequestNil checks if a request object is nil or an uninitialized pointer.
func isRequestNil(i interface{}) bool {
return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil())
}
56 changes: 36 additions & 20 deletions request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import (
"testing"
)

// TestRequest includes custom type annotation for UUID type
// TestRequest includes custom type annotation for UUID type.
type TestRequest struct {
ID int `json:"id" validate:"required"`
ID int `json:"id" validate:"required,gt=0"`
Name string `json:"name" validate:"required"`
}

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

// This implementation extracts parameters from the path, assuming the request URL follows a pattern
// like "/test/{id}", where "id" is a path parameter.
// MyParamExtractor extracts parameters from the path, assuming the request URL follows a pattern like "/test/{id}".
func MyParamExtractor(r *http.Request, key string) string {
// Here, we can extract parameters directly from the URL path for simplicity.
// Example: for "/test/123", if key is "ID", we want to capture "123".
pathSegments := strings.Split(r.URL.Path, "/")

// You should know how the path is structured; in this case, we expect the ID to be the second segment.
if len(pathSegments) > 2 && key == "ID" {
return pathSegments[2]
}
Expand All @@ -54,10 +49,11 @@ func Test_ParseRequest(t *testing.T) {
pathParams []string
}
type testCase[T any] struct {
name string
args args
want *TestRequest
wantErr assert.ErrorAssertionFunc
name string
args args
want *TestRequest
wantErr assert.ErrorAssertionFunc
wantDetail *ProblemDetails
}

tests := []testCase[TestRequest]{
Expand All @@ -73,8 +69,9 @@ func Test_ParseRequest(t *testing.T) {
}(),
pathParams: []string{"ID"},
},
want: &TestRequest{ID: 123, Name: "Test"},
wantErr: assert.NoError,
want: &TestRequest{ID: 123, Name: "Test"},
wantErr: assert.NoError,
wantDetail: nil,
},
{
name: "Missing body",
Expand All @@ -83,8 +80,9 @@ func Test_ParseRequest(t *testing.T) {
r: httptest.NewRequest("POST", "/test/123", nil),
pathParams: []string{"ID"},
},
want: nil,
wantErr: assert.Error,
want: nil,
wantErr: assert.Error,
wantDetail: NewProblemDetails(http.StatusBadRequest, "Validation Error", "One or more fields failed validation."),
},
{
name: "Invalid JSON Body",
Expand All @@ -97,18 +95,36 @@ func Test_ParseRequest(t *testing.T) {
}(),
pathParams: []string{"ID"},
},
want: nil,
wantErr: assert.Error,
want: nil,
wantErr: assert.Error,
wantDetail: NewProblemDetails(http.StatusBadRequest, "Invalid Request", "invalid character 'i' looking for beginning of object key string"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseRequest[*TestRequest](tt.args.w, tt.args.r, MyParamExtractor, tt.args.pathParams...)
// Call the function under test.
w := tt.args.w
got, err := ParseRequest[*TestRequest](w, tt.args.r, MyParamExtractor, tt.args.pathParams...)

// Validate the error response if applicable.
if !tt.wantErr(t, err, fmt.Sprintf("parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)) {
return
}
assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)

// Check ProblemDetails if an error was expected.
if tt.wantDetail != nil {
rec := w.(*httptest.ResponseRecorder)
var pd ProblemDetails
decodeErr := json.NewDecoder(rec.Body).Decode(&pd)
assert.NoError(t, decodeErr, "Failed to decode problem details response")
assert.Equal(t, tt.wantDetail.Title, pd.Title, "Problem detail title mismatch")
assert.Equal(t, tt.wantDetail.Status, pd.Status, "Problem detail status mismatch")
assert.Contains(t, pd.Detail, tt.wantDetail.Detail, "Problem detail message mismatch")
}

// Validate successful response.
assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", w, tt.args.r, tt.args.pathParams)
})
}
}
96 changes: 50 additions & 46 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,78 +7,82 @@ import (
"net/http"
)

// Response represents the structure of an HTTP response, including a status code, message, and optional body.
// T represents the type of the `Data` field, allowing this structure to be used flexibly across different endpoints.
// Response represents the structure of an HTTP success response, including optional data and metadata.
type Response[T any] struct {
Data T `json:"data,omitempty"`
Errors []Error `json:"errors,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}

// Error represents an error in the aPI response, with a structured format to describe issues in a consistent manner.
type Error struct {
// Code unique error code or HTTP status code for categorizing the error
Code int `json:"code"`
// Message user-friendly message describing the error.
Message string `json:"message"`
// Details additional details about the error, often used for validation errors.
Details interface{} `json:"details,omitempty"`
Data T `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}

// Meta provides additional information about the response, such as pagination details.
// This is particularly useful for endpoints returning lists of data.
type Meta struct {
// Page the current page number
Page int `json:"page,omitempty"`
// PageSize the number of items per page
PageSize int `json:"page_size,omitempty"`
// TotalPages the total number of pages available.
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
// TotalItems the total number of items across all pages.
TotalItems int `json:"total_items,omitempty"`
}

// SendResponse sends a JSON response to the client, using a unified structure for both success and error responses.
// T represents the type of the `data` payload. This function automatically adapts the response structure
// based on whether `data` or `errors` is provided, promoting a consistent API format.
// ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs.
type ProblemDetails struct {
Type string `json:"type"` // A URI reference identifying the problem type.
Title string `json:"title"` // A short, human-readable summary of the problem.
Status int `json:"status"` // The HTTP status code.
Detail string `json:"detail,omitempty"` // Detailed explanation of the problem.
Instance string `json:"instance,omitempty"` // A URI reference identifying the specific instance of the problem.
Extensions map[string]interface{} `json:"extensions,omitempty"` // Custom fields for additional details.
}

// NewProblemDetails creates a ProblemDetails instance with standard fields.
func NewProblemDetails(status int, title, detail string) *ProblemDetails {
return &ProblemDetails{
Type: "about:blank", // Replace with a custom URI if desired.
Title: title,
Status: status,
Detail: detail,
}
}

// SendResponse sends a JSON response to the client, supporting both success and error scenarios.
//
// Parameters:
// - w: The http.ResponseWriter to send the response.
// - code: HTTP status code to indicate success or failure.
// - data: The main payload of the response. Use `nil` for error responses.
// - errs: A slice of Error structs to describe issues. Use `nil` for successful responses.
// - meta: Optional metadata, such as pagination information. Use `nil` if not needed.
func SendResponse[T any](w http.ResponseWriter, code int, data T, errs []Error, meta *Meta) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// - data: The main payload of the response (only for successful responses).
// - problem: An optional ProblemDetails struct (used for error responses).
// - meta: Optional metadata for successful responses (e.g., pagination details).
func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *ProblemDetails, meta *Meta) {

// Handle error responses
if code >= 400 && problem != nil {
writeProblemDetail(w, code, problem)
return
}

// Construct and encode the success response
response := &Response[T]{
Data: data,
Errors: errs,
Meta: meta,
Data: data,
Meta: meta,
}

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

w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(&Response[T]{
Errors: []Error{{
Code: http.StatusInternalServerError,
Message: "Internal Server Error",
Details: err.Error(),
}},
})
// Internal server error fallback using ProblemDetails
internalError := NewProblemDetails(http.StatusInternalServerError, "Internal Server Error", err.Error())
writeProblemDetail(w, http.StatusInternalServerError, internalError)
return
}

// Set the status code after success encoding
// Send the success response
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)

// Write the encoded response to the ResponseWriter
if _, err := w.Write(buffer.Bytes()); err != nil {
// Note: Cannot change status code here as headers are already sent
log.Printf("Failed to write response body (status=%d): %v", code, err)
}
}

func writeProblemDetail(w http.ResponseWriter, code int, problem *ProblemDetails) {
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
w.WriteHeader(problem.Status)
_ = json.NewEncoder(w).Encode(problem)
}
Loading

0 comments on commit 376d62b

Please sign in to comment.