Skip to content

Commit 8cfb9dc

Browse files
authored
(feat) improving response format (#2)
* chore: adds gitignore * feat: better response format * docs: update readme * fix: code review suggestions
1 parent 409ade1 commit 8cfb9dc

File tree

10 files changed

+107
-157
lines changed

10 files changed

+107
-157
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.idea

.idea/.gitignore

Lines changed: 0 additions & 8 deletions
This file was deleted.

.idea/httpsuite.iml

Lines changed: 0 additions & 9 deletions
This file was deleted.

.idea/modules.xml

Lines changed: 0 additions & 8 deletions
This file was deleted.

.idea/vcs.xml

Lines changed: 0 additions & 6 deletions
This file was deleted.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,13 @@ func main() {
6666
}
6767

6868
// Step 2: Send a success response
69-
httpsuite.SendResponse(w, "Request received successfully", http.StatusOK, &req)
69+
httpsuite.SendResponse[SampleRequest](w, http.StatusOK, *req, nil, nil)
7070
})
7171

7272
log.Println("Starting server on :8080")
7373
http.ListenAndServe(":8080", r)
7474
}
75+
7576
```
7677

7778
Check out the [example folder for a complete project](./examples) demonstrating how to integrate **httpsuite** into

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, Details: err.Error()}}, 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: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,80 @@
11
package httpsuite
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"log"
67
"net/http"
78
)
89

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

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-
}
18+
// Error represents an error in the aPI response, with a structured format to describe issues in a consistent manner.
19+
type Error struct {
20+
// Code unique error code or HTTP status code for categorizing the error
21+
Code int `json:"code"`
22+
// Message user-friendly message describing the error.
23+
Message string `json:"message"`
24+
// Details additional details about the error, often used for validation errors.
25+
Details interface{} `json:"details,omitempty"`
26+
}
2327

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

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) {
41+
// SendResponse sends a JSON response to the client, using a unified structure for both success and error responses.
42+
// T represents the type of the `data` payload. This function automatically adapts the response structure
43+
// based on whether `data` or `errors` is provided, promoting a consistent API format.
44+
//
45+
// Parameters:
46+
// - w: The http.ResponseWriter to send the response.
47+
// - code: HTTP status code to indicate success or failure.
48+
// - data: The main payload of the response. Use `nil` for error responses.
49+
// - errs: A slice of Error structs to describe issues. Use `nil` for successful responses.
50+
// - meta: Optional metadata, such as pagination information. Use `nil` if not needed.
51+
func SendResponse[T any](w http.ResponseWriter, code int, data T, errs []Error, meta *Meta) {
52+
w.Header().Set("Content-Type", "application/json")
53+
3054
response := &Response[T]{
31-
Code: code,
32-
Message: message,
33-
}
34-
if body != nil {
35-
response.Body = *body
55+
Data: data,
56+
Errors: errs,
57+
Meta: meta,
3658
}
3759

38-
writeResponse[T](w, response)
39-
}
60+
// Set the status code after encoding to ensure no issues with writing the response body
61+
w.WriteHeader(code)
62+
63+
// Attempt to encode the response as JSON
64+
var buffer bytes.Buffer
65+
if err := json.NewEncoder(&buffer).Encode(response); err != nil {
66+
log.Printf("Error writing response: %v", err)
4067

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()
68+
errResponse := `{"errors":[{"code":500,"message":"Internal Server Error"}]}`
69+
http.Error(w, errResponse, http.StatusInternalServerError)
70+
return
71+
}
4572

46-
w.Header().Set("Content-Type", "application/json")
47-
w.WriteHeader(r.Code)
73+
// Set the status code after success encoding
74+
w.WriteHeader(code)
4875

49-
if _, err := w.Write(jsonResponse); err != nil {
76+
// Write the encoded response to the ResponseWriter
77+
if _, err := w.Write(buffer.Bytes()); err != nil {
5078
log.Printf("Error writing response: %v", err)
51-
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
5279
}
5380
}

response_test.go

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

15-
func TestResponse_Marshal(t *testing.T) {
15+
func Test_SendResponse(t *testing.T) {
1616
tests := []struct {
17-
name string
18-
response Response[any]
19-
expected string
17+
name string
18+
code int
19+
data any
20+
errs []Error
21+
meta *Meta
22+
expectedCode int
23+
expectedJSON string
2024
}{
2125
{
22-
name: "Basic Response",
23-
response: Response[any]{Code: 200, Message: "OK"},
24-
expected: `{"code":200,"message":"OK"}`,
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"}}`,
2532
},
2633
{
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"}}`,
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"}]}`,
3040
},
3141
{
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-
46-
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",
42+
name: "200 OK with pagination metadata",
43+
code: http.StatusOK,
44+
data: &TestResponse{Key: "value"},
45+
meta: &Meta{TotalPages: 100, Page: 1, PageSize: 10},
46+
expectedCode: http.StatusOK,
47+
expectedJSON: `{"data":{"key":"value"},"meta":{"total_pages":100,"page":1,"page_size":10}}`,
6448
},
6549
{
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",
50+
name: "400 Bad Request with multiple errors",
51+
code: http.StatusBadRequest,
52+
errs: []Error{{Code: 400, Message: "Invalid email"}, {Code: 400, Message: "Invalid password"}},
53+
expectedCode: http.StatusBadRequest,
54+
expectedJSON: `{"errors":[{"code":400,"message":"Invalid email"},{"code":400,"message":"Invalid password"}]}`,
7355
},
7456
}
7557

7658
for _, tt := range tests {
7759
t.Run(tt.name, func(t *testing.T) {
78-
recorder := httptest.NewRecorder()
60+
w := httptest.NewRecorder()
7961

80-
switch body := tt.body.(type) {
81-
case *TestResponse:
82-
SendResponse[TestResponse](recorder, tt.message, tt.code, body)
62+
switch data := tt.data.(type) {
63+
case TestResponse:
64+
SendResponse[TestResponse](w, tt.code, data, tt.errs, tt.meta)
8365
default:
84-
SendResponse(recorder, tt.message, tt.code, &tt.body)
66+
SendResponse[any](w, tt.code, tt.data, tt.errs, tt.meta)
8567
}
8668

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) {
95-
tests := []struct {
96-
name string
97-
response Response[any]
98-
expectedCode int
99-
expectedBody string
100-
}{
101-
{
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"}}`,
106-
},
107-
{
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"}`,
112-
},
113-
}
114-
115-
for _, tt := range tests {
116-
t.Run(tt.name, func(t *testing.T) {
117-
recorder := httptest.NewRecorder()
118-
119-
writeResponse(recorder, &tt.response)
120-
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())
69+
assert.Equal(t, tt.expectedCode, w.Code)
70+
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
71+
assert.JSONEq(t, tt.expectedJSON, w.Body.String())
12472
})
12573
}
12674
}

0 commit comments

Comments
 (0)