Skip to content

Commit a14152e

Browse files
committed
feat: better response format
1 parent ae0e62c commit a14152e

File tree

4 files changed

+86
-129
lines changed

4 files changed

+86
-129
lines changed

examples/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func main() {
3535
}
3636

3737
// Step 2: Send a success response
38-
httpsuite.SendResponse(w, "Request received successfully", http.StatusOK, &req)
38+
httpsuite.SendResponse[SampleRequest](w, http.StatusOK, *req, nil, nil)
3939
})
4040

4141
log.Println("Starting server on :8080")

request.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request,
3030

3131
if r.Body != http.NoBody {
3232
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
33-
SendResponse[any](w, "Invalid JSON format", http.StatusBadRequest, nil)
33+
SendResponse[any](w, http.StatusBadRequest, nil,
34+
[]Error{{Code: http.StatusBadRequest, Message: "Invalid JSON format"}}, nil)
3435
return empty, err
3536
}
3637
}
@@ -44,19 +45,22 @@ func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request,
4445
for _, key := range pathParams {
4546
value := chi.URLParam(r, key)
4647
if value == "" {
47-
SendResponse[any](w, "Parameter "+key+" not found in request", http.StatusBadRequest, nil)
48+
SendResponse[any](w, http.StatusBadRequest, nil,
49+
[]Error{{Code: http.StatusBadRequest, Message: "Parameter " + key + " not found in request"}}, nil)
4850
return empty, errors.New("missing parameter: " + key)
4951
}
5052

5153
if err := request.SetParam(key, value); err != nil {
52-
SendResponse[any](w, "Failed to set field "+key, http.StatusInternalServerError, nil)
54+
SendResponse[any](w, http.StatusInternalServerError, nil,
55+
[]Error{{Code: http.StatusInternalServerError, Message: "Failed to set field " + key}}, nil)
5356
return empty, err
5457
}
5558
}
5659

5760
// Validate the combined request struct
5861
if validationErr := IsRequestValid(request); validationErr != nil {
59-
SendResponse[ValidationErrors](w, "Validation error", http.StatusBadRequest, validationErr)
62+
SendResponse[any](w, http.StatusBadRequest, nil,
63+
[]Error{{Code: http.StatusBadRequest, Message: "Validation error", Details: validationErr}}, nil)
6064
return empty, errors.New("validation error")
6165
}
6266

response.go

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,47 +7,67 @@ import (
77
)
88

99
// Response represents the structure of an HTTP response, including a status code, message, and optional body.
10+
// T represents the type of the `Data` field, allowing this structure to be used flexibly across different endpoints.
1011
type Response[T any] struct {
11-
Code int `json:"code"`
12-
Message string `json:"message"`
13-
Body T `json:"body,omitempty"`
12+
Data T `json:"data,omitempty"`
13+
Errors []Error `json:"errors,omitempty"`
14+
Meta *Meta `json:"meta,omitempty"`
1415
}
1516

16-
// Marshal serializes the Response struct into a JSON byte slice.
17-
// It logs an error if marshalling fails.
18-
func (r *Response[T]) Marshal() []byte {
19-
jsonResponse, err := json.Marshal(r)
20-
if err != nil {
21-
log.Printf("failed to marshal response: %v", err)
22-
}
17+
// Error represents an error in the aPI response, with a structured format to describe issues in a consistent manner.
18+
type Error struct {
19+
// Code unique error code or HTTP status code for categorizing the error
20+
Code int `json:"code"`
21+
// Message user-friendly message describing the error.
22+
Message string `json:"message"`
23+
// Details additional details about the error, often used for validation errors.
24+
Details interface{} `json:"details,omitempty"`
25+
}
2326

24-
return jsonResponse
27+
// Meta provides additional information about the response, such as pagination details.
28+
// This is particularly useful for endpoints returning lists of data.
29+
type Meta struct {
30+
// Page the current page number
31+
Page int `json:"page,omitempty"`
32+
// PageSize the number of items per page
33+
PageSize int `json:"page_size,omitempty"`
34+
// TotalPages the total number of pages available.
35+
TotalPages int `json:"total_pages,omitempty"`
36+
// TotalItems the total number of items across all pages.
37+
TotalItems int `json:"total_items,omitempty"`
2538
}
2639

27-
// SendResponse creates a Response struct, serializes it to JSON, and writes it to the provided http.ResponseWriter.
28-
// If the body parameter is non-nil, it will be included in the response body.
29-
func SendResponse[T any](w http.ResponseWriter, message string, code int, body *T) {
40+
// SendResponse sends a JSON response to the client, using a unified structure for both success and error responses.
41+
// T represents the type of the `data` payload. This function automatically adapts the response structure
42+
// based on whether `data` or `errors` is provided, promoting a consistent API format.
43+
//
44+
// Parameters:
45+
// - w: The http.ResponseWriter to send the response.
46+
// - code: HTTP status code to indicate success or failure.
47+
// - data: The main payload of the response. Use `nil` for error responses.
48+
// - errs: A slice of Error structs to describe issues. Use `nil` for successful responses.
49+
// - meta: Optional metadata, such as pagination information. Use `nil` if not needed.
50+
func SendResponse[T any](w http.ResponseWriter, code int, data T, errs []Error, meta *Meta) {
51+
w.Header().Set("Content-Type", "application/json")
52+
3053
response := &Response[T]{
31-
Code: code,
32-
Message: message,
33-
}
34-
if body != nil {
35-
response.Body = *body
54+
Data: data,
55+
Errors: errs,
56+
Meta: meta,
3657
}
3758

38-
writeResponse[T](w, response)
39-
}
59+
// Set the status code after encoding to ensure no issues with writing the response body
60+
w.WriteHeader(code)
4061

41-
// writeResponse serializes a Response and writes it to the http.ResponseWriter with appropriate headers.
42-
// If an error occurs during the write, it logs the error and sends a 500 Internal Server Error response.
43-
func writeResponse[T any](w http.ResponseWriter, r *Response[T]) {
44-
jsonResponse := r.Marshal()
62+
// Attempt to encode the response as JSON
63+
if err := json.NewEncoder(w).Encode(response); err != nil {
64+
log.Printf("Error writing response: %v", err)
4565

46-
w.Header().Set("Content-Type", "application/json")
47-
w.WriteHeader(r.Code)
66+
// Overwrite with internal server error code
67+
w.WriteHeader(http.StatusInternalServerError)
4868

49-
if _, err := w.Write(jsonResponse); err != nil {
50-
log.Printf("Error writing response: %v", err)
51-
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
69+
errResponse := `{"errors":[{"code":500,"message":"Internal Server Error"}]}`
70+
http.Error(w, errResponse, http.StatusInternalServerError)
71+
return
5272
}
5373
}

response_test.go

Lines changed: 27 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -12,115 +12,48 @@ type TestResponse struct {
1212
Key string `json:"key"`
1313
}
1414

15-
func TestResponse_Marshal(t *testing.T) {
16-
tests := []struct {
17-
name string
18-
response Response[any]
19-
expected string
20-
}{
21-
{
22-
name: "Basic Response",
23-
response: Response[any]{Code: 200, Message: "OK"},
24-
expected: `{"code":200,"message":"OK"}`,
25-
},
26-
{
27-
name: "Response with Body",
28-
response: Response[any]{Code: 201, Message: "Created", Body: map[string]string{"id": "123"}},
29-
expected: `{"code":201,"message":"Created","body":{"id":"123"}}`,
30-
},
31-
{
32-
name: "Response with Empty Body",
33-
response: Response[any]{Code: 204, Message: "No Content", Body: nil},
34-
expected: `{"code":204,"message":"No Content"}`,
35-
},
36-
}
37-
38-
for _, tt := range tests {
39-
t.Run(tt.name, func(t *testing.T) {
40-
jsonResponse := tt.response.Marshal()
41-
assert.JSONEq(t, tt.expected, string(jsonResponse))
42-
})
43-
}
44-
}
45-
4615
func Test_SendResponse(t *testing.T) {
47-
tests := []struct {
48-
name string
49-
message string
50-
code int
51-
body any
52-
expectedCode int
53-
expectedBody string
54-
expectedHeader string
55-
}{
56-
{
57-
name: "200 OK with TestResponse body",
58-
message: "Success",
59-
code: http.StatusOK,
60-
body: &TestResponse{Key: "value"},
61-
expectedCode: http.StatusOK,
62-
expectedBody: `{"code":200,"message":"Success","body":{"key":"value"}}`,
63-
expectedHeader: "application/json",
64-
},
65-
{
66-
name: "404 Not Found without body",
67-
message: "Not Found",
68-
code: http.StatusNotFound,
69-
body: nil,
70-
expectedCode: http.StatusNotFound,
71-
expectedBody: `{"code":404,"message":"Not Found"}`,
72-
expectedHeader: "application/json",
73-
},
74-
}
75-
76-
for _, tt := range tests {
77-
t.Run(tt.name, func(t *testing.T) {
78-
recorder := httptest.NewRecorder()
79-
80-
switch body := tt.body.(type) {
81-
case *TestResponse:
82-
SendResponse[TestResponse](recorder, tt.message, tt.code, body)
83-
default:
84-
SendResponse(recorder, tt.message, tt.code, &tt.body)
85-
}
86-
87-
assert.Equal(t, tt.expectedCode, recorder.Code)
88-
assert.Equal(t, tt.expectedHeader, recorder.Header().Get("Content-Type"))
89-
assert.JSONEq(t, tt.expectedBody, recorder.Body.String())
90-
})
91-
}
92-
}
93-
94-
func TestWriteResponse(t *testing.T) {
9516
tests := []struct {
9617
name string
97-
response Response[any]
18+
code int
19+
data any
20+
errs []Error
21+
meta *Meta
9822
expectedCode int
99-
expectedBody string
23+
expectedJSON string
10024
}{
10125
{
102-
name: "200 OK with Body",
103-
response: Response[any]{Code: 200, Message: "OK", Body: map[string]string{"id": "123"}},
104-
expectedCode: 200,
105-
expectedBody: `{"code":200,"message":"OK","body":{"id":"123"}}`,
26+
name: "200 OK with TestResponse body",
27+
code: http.StatusOK,
28+
data: &TestResponse{Key: "value"},
29+
errs: nil,
30+
expectedCode: http.StatusOK,
31+
expectedJSON: `{"data":{"key":"value"}}`,
10632
},
10733
{
108-
name: "500 Internal Server Error without Body",
109-
response: Response[any]{Code: 500, Message: "Internal Server Error"},
110-
expectedCode: 500,
111-
expectedBody: `{"code":500,"message":"Internal Server Error"}`,
34+
name: "404 Not Found without body",
35+
code: http.StatusNotFound,
36+
data: nil,
37+
errs: []Error{{Code: http.StatusNotFound, Message: "Not Found"}},
38+
expectedCode: http.StatusNotFound,
39+
expectedJSON: `{"errors":[{"code":404,"message":"Not Found"}]}`,
11240
},
11341
}
11442

11543
for _, tt := range tests {
11644
t.Run(tt.name, func(t *testing.T) {
117-
recorder := httptest.NewRecorder()
45+
w := httptest.NewRecorder()
11846

119-
writeResponse(recorder, &tt.response)
47+
switch data := tt.data.(type) {
48+
case TestResponse:
49+
SendResponse[TestResponse](w, tt.code, data, tt.errs, tt.meta)
50+
default:
51+
SendResponse[any](w, tt.code, tt.data, tt.errs, tt.meta)
52+
}
12053

121-
assert.Equal(t, tt.expectedCode, recorder.Code)
122-
assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
123-
assert.JSONEq(t, tt.expectedBody, recorder.Body.String())
54+
assert.Equal(t, tt.expectedCode, w.Code)
55+
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
56+
assert.JSONEq(t, tt.expectedJSON, w.Body.String())
12457
})
12558
}
12659
}

0 commit comments

Comments
 (0)