Skip to content

Commit

Permalink
(feat) improving response format (#2)
Browse files Browse the repository at this point in the history
* chore: adds gitignore

* feat: better response format

* docs: update readme

* fix: code review suggestions
  • Loading branch information
rluders authored Nov 12, 2024
1 parent 409ade1 commit 8cfb9dc
Show file tree
Hide file tree
Showing 10 changed files with 107 additions and 157 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.idea
8 changes: 0 additions & 8 deletions .idea/.gitignore

This file was deleted.

9 changes: 0 additions & 9 deletions .idea/httpsuite.iml

This file was deleted.

8 changes: 0 additions & 8 deletions .idea/modules.xml

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/vcs.xml

This file was deleted.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 8 additions & 4 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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")
}

Expand Down
85 changes: 56 additions & 29 deletions response.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
130 changes: 39 additions & 91 deletions response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
}
}

0 comments on commit 8cfb9dc

Please sign in to comment.