diff --git a/docs/advanced-guide/gofr-errors/page.md b/docs/advanced-guide/gofr-errors/page.md index 188e89cc3..018787f6e 100644 --- a/docs/advanced-guide/gofr-errors/page.md +++ b/docs/advanced-guide/gofr-errors/page.md @@ -64,3 +64,35 @@ func (c customError) LogLevel() logging.Level { return logging.WARN } ``` + +## Extended Error Responses + +For [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) style error responses with additional fields, implement the ResponseMarshaller interface: + +```go +type ResponseMarshaller interface { + Response() map[string]any +} +``` + +#### Usage: +```go +type ValidationError struct { + Field string + Message string + Code int +} + +func (e ValidationError) Error() string { return e.Message } +func (e ValidationError) StatusCode() int { return e.Code } + +func (e ValidationError) Response() map[string]any { + return map[string]any{ + "field": e.Field, + "type": "validation_error", + "details": "Invalid input format", + } +} +``` + +> NOTE: The `message` field is automatically populated from the `Error()` method. Custom fields with the name "message" in the `Response()` map should not be used as they will be ignored in favor of the `Error()` value. \ No newline at end of file diff --git a/pkg/gofr/container/mock_container.go b/pkg/gofr/container/mock_container.go index 4b8102eab..5e5f6426c 100644 --- a/pkg/gofr/container/mock_container.go +++ b/pkg/gofr/container/mock_container.go @@ -32,7 +32,7 @@ type Mocks struct { type options func(c *Container, ctrl *gomock.Controller) any -//nolint:revive // Because user should not access the options, and we might change it to an interface in the future. +//nolint:revive // WithMockHTTPService returns an exported type intentionally; options are internal and subject to change. func WithMockHTTPService(httpServiceNames ...string) options { return func(c *Container, ctrl *gomock.Controller) any { mockservice := service.NewMockHTTP(ctrl) diff --git a/pkg/gofr/datasource/pubsub/kafka/kafka.go b/pkg/gofr/datasource/pubsub/kafka/kafka.go index 8fef71813..61f0ea9a7 100644 --- a/pkg/gofr/datasource/pubsub/kafka/kafka.go +++ b/pkg/gofr/datasource/pubsub/kafka/kafka.go @@ -69,7 +69,7 @@ type kafkaClient struct { metrics Metrics } -//nolint:revive // Allow returning unexported types as intended. +//nolint:revive // New allow returning unexported types as intended. func New(conf *Config, logger pubsub.Logger, metrics Metrics) *kafkaClient { err := validateConfigs(conf) if err != nil { diff --git a/pkg/gofr/http/responder.go b/pkg/gofr/http/responder.go index c13700be3..bce1ba734 100644 --- a/pkg/gofr/http/responder.go +++ b/pkg/gofr/http/responder.go @@ -2,12 +2,17 @@ package http import ( "encoding/json" + "errors" "net/http" "reflect" resTypes "gofr.dev/pkg/gofr/http/response" ) +var ( + errEmptyResponse = errors.New("internal server error") +) + // NewResponder creates a new Responder instance from the given http.ResponseWriter.. func NewResponder(w http.ResponseWriter, method string) *Responder { return &Responder{w: w, method: method} @@ -22,7 +27,7 @@ type Responder struct { // Respond sends a response with the given data and handles potential errors, setting appropriate // status codes and formatting responses as JSON or raw data as needed. func (r Responder) Respond(data any, err error) { - statusCode, errorObj := getStatusCode(r.method, data, err) + statusCode, errorObj := r.determineResponse(data, err) var resp any switch v := data.(type) { @@ -58,6 +63,49 @@ func (r Responder) Respond(data any, err error) { _ = json.NewEncoder(r.w).Encode(resp) } +func (r Responder) determineResponse(data any, err error) (statusCode int, errObj any) { + // Handle empty struct case first + if err != nil && isEmptyStruct(data) { + return http.StatusInternalServerError, createErrorResponse(errEmptyResponse) + } + + statusCode, errorObj := getStatusCode(r.method, data, err) + + if statusCode == 0 { + statusCode = http.StatusInternalServerError + } + + return statusCode, errorObj +} + +// isEmptyStruct checks if a value is a struct with all zero/empty fields. +func isEmptyStruct(data any) bool { + if data == nil { + return false + } + + v := reflect.ValueOf(data) + + // Handle pointers by dereferencing them + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return false // nil pointer isn't an empty struct + } + + v = v.Elem() + } + + // Only check actual struct types + if v.Kind() != reflect.Struct { + return false + } + + // Compare against a zero value of the same type + zero := reflect.Zero(v.Type()).Interface() + + return reflect.DeepEqual(data, zero) +} + // getStatusCode returns corresponding HTTP status codes. func getStatusCode(method string, data any, err error) (statusCode int, errResp any) { if err == nil { @@ -90,10 +138,28 @@ func handleSuccess(method string, data any) (statusCode int, err any) { } } +// ResponseMarshaller defines an interface for errors that can provide custom fields. +// This enables errors to extend the error response with additional fields. +type ResponseMarshaller interface { + Response() map[string]any +} + +// createErrorResponse returns an error response that always contains a "message" field, +// and if the error implements ResponseMarshaller, it merges custom fields into the response. func createErrorResponse(err error) map[string]any { - return map[string]any{ - "message": err.Error(), + resp := map[string]any{"message": err.Error()} + + if rm, ok := err.(ResponseMarshaller); ok { + for k, v := range rm.Response() { + if k == "message" { + continue // Skip to avoid overriding the Error() message + } + + resp[k] = v + } } + + return resp } // response represents an HTTP response. diff --git a/pkg/gofr/http/responder_test.go b/pkg/gofr/http/responder_test.go index afc2b10fb..e88ec8392 100644 --- a/pkg/gofr/http/responder_test.go +++ b/pkg/gofr/http/responder_test.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "io" "net/http" "net/http/httptest" "os" @@ -207,6 +208,129 @@ func TestResponder_TemplateResponse(t *testing.T) { assert.Equal(t, expectedBody, responseBody) } +func TestResponder_CustomErrorWithResponse(t *testing.T) { + w := httptest.NewRecorder() + responder := NewResponder(w, http.MethodGet) + + customErr := &CustomError{ + Code: http.StatusNotFound, + Message: "resource not found", + Title: "Custom Error", + } + + responder.Respond(nil, customErr) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedJSON := `{ + "error": { + "code": 404, + "title": "Custom Error", + "message": "resource not found" + } + }` + + assert.JSONEq(t, expectedJSON, string(bodyBytes)) +} + +type CustomError struct { + Code int + Message string + Title string +} + +func (e *CustomError) Error() string { return e.Message } +func (e *CustomError) StatusCode() int { return e.Code } +func (e *CustomError) Response() map[string]any { + return map[string]any{"title": e.Title, "code": e.Code} +} + +func TestResponder_ReservedMessageField(t *testing.T) { + w := httptest.NewRecorder() + responder := NewResponder(w, http.MethodGet) + + msgErr := &MessageOverrideError{ + Msg: "original message", + } + + responder.Respond(nil, msgErr) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedJSON := `{ + "error": { + "message": "original message", + "info": "additional info" + } + }` + + assert.JSONEq(t, expectedJSON, string(bodyBytes)) +} + +// EmptyError represents an error as an empty struct. +// It implements the error interface. +type emptyError struct{} + +// Error implements the error interface. +func (emptyError) Error() string { + return "error occurred" +} + +func TestResponder_EmptyErrorStruct(t *testing.T) { + recorder := httptest.NewRecorder() + responder := Responder{w: recorder, method: http.MethodGet} + + statusCode, errObj := responder.determineResponse(nil, emptyError{}) + + assert.Equal(t, http.StatusInternalServerError, statusCode) + assert.Equal(t, map[string]any{"message": "error occurred"}, errObj) +} + +func TestIsEmptyStruct(t *testing.T) { + tests := []struct { + desc string + data any + expected bool + }{ + {"nil value", nil, false}, + {"empty struct", struct{}{}, true}, + {"non-empty struct", struct{ ID int }{ID: 1}, false}, + {"nil pointer to struct", (*struct{})(nil), false}, + {"pointer to non-empty struct", &struct{ ID int }{ID: 1}, false}, + {"non-struct type", 42, false}, + } + + for i, tc := range tests { + result := isEmptyStruct(tc.data) + + assert.Equal(t, tc.expected, result, "TEST[%d] Failed: %s", i, tc.desc) + } +} + +type MessageOverrideError struct { + Msg string +} + +func (e *MessageOverrideError) Error() string { return e.Msg } +func (*MessageOverrideError) Response() map[string]any { + return map[string]any{ + "message": "trying to override", + "info": "additional info", + } +} + func createTemplateFile(t *testing.T, path, content string) { t.Helper()