diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/httpsuite.iml b/.idea/httpsuite.iml deleted file mode 100644 index 5e764c4..0000000 --- a/.idea/httpsuite.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 4868574..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 55f2d5d..e38f2be 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,13 @@ func main() { } // Step 2: Send a success response - httpsuite.SendResponse(w, "Request received successfully", http.StatusOK, &req) + httpsuite.SendResponse[SampleRequest](w, http.StatusOK, *req, nil, nil) }) log.Println("Starting server on :8080") http.ListenAndServe(":8080", r) } + ``` Check out the [example folder for a complete project](./examples) demonstrating how to integrate **httpsuite** into diff --git a/examples/main.go b/examples/main.go index ebf9591..22b8b94 100644 --- a/examples/main.go +++ b/examples/main.go @@ -35,7 +35,7 @@ func main() { } // Step 2: Send a success response - httpsuite.SendResponse(w, "Request received successfully", http.StatusOK, &req) + httpsuite.SendResponse[SampleRequest](w, http.StatusOK, *req, nil, nil) }) log.Println("Starting server on :8080") diff --git a/request.go b/request.go index 36af1b2..72c3e2b 100644 --- a/request.go +++ b/request.go @@ -30,7 +30,8 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, if r.Body != http.NoBody { if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - SendResponse[any](w, "Invalid JSON format", http.StatusBadRequest, nil) + SendResponse[any](w, http.StatusBadRequest, nil, + []Error{{Code: http.StatusBadRequest, Message: "Invalid JSON format"}}, nil) return empty, err } } @@ -44,19 +45,22 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, for _, key := range pathParams { value := chi.URLParam(r, key) if value == "" { - SendResponse[any](w, "Parameter "+key+" not found in request", http.StatusBadRequest, nil) + SendResponse[any](w, http.StatusBadRequest, nil, + []Error{{Code: http.StatusBadRequest, Message: "Parameter " + key + " not found in request"}}, nil) return empty, errors.New("missing parameter: " + key) } if err := request.SetParam(key, value); err != nil { - SendResponse[any](w, "Failed to set field "+key, http.StatusInternalServerError, nil) + SendResponse[any](w, http.StatusInternalServerError, nil, + []Error{{Code: http.StatusInternalServerError, Message: "Failed to set field " + key, Details: err.Error()}}, nil) return empty, err } } // Validate the combined request struct if validationErr := IsRequestValid(request); validationErr != nil { - SendResponse[ValidationErrors](w, "Validation error", http.StatusBadRequest, validationErr) + SendResponse[any](w, http.StatusBadRequest, nil, + []Error{{Code: http.StatusBadRequest, Message: "Validation error", Details: validationErr}}, nil) return empty, errors.New("validation error") } diff --git a/response.go b/response.go index adb12fd..67931bb 100644 --- a/response.go +++ b/response.go @@ -1,53 +1,80 @@ package httpsuite import ( + "bytes" "encoding/json" "log" "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. type Response[T any] struct { - Code int `json:"code"` - Message string `json:"message"` - Body T `json:"body,omitempty"` + Data T `json:"data,omitempty"` + Errors []Error `json:"errors,omitempty"` + Meta *Meta `json:"meta,omitempty"` } -// Marshal serializes the Response struct into a JSON byte slice. -// It logs an error if marshalling fails. -func (r *Response[T]) Marshal() []byte { - jsonResponse, err := json.Marshal(r) - if err != nil { - log.Printf("failed to marshal response: %v", err) - } +// 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"` +} - return jsonResponse +// 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. + TotalPages int `json:"total_pages,omitempty"` + // TotalItems the total number of items across all pages. + TotalItems int `json:"total_items,omitempty"` } -// SendResponse creates a Response struct, serializes it to JSON, and writes it to the provided http.ResponseWriter. -// If the body parameter is non-nil, it will be included in the response body. -func SendResponse[T any](w http.ResponseWriter, message string, code int, body *T) { +// 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. +// +// 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") + response := &Response[T]{ - Code: code, - Message: message, - } - if body != nil { - response.Body = *body + Data: data, + Errors: errs, + Meta: meta, } - writeResponse[T](w, response) -} + // Set the status code after encoding to ensure no issues with writing the response body + w.WriteHeader(code) + + // 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) -// writeResponse serializes a Response and writes it to the http.ResponseWriter with appropriate headers. -// If an error occurs during the write, it logs the error and sends a 500 Internal Server Error response. -func writeResponse[T any](w http.ResponseWriter, r *Response[T]) { - jsonResponse := r.Marshal() + errResponse := `{"errors":[{"code":500,"message":"Internal Server Error"}]}` + http.Error(w, errResponse, http.StatusInternalServerError) + return + } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(r.Code) + // Set the status code after success encoding + w.WriteHeader(code) - if _, err := w.Write(jsonResponse); err != nil { + // Write the encoded response to the ResponseWriter + if _, err := w.Write(buffer.Bytes()); err != nil { log.Printf("Error writing response: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } diff --git a/response_test.go b/response_test.go index 2df7b4a..32c764a 100644 --- a/response_test.go +++ b/response_test.go @@ -12,115 +12,63 @@ type TestResponse struct { Key string `json:"key"` } -func TestResponse_Marshal(t *testing.T) { +func Test_SendResponse(t *testing.T) { tests := []struct { - name string - response Response[any] - expected string + name string + code int + data any + errs []Error + meta *Meta + expectedCode int + expectedJSON string }{ { - name: "Basic Response", - response: Response[any]{Code: 200, Message: "OK"}, - expected: `{"code":200,"message":"OK"}`, + name: "200 OK with TestResponse body", + code: http.StatusOK, + data: &TestResponse{Key: "value"}, + errs: nil, + expectedCode: http.StatusOK, + expectedJSON: `{"data":{"key":"value"}}`, }, { - name: "Response with Body", - response: Response[any]{Code: 201, Message: "Created", Body: map[string]string{"id": "123"}}, - expected: `{"code":201,"message":"Created","body":{"id":"123"}}`, + name: "404 Not Found without body", + code: http.StatusNotFound, + data: nil, + errs: []Error{{Code: http.StatusNotFound, Message: "Not Found"}}, + expectedCode: http.StatusNotFound, + expectedJSON: `{"errors":[{"code":404,"message":"Not Found"}]}`, }, { - name: "Response with Empty Body", - response: Response[any]{Code: 204, Message: "No Content", Body: nil}, - expected: `{"code":204,"message":"No Content"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - jsonResponse := tt.response.Marshal() - assert.JSONEq(t, tt.expected, string(jsonResponse)) - }) - } -} - -func Test_SendResponse(t *testing.T) { - tests := []struct { - name string - message string - code int - body any - expectedCode int - expectedBody string - expectedHeader string - }{ - { - name: "200 OK with TestResponse body", - message: "Success", - code: http.StatusOK, - body: &TestResponse{Key: "value"}, - expectedCode: http.StatusOK, - expectedBody: `{"code":200,"message":"Success","body":{"key":"value"}}`, - expectedHeader: "application/json", + name: "200 OK with pagination metadata", + code: http.StatusOK, + 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}}`, }, { - name: "404 Not Found without body", - message: "Not Found", - code: http.StatusNotFound, - body: nil, - expectedCode: http.StatusNotFound, - expectedBody: `{"code":404,"message":"Not Found"}`, - expectedHeader: "application/json", + name: "400 Bad Request with multiple errors", + code: http.StatusBadRequest, + errs: []Error{{Code: 400, Message: "Invalid email"}, {Code: 400, Message: "Invalid password"}}, + expectedCode: http.StatusBadRequest, + expectedJSON: `{"errors":[{"code":400,"message":"Invalid email"},{"code":400,"message":"Invalid password"}]}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - recorder := httptest.NewRecorder() + w := httptest.NewRecorder() - switch body := tt.body.(type) { - case *TestResponse: - SendResponse[TestResponse](recorder, tt.message, tt.code, body) + switch data := tt.data.(type) { + case TestResponse: + SendResponse[TestResponse](w, tt.code, data, tt.errs, tt.meta) default: - SendResponse(recorder, tt.message, tt.code, &tt.body) + SendResponse[any](w, tt.code, tt.data, tt.errs, tt.meta) } - assert.Equal(t, tt.expectedCode, recorder.Code) - assert.Equal(t, tt.expectedHeader, recorder.Header().Get("Content-Type")) - assert.JSONEq(t, tt.expectedBody, recorder.Body.String()) - }) - } -} - -func TestWriteResponse(t *testing.T) { - tests := []struct { - name string - response Response[any] - expectedCode int - expectedBody string - }{ - { - name: "200 OK with Body", - response: Response[any]{Code: 200, Message: "OK", Body: map[string]string{"id": "123"}}, - expectedCode: 200, - expectedBody: `{"code":200,"message":"OK","body":{"id":"123"}}`, - }, - { - name: "500 Internal Server Error without Body", - response: Response[any]{Code: 500, Message: "Internal Server Error"}, - expectedCode: 500, - expectedBody: `{"code":500,"message":"Internal Server Error"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recorder := httptest.NewRecorder() - - writeResponse(recorder, &tt.response) - - assert.Equal(t, tt.expectedCode, recorder.Code) - assert.Equal(t, "application/json", recorder.Header().Get("Content-Type")) - assert.JSONEq(t, tt.expectedBody, recorder.Body.String()) + assert.Equal(t, tt.expectedCode, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + assert.JSONEq(t, tt.expectedJSON, w.Body.String()) }) } }