From 90bc8c2a39035996ab3e1243b210c66fe51e8754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 09:42:48 +0100 Subject: [PATCH 1/8] feat: RFC 9457 compatible --- examples/chi/go.sum | 2 + examples/gorillamux/go.sum | 2 + examples/stdmux/go.mod | 2 +- examples/stdmux/go.sum | 2 + request.go | 17 +++++-- request_test.go | 56 +++++++++++++++-------- response.go | 93 ++++++++++++++++++++------------------ response_test.go | 54 +++++++++++++++------- validation.go | 54 +++++++++++++++------- validation_test.go | 79 ++++++++++++++++++++++---------- 10 files changed, 234 insertions(+), 127 deletions(-) diff --git a/examples/chi/go.sum b/examples/chi/go.sum index 280d726..9d20e15 100644 --- a/examples/chi/go.sum +++ b/examples/chi/go.sum @@ -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= diff --git a/examples/gorillamux/go.sum b/examples/gorillamux/go.sum index fb00659..9b0b220 100644 --- a/examples/gorillamux/go.sum +++ b/examples/gorillamux/go.sum @@ -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= diff --git a/examples/stdmux/go.mod b/examples/stdmux/go.mod index 3bd464c..209bc3b 100644 --- a/examples/stdmux/go.mod +++ b/examples/stdmux/go.mod @@ -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 diff --git a/examples/stdmux/go.sum b/examples/stdmux/go.sum index 03f59a4..37e39a9 100644 --- a/examples/stdmux/go.sum +++ b/examples/stdmux/go.sum @@ -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= diff --git a/request.go b/request.go index 04e222b..88b02c7 100644 --- a/request.go +++ b/request.go @@ -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()) } diff --git a/request_test.go b/request_test.go index eca1e05..e1581d9 100644 --- a/request_test.go +++ b/request_test.go @@ -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"` } @@ -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] } @@ -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]{ @@ -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", @@ -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", @@ -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) }) } } diff --git a/response.go b/response.go index 77d8b23..0c9fca7 100644 --- a/response.go +++ b/response.go @@ -10,75 +10,80 @@ import ( // 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. 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) +} diff --git a/response_test.go b/response_test.go index 7af2fe1..cd89158 100644 --- a/response_test.go +++ b/response_test.go @@ -17,7 +17,7 @@ func Test_SendResponse(t *testing.T) { name string code int data any - errs []Error + problem *ProblemDetails meta *Meta expectedCode int expectedJSON string @@ -26,17 +26,15 @@ func Test_SendResponse(t *testing.T) { name: "200 OK with TestResponse body", code: http.StatusOK, data: &TestResponse{Key: "value"}, - errs: nil, expectedCode: http.StatusOK, expectedJSON: `{"data":{"key":"value"}}`, }, { name: "404 Not Found without body", code: http.StatusNotFound, - data: nil, - errs: []Error{{Code: http.StatusNotFound, Message: "Not Found"}}, + problem: NewProblemDetails(http.StatusNotFound, "Not Found", "The requested resource was not found"), expectedCode: http.StatusNotFound, - expectedJSON: `{"errors":[{"code":404,"message":"Not Found"}]}`, + expectedJSON: `{"type":"about:blank","title":"Not Found","status":404,"detail":"The requested resource was not found"}`, }, { name: "200 OK with pagination metadata", @@ -47,11 +45,33 @@ func Test_SendResponse(t *testing.T) { expectedJSON: `{"data":{"key":"value"},"meta":{"total_pages":100,"page":1,"page_size":10}}`, }, { - name: "400 Bad Request with multiple errors", - code: http.StatusBadRequest, - errs: []Error{{Code: 400, Message: "Invalid email"}, {Code: 400, Message: "Invalid password"}}, + name: "400 Bad Request with validation error", + code: http.StatusBadRequest, + problem: &ProblemDetails{ + Type: "https://example.com/validation-error", + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "One or more fields failed validation.", + Extensions: map[string]interface{}{ + "errors": []ValidationErrorDetail{ + {Field: "email", Message: "Email is required"}, + {Field: "password", Message: "Password is required"}, + }, + }, + }, expectedCode: http.StatusBadRequest, - expectedJSON: `{"errors":[{"code":400,"message":"Invalid email"},{"code":400,"message":"Invalid password"}]}`, + expectedJSON: `{ + "type": "https://example.com/validation-error", + "title": "Validation Error", + "status": 400, + "detail": "One or more fields failed validation.", + "extensions": { + "errors": [ + {"field": "email", "message": "Email is required"}, + {"field": "password", "message": "Password is required"} + ] + } + }`, }, } @@ -59,15 +79,17 @@ func Test_SendResponse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() - switch data := tt.data.(type) { - case TestResponse: - SendResponse[TestResponse](w, tt.code, data, tt.errs, tt.meta) - default: - SendResponse[any](w, tt.code, tt.data, tt.errs, tt.meta) - } + // Call SendResponse with the appropriate data or problem + SendResponse[any](w, tt.code, tt.data, tt.problem, tt.meta) + // Assert response status code and content type assert.Equal(t, tt.expectedCode, w.Code) - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + if w.Code >= 400 { + assert.Equal(t, "application/problem+json; charset=utf-8", w.Header().Get("Content-Type")) + } else { + assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + } + // Assert response body assert.JSONEq(t, tt.expectedJSON, w.Body.String()) }) } diff --git a/validation.go b/validation.go index 54605da..44c1b1d 100644 --- a/validation.go +++ b/validation.go @@ -2,38 +2,58 @@ package httpsuite import ( "errors" + "net/http" + "github.com/go-playground/validator/v10" ) -// ValidationErrors represents a collection of validation errors for an HTTP request. -type ValidationErrors struct { - Errors map[string][]string `json:"errors,omitempty"` +// ValidationErrorDetail provides structured details about a single validation error. +type ValidationErrorDetail struct { + Field string `json:"field"` // The name of the field that failed validation. + Message string `json:"message"` // A human-readable message describing the error. } -// NewValidationErrors creates a new ValidationErrors instance from a given error. -// It extracts field-specific validation errors and maps them for structured output. -func NewValidationErrors(err error) *ValidationErrors { +// NewValidationProblemDetails creates a ProblemDetails instance based on validation errors. +// It maps field-specific validation errors into structured details. +func NewValidationProblemDetails(err error) *ProblemDetails { var validationErrors validator.ValidationErrors - errors.As(err, &validationErrors) + if !errors.As(err, &validationErrors) { + // If the error is not of type ValidationErrors, return a generic problem response. + return NewProblemDetails(http.StatusBadRequest, "Invalid Request", "Invalid data format or structure") + } - fieldErrors := make(map[string][]string) - for _, vErr := range validationErrors { - fieldName := vErr.Field() - fieldError := fieldName + " " + vErr.Tag() + // Collect structured details about each validation error. + errorDetails := make([]ValidationErrorDetail, len(validationErrors)) + for i, vErr := range validationErrors { + errorDetails[i] = ValidationErrorDetail{ + Field: vErr.Field(), + Message: formatValidationMessage(vErr), + } + } - fieldErrors[fieldName] = append(fieldErrors[fieldName], fieldError) + return &ProblemDetails{ + Type: "https://example.com/validation-error", + Title: "Validation Error", + Status: http.StatusBadRequest, + Detail: "One or more fields failed validation.", + Extensions: map[string]interface{}{ + "errors": errorDetails, + }, } +} - return &ValidationErrors{Errors: fieldErrors} +// formatValidationMessage generates a descriptive message for a validation error. +func formatValidationMessage(vErr validator.FieldError) string { + return vErr.Field() + " failed " + vErr.Tag() + " validation" } // IsRequestValid validates the provided request struct using the go-playground/validator package. -// It returns a ValidationErrors instance if validation fails, or nil if the request is valid. -func IsRequestValid(request any) *ValidationErrors { - validate := validator.New(validator.WithRequiredStructEnabled()) +// It returns a ProblemDetails instance if validation fails, or nil if the request is valid. +func IsRequestValid(request any) *ProblemDetails { + validate := validator.New() err := validate.Struct(request) if err != nil { - return NewValidationErrors(err) + return NewValidationProblemDetails(err) } return nil } diff --git a/validation_test.go b/validation_test.go index 6e593f1..3baaf81 100644 --- a/validation_test.go +++ b/validation_test.go @@ -1,9 +1,9 @@ package httpsuite import ( - "github.com/go-playground/validator/v10" "testing" + "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" ) @@ -12,7 +12,7 @@ type TestValidationRequest struct { Age int `validate:"required,min=18"` } -func TestNewValidationErrors(t *testing.T) { +func TestNewValidationProblemDetails(t *testing.T) { validate := validator.New() request := TestValidationRequest{} // Missing required fields to trigger validation errors @@ -21,43 +21,67 @@ func TestNewValidationErrors(t *testing.T) { t.Fatal("Expected validation errors, but got none") } - validationErrors := NewValidationErrors(err) + validationProblem := NewValidationProblemDetails(err) - expectedErrors := map[string][]string{ - "Name": {"Name required"}, - "Age": {"Age required"}, + expectedProblem := &ProblemDetails{ + Type: "https://example.com/validation-error", + Title: "Validation Error", + Status: 400, + Detail: "One or more fields failed validation.", + Extensions: map[string]interface{}{ + "errors": []ValidationErrorDetail{ + {Field: "Name", Message: "Name failed required validation"}, + {Field: "Age", Message: "Age failed required validation"}, + }, + }, } - assert.Equal(t, expectedErrors, validationErrors.Errors) + assert.Equal(t, expectedProblem.Type, validationProblem.Type) + assert.Equal(t, expectedProblem.Title, validationProblem.Title) + assert.Equal(t, expectedProblem.Status, validationProblem.Status) + assert.Equal(t, expectedProblem.Detail, validationProblem.Detail) + assert.ElementsMatch(t, expectedProblem.Extensions["errors"], validationProblem.Extensions["errors"]) } func TestIsRequestValid(t *testing.T) { tests := []struct { - name string - request TestValidationRequest - expectedErrors *ValidationErrors + name string + request TestValidationRequest + expectedProblem *ProblemDetails }{ { - name: "Valid request", - request: TestValidationRequest{Name: "Alice", Age: 25}, - expectedErrors: nil, // No errors expected for valid input + name: "Valid request", + request: TestValidationRequest{Name: "Alice", Age: 25}, + expectedProblem: nil, // No errors expected for valid input }, { name: "Missing Name and Age below minimum", request: TestValidationRequest{Age: 17}, - expectedErrors: &ValidationErrors{ - Errors: map[string][]string{ - "Name": {"Name required"}, - "Age": {"Age min"}, + expectedProblem: &ProblemDetails{ + Type: "https://example.com/validation-error", + Title: "Validation Error", + Status: 400, + Detail: "One or more fields failed validation.", + Extensions: map[string]interface{}{ + "errors": []ValidationErrorDetail{ + {Field: "Name", Message: "Name failed required validation"}, + {Field: "Age", Message: "Age failed min validation"}, + }, }, }, }, { name: "Missing Age", request: TestValidationRequest{Name: "Alice"}, - expectedErrors: &ValidationErrors{ - Errors: map[string][]string{ - "Age": {"Age required"}, + expectedProblem: &ProblemDetails{ + Type: "https://example.com/validation-error", + Title: "Validation Error", + Status: 400, + Detail: "One or more fields failed validation.", + Extensions: map[string]interface{}{ + "errors": []ValidationErrorDetail{ + {Field: "Age", Message: "Age failed required validation"}, + }, }, }, }, @@ -65,12 +89,17 @@ func TestIsRequestValid(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - errs := IsRequestValid(tt.request) - if tt.expectedErrors == nil { - assert.Nil(t, errs) + problem := IsRequestValid(tt.request) + + if tt.expectedProblem == nil { + assert.Nil(t, problem) } else { - assert.NotNil(t, errs) - assert.Equal(t, tt.expectedErrors.Errors, errs.Errors) + assert.NotNil(t, problem) + assert.Equal(t, tt.expectedProblem.Type, problem.Type) + assert.Equal(t, tt.expectedProblem.Title, problem.Title) + assert.Equal(t, tt.expectedProblem.Status, problem.Status) + assert.Equal(t, tt.expectedProblem.Detail, problem.Detail) + assert.ElementsMatch(t, tt.expectedProblem.Extensions["errors"], problem.Extensions["errors"]) } }) } From 922048fe4b39433d5863da7fc80ccf5536c0c7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 10:39:25 +0100 Subject: [PATCH 2/8] fix: test content-type (resolve: discussion_r1936938314) --- request_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/request_test.go b/request_test.go index e1581d9..e8fc52c 100644 --- a/request_test.go +++ b/request_test.go @@ -115,6 +115,7 @@ func Test_ParseRequest(t *testing.T) { // Check ProblemDetails if an error was expected. if tt.wantDetail != nil { rec := w.(*httptest.ResponseRecorder) + assert.Equal(t, "application/problem+json; charset=utf-8", rec.Header().Get("Content-Type"), "Content-Type header mismatch") var pd ProblemDetails decodeErr := json.NewDecoder(rec.Body).Decode(&pd) assert.NoError(t, decodeErr, "Failed to decode problem details response") From da0b9096a9399acd558cc81cc531a9edd6fda9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 10:42:09 +0100 Subject: [PATCH 3/8] fix: validation as package var (resolve: discussion_r1936947547) --- validation.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/validation.go b/validation.go index 44c1b1d..f2e7582 100644 --- a/validation.go +++ b/validation.go @@ -7,6 +7,9 @@ import ( "github.com/go-playground/validator/v10" ) +// Validator instance +var validate = validator.New() + // ValidationErrorDetail provides structured details about a single validation error. type ValidationErrorDetail struct { Field string `json:"field"` // The name of the field that failed validation. @@ -50,7 +53,6 @@ func formatValidationMessage(vErr validator.FieldError) string { // IsRequestValid validates the provided request struct using the go-playground/validator package. // It returns a ProblemDetails instance if validation fails, or nil if the request is valid. func IsRequestValid(request any) *ProblemDetails { - validate := validator.New() err := validate.Struct(request) if err != nil { return NewValidationProblemDetails(err) From 0c031ff0079dc6171a9038266c2060ebf0384e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 10:44:12 +0100 Subject: [PATCH 4/8] fix: handle response encode error (resolve discussion_r1936947554) --- response.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/response.go b/response.go index 0c9fca7..c9baeed 100644 --- a/response.go +++ b/response.go @@ -85,5 +85,7 @@ func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *Probl 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) + if err := json.NewEncoder(w).Encode(problem); err != nil { + log.Printf("Failed to encode problem details: %v", err) + } } From 03f137ff6eb1d0d8d6f1102c7b6b4cd03f890f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 16:20:24 +0100 Subject: [PATCH 5/8] feat: expanding problem details --- examples/chi/main.go | 3 + examples/gorillamux/main.go | 3 + examples/stdmux/main.go | 3 + problem_details.go | 123 ++++++++++++++++++++++++++++ problem_details_test.go | 156 ++++++++++++++++++++++++++++++++++++ request.go | 22 ++++- request_test.go | 4 +- response.go | 27 ++----- response_test.go | 26 +++++- validation.go | 9 ++- validation_test.go | 9 ++- 11 files changed, 350 insertions(+), 35 deletions(-) create mode 100644 problem_details.go create mode 100644 problem_details_test.go diff --git a/examples/chi/main.go b/examples/chi/main.go index 1b9208d..7b26757 100644 --- a/examples/chi/main.go +++ b/examples/chi/main.go @@ -52,6 +52,9 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) + // Define the ProblemBaseURL - doesn't create the handlers + httpsuite.SetProblemBaseURL("http://localhost:8080") + // Define the endpoint POST r.Post("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { // Using the function for parameter extraction to the ParseRequest diff --git a/examples/gorillamux/main.go b/examples/gorillamux/main.go index 4217465..5edaba5 100644 --- a/examples/gorillamux/main.go +++ b/examples/gorillamux/main.go @@ -42,6 +42,9 @@ func main() { // Creating the router with Gorilla Mux r := mux.NewRouter() + // Define the ProblemBaseURL - doesn't create the handlers + httpsuite.SetProblemBaseURL("http://localhost:8080") + r.HandleFunc("/submit/{id}", func(w http.ResponseWriter, r *http.Request) { // Using the function for parameter extraction to the ParseRequest req, err := httpsuite.ParseRequest[*SampleRequest](w, r, GorillaMuxParamExtractor, "id") diff --git a/examples/stdmux/main.go b/examples/stdmux/main.go index dc7dd19..047847a 100644 --- a/examples/stdmux/main.go +++ b/examples/stdmux/main.go @@ -46,6 +46,9 @@ func main() { // Creating the router using the Go standard mux mux := http.NewServeMux() + // Define the ProblemBaseURL - doesn't create the handlers + httpsuite.SetProblemBaseURL("http://localhost:8080") + // Define the endpoint POST mux.HandleFunc("/submit/", func(w http.ResponseWriter, r *http.Request) { // Using the function for parameter extraction to the ParseRequest diff --git a/problem_details.go b/problem_details.go new file mode 100644 index 0000000..f9c2e0e --- /dev/null +++ b/problem_details.go @@ -0,0 +1,123 @@ +package httpsuite + +const BlankUrl = "about:blank" + +var problemBaseURL = BlankUrl +var errorTypePaths = map[string]string{ + "validation_error": "/errors/validation-error", + "not_found_error": "/errors/not-found", + "server_error": "/errors/server-error", + "bad_request_error": "/errors/bad-request", +} + +// 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, problemType, title, detail string) *ProblemDetails { + if problemType == "" { + problemType = BlankUrl + } + return &ProblemDetails{ + Type: problemType, + Title: title, + Status: status, + Detail: detail, + } +} + +// SetProblemBaseURL configures the base URL used in the "type" field for ProblemDetails. +// +// This function allows applications using httpsuite to provide a custom domain and structure +// for error documentation URLs. By setting this base URL, the library can generate meaningful +// and discoverable problem types. +// +// Parameters: +// - baseURL: The base URL where error documentation is hosted (e.g., "https://api.mycompany.com"). +// +// Example usage: +// +// httpsuite.SetProblemBaseURL("https://api.mycompany.com") +// +// Once configured, generated ProblemDetails will include a "type" such as: +// +// "https://api.mycompany.com/errors/validation-error" +// +// If the base URL is not set, the default value for the "type" field will be "about:blank". +func SetProblemBaseURL(baseURL string) { + problemBaseURL = baseURL +} + +// SetProblemErrorTypePath sets or updates the path for a specific error type. +// +// This allows applications to define custom paths for error documentation. +// +// Parameters: +// - errorType: The unique key identifying the error type (e.g., "validation_error"). +// - path: The path under the base URL where the error documentation is located. +// +// Example usage: +// +// httpsuite.SetProblemErrorTypePath("validation_error", "/errors/validation-error") +// +// After setting this path, the generated problem type for "validation_error" will be: +// +// "https://api.mycompany.com/errors/validation-error" +func SetProblemErrorTypePath(errorType, path string) { + errorTypePaths[errorType] = path +} + +// SetProblemErrorTypePaths sets or updates multiple paths for different error types. +// +// This allows applications to define multiple custom paths at once. +// +// Parameters: +// - paths: A map of error types to paths (e.g., {"validation_error": "/errors/validation-error"}). +// +// Example usage: +// +// paths := map[string]string{ +// "validation_error": "/errors/validation-error", +// "not_found_error": "/errors/not-found", +// } +// httpsuite.SetProblemErrorTypePaths(paths) +// +// This method overwrites any existing paths with the same keys. +func SetProblemErrorTypePaths(paths map[string]string) { + for errorType, path := range paths { + errorTypePaths[errorType] = path + } +} + +// GetProblemTypeURL get the full problem type URL based on the error type. +// +// If the error type is not found in the predefined paths, it returns a default unknown error path. +// +// Parameters: +// - errorType: The unique key identifying the error type (e.g., "validation_error"). +// +// Example usage: +// +// problemTypeURL := GetProblemTypeURL("validation_error") +func GetProblemTypeURL(errorType string) string { + if path, exists := errorTypePaths[errorType]; exists { + return getProblemBaseURL() + path + } + + return BlankUrl +} + +// getProblemBaseURL just return the baseURL if it isn't "about:blank" +func getProblemBaseURL() string { + if problemBaseURL == BlankUrl { + return "" + } + return problemBaseURL +} diff --git a/problem_details_test.go b/problem_details_test.go new file mode 100644 index 0000000..cb01a05 --- /dev/null +++ b/problem_details_test.go @@ -0,0 +1,156 @@ +package httpsuite + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_SetProblemBaseURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Set valid base URL", + input: "https://api.example.com", + expected: "https://api.example.com", + }, + { + name: "Set base URL to blank", + input: BlankUrl, + expected: BlankUrl, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetProblemBaseURL(tt.input) + assert.Equal(t, tt.expected, problemBaseURL) + }) + } +} + +func Test_SetProblemErrorTypePath(t *testing.T) { + tests := []struct { + name string + errorKey string + path string + expected string + }{ + { + name: "Set custom error path", + errorKey: "custom_error", + path: "/errors/custom-error", + expected: "/errors/custom-error", + }, + { + name: "Override existing path", + errorKey: "validation_error", + path: "/errors/new-validation-error", + expected: "/errors/new-validation-error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetProblemErrorTypePath(tt.errorKey, tt.path) + assert.Equal(t, tt.expected, errorTypePaths[tt.errorKey]) + }) + } +} + +func Test_GetProblemTypeURL(t *testing.T) { + // Setup initial state + SetProblemBaseURL("https://api.example.com") + SetProblemErrorTypePath("validation_error", "/errors/validation-error") + + tests := []struct { + name string + errorType string + expectedURL string + }{ + { + name: "Valid error type", + errorType: "validation_error", + expectedURL: "https://api.example.com/errors/validation-error", + }, + { + name: "Unknown error type", + errorType: "unknown_error", + expectedURL: BlankUrl, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetProblemTypeURL(tt.errorType) + assert.Equal(t, tt.expectedURL, result) + }) + } +} + +func Test_getProblemBaseURL(t *testing.T) { + tests := []struct { + name string + baseURL string + expectedResult string + }{ + { + name: "Base URL is set", + baseURL: "https://api.example.com", + expectedResult: "https://api.example.com", + }, + { + name: "Base URL is about:blank", + baseURL: BlankUrl, + expectedResult: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + problemBaseURL = tt.baseURL + assert.Equal(t, tt.expectedResult, getProblemBaseURL()) + }) + } +} + +func Test_NewProblemDetails(t *testing.T) { + tests := []struct { + name string + status int + problemType string + title string + detail string + expectedType string + }{ + { + name: "All fields provided", + status: 400, + problemType: "https://api.example.com/errors/validation-error", + title: "Validation Error", + detail: "Invalid input", + expectedType: "https://api.example.com/errors/validation-error", + }, + { + name: "Empty problem type", + status: 404, + problemType: "", + title: "Not Found", + detail: "The requested resource was not found", + expectedType: BlankUrl, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + details := NewProblemDetails(tt.status, tt.problemType, tt.title, tt.detail) + assert.Equal(t, tt.status, details.Status) + assert.Equal(t, tt.title, details.Title) + assert.Equal(t, tt.detail, details.Detail) + assert.Equal(t, tt.expectedType, details.Type) + }) + } +} diff --git a/request.go b/request.go index 88b02c7..7d6989e 100644 --- a/request.go +++ b/request.go @@ -64,7 +64,12 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, // Decode JSON body if present if r.Body != http.NoBody { if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - problem := NewProblemDetails(http.StatusBadRequest, "Invalid Request", err.Error()) + problem := NewProblemDetails( + http.StatusBadRequest, + GetProblemTypeURL("bad_request_error"), + "Invalid Request", + err.Error(), + ) SendResponse[any](w, http.StatusBadRequest, nil, problem, nil) return empty, err } @@ -79,12 +84,23 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, for _, key := range pathParams { value := paramExtractor(r, key) if value == "" { - problem := NewProblemDetails(http.StatusBadRequest, "Missing Parameter", "Parameter "+key+" not found in request") + problem := NewProblemDetails( + http.StatusBadRequest, + GetProblemTypeURL("bad_request_error"), + "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 { - problem := NewProblemDetails(http.StatusInternalServerError, "Parameter Error", "Failed to set field "+key) + problem := NewProblemDetails( + http.StatusInternalServerError, + GetProblemTypeURL("sever_error"), + "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 diff --git a/request_test.go b/request_test.go index e8fc52c..55c02aa 100644 --- a/request_test.go +++ b/request_test.go @@ -82,7 +82,7 @@ func Test_ParseRequest(t *testing.T) { }, want: nil, wantErr: assert.Error, - wantDetail: NewProblemDetails(http.StatusBadRequest, "Validation Error", "One or more fields failed validation."), + wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Validation Error", "One or more fields failed validation."), }, { name: "Invalid JSON Body", @@ -97,7 +97,7 @@ func Test_ParseRequest(t *testing.T) { }, want: nil, wantErr: assert.Error, - wantDetail: NewProblemDetails(http.StatusBadRequest, "Invalid Request", "invalid character 'i' looking for beginning of object key string"), + wantDetail: NewProblemDetails(http.StatusBadRequest, "about:blank", "Invalid Request", "invalid character 'i' looking for beginning of object key string"), }, } diff --git a/response.go b/response.go index c9baeed..05cdf3c 100644 --- a/response.go +++ b/response.go @@ -22,26 +22,6 @@ type Meta struct { TotalItems int `json:"total_items,omitempty"` } -// 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: @@ -69,7 +49,12 @@ func SendResponse[T any](w http.ResponseWriter, code int, data T, problem *Probl log.Printf("Error writing response: %v", err) // Internal server error fallback using ProblemDetails - internalError := NewProblemDetails(http.StatusInternalServerError, "Internal Server Error", err.Error()) + internalError := NewProblemDetails( + http.StatusInternalServerError, + GetProblemTypeURL("server_error"), + "Internal Server Error", + err.Error(), + ) writeProblemDetail(w, http.StatusInternalServerError, internalError) return } diff --git a/response_test.go b/response_test.go index cd89158..16c7477 100644 --- a/response_test.go +++ b/response_test.go @@ -27,14 +27,23 @@ func Test_SendResponse(t *testing.T) { code: http.StatusOK, data: &TestResponse{Key: "value"}, expectedCode: http.StatusOK, - expectedJSON: `{"data":{"key":"value"}}`, + expectedJSON: `{ + "data": { + "key": "value" + } + }`, }, { name: "404 Not Found without body", code: http.StatusNotFound, - problem: NewProblemDetails(http.StatusNotFound, "Not Found", "The requested resource was not found"), + problem: NewProblemDetails(http.StatusNotFound, "", "Not Found", "The requested resource was not found"), expectedCode: http.StatusNotFound, - expectedJSON: `{"type":"about:blank","title":"Not Found","status":404,"detail":"The requested resource was not found"}`, + expectedJSON: `{ + "type": "about:blank", + "title": "Not Found", + "status": 404, + "detail": "The requested resource was not found" + }`, }, { name: "200 OK with pagination metadata", @@ -42,7 +51,16 @@ func Test_SendResponse(t *testing.T) { data: &TestResponse{Key: "value"}, meta: &Meta{TotalPages: 100, Page: 1, PageSize: 10}, expectedCode: http.StatusOK, - expectedJSON: `{"data":{"key":"value"},"meta":{"total_pages":100,"page":1,"page_size":10}}`, + expectedJSON: `{ + "data": { + "key": "value" + }, + "meta": { + "total_pages": 100, + "page": 1, + "page_size": 10 + } + }`, }, { name: "400 Bad Request with validation error", diff --git a/validation.go b/validation.go index f2e7582..b115001 100644 --- a/validation.go +++ b/validation.go @@ -22,7 +22,12 @@ func NewValidationProblemDetails(err error) *ProblemDetails { var validationErrors validator.ValidationErrors if !errors.As(err, &validationErrors) { // If the error is not of type ValidationErrors, return a generic problem response. - return NewProblemDetails(http.StatusBadRequest, "Invalid Request", "Invalid data format or structure") + return NewProblemDetails( + http.StatusBadRequest, + GetProblemTypeURL("bad_request_error"), + "Invalid Request", + "Invalid data format or structure", + ) } // Collect structured details about each validation error. @@ -35,7 +40,7 @@ func NewValidationProblemDetails(err error) *ProblemDetails { } return &ProblemDetails{ - Type: "https://example.com/validation-error", + Type: GetProblemTypeURL("validation_error"), Title: "Validation Error", Status: http.StatusBadRequest, Detail: "One or more fields failed validation.", diff --git a/validation_test.go b/validation_test.go index 3baaf81..dd7d1c4 100644 --- a/validation_test.go +++ b/validation_test.go @@ -21,10 +21,11 @@ func TestNewValidationProblemDetails(t *testing.T) { t.Fatal("Expected validation errors, but got none") } + SetProblemBaseURL("https://example.com") validationProblem := NewValidationProblemDetails(err) expectedProblem := &ProblemDetails{ - Type: "https://example.com/validation-error", + Type: "https://example.com/errors/validation-error", Title: "Validation Error", Status: 400, Detail: "One or more fields failed validation.", @@ -58,7 +59,7 @@ func TestIsRequestValid(t *testing.T) { name: "Missing Name and Age below minimum", request: TestValidationRequest{Age: 17}, expectedProblem: &ProblemDetails{ - Type: "https://example.com/validation-error", + Type: "https://example.com/errors/validation-error", Title: "Validation Error", Status: 400, Detail: "One or more fields failed validation.", @@ -74,7 +75,7 @@ func TestIsRequestValid(t *testing.T) { name: "Missing Age", request: TestValidationRequest{Name: "Alice"}, expectedProblem: &ProblemDetails{ - Type: "https://example.com/validation-error", + Type: "https://example.com/errors/validation-error", Title: "Validation Error", Status: 400, Detail: "One or more fields failed validation.", @@ -87,6 +88,8 @@ func TestIsRequestValid(t *testing.T) { }, } + SetProblemBaseURL("https://example.com") + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { problem := IsRequestValid(tt.request) From 6301ca8de16b4e8a35e63ff9d1628b2145d6f4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 16:42:43 +0100 Subject: [PATCH 6/8] fix: adjust typo and handle mutex --- problem_details.go | 25 ++++++++++++++++++------- request.go | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/problem_details.go b/problem_details.go index f9c2e0e..607893f 100644 --- a/problem_details.go +++ b/problem_details.go @@ -1,14 +1,19 @@ package httpsuite +import "sync" + const BlankUrl = "about:blank" -var problemBaseURL = BlankUrl -var errorTypePaths = map[string]string{ - "validation_error": "/errors/validation-error", - "not_found_error": "/errors/not-found", - "server_error": "/errors/server-error", - "bad_request_error": "/errors/bad-request", -} +var ( + mu sync.RWMutex + problemBaseURL = BlankUrl + errorTypePaths = map[string]string{ + "validation_error": "/errors/validation-error", + "not_found_error": "/errors/not-found", + "server_error": "/errors/server-error", + "bad_request_error": "/errors/bad-request", + } +) // ProblemDetails conforms to RFC 9457, providing a standard format for describing errors in HTTP APIs. type ProblemDetails struct { @@ -52,6 +57,8 @@ func NewProblemDetails(status int, problemType, title, detail string) *ProblemDe // // If the base URL is not set, the default value for the "type" field will be "about:blank". func SetProblemBaseURL(baseURL string) { + mu.Lock() + defer mu.Unlock() problemBaseURL = baseURL } @@ -71,6 +78,8 @@ func SetProblemBaseURL(baseURL string) { // // "https://api.mycompany.com/errors/validation-error" func SetProblemErrorTypePath(errorType, path string) { + mu.Lock() + defer mu.Unlock() errorTypePaths[errorType] = path } @@ -91,6 +100,8 @@ func SetProblemErrorTypePath(errorType, path string) { // // This method overwrites any existing paths with the same keys. func SetProblemErrorTypePaths(paths map[string]string) { + mu.Lock() + defer mu.Unlock() for errorType, path := range paths { errorTypePaths[errorType] = path } diff --git a/request.go b/request.go index 7d6989e..73a5426 100644 --- a/request.go +++ b/request.go @@ -97,7 +97,7 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, if err := request.SetParam(key, value); err != nil { problem := NewProblemDetails( http.StatusInternalServerError, - GetProblemTypeURL("sever_error"), + GetProblemTypeURL("server_error"), "Parameter Error", "Failed to set field "+key, ) From d9b8f7adaa4bb2fcbf53580704e466b508d7d830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 20:29:30 +0100 Subject: [PATCH 7/8] fix: use default title based on status code --- problem_details.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/problem_details.go b/problem_details.go index 607893f..098763d 100644 --- a/problem_details.go +++ b/problem_details.go @@ -1,6 +1,9 @@ package httpsuite -import "sync" +import ( + "net/http" + "sync" +) const BlankUrl = "about:blank" @@ -30,6 +33,12 @@ func NewProblemDetails(status int, problemType, title, detail string) *ProblemDe if problemType == "" { problemType = BlankUrl } + if title == "" { + title = http.StatusText(status) + if title == "" { + title = "Unknown error" + } + } return &ProblemDetails{ Type: problemType, Title: title, From a36da0de9b131f5e96686ce885ee62fb1f659c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 31 Jan 2025 20:33:24 +0100 Subject: [PATCH 8/8] fix: mu.RLock for getters --- problem_details.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/problem_details.go b/problem_details.go index 098763d..bf02c71 100644 --- a/problem_details.go +++ b/problem_details.go @@ -127,6 +127,8 @@ func SetProblemErrorTypePaths(paths map[string]string) { // // problemTypeURL := GetProblemTypeURL("validation_error") func GetProblemTypeURL(errorType string) string { + mu.RLock() + defer mu.RUnlock() if path, exists := errorTypePaths[errorType]; exists { return getProblemBaseURL() + path } @@ -136,6 +138,8 @@ func GetProblemTypeURL(errorType string) string { // getProblemBaseURL just return the baseURL if it isn't "about:blank" func getProblemBaseURL() string { + mu.RLock() + defer mu.RUnlock() if problemBaseURL == BlankUrl { return "" }