Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ coverage/

# Liquibase Specific files/folders
liquibase.properties
liquibase_libs/
liquibase_libs/

.junie
39 changes: 39 additions & 0 deletions internal/error_envelope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package internal

import (
"encoding/json"
"log/slog"
"net/http"
)

type ErrorEnvelope struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}

// HandleHttpError handles an error and writes it to the response writer
// in the format specified by RFC 7807. If an invalid status code is provided,
// it will default to 500 Internal Server Error.
func HandleHttpError(w http.ResponseWriter, err ErrorEnvelope, statusCode int) {
if err.Type == "" {
err.Type = "about:blank"
}
if http.StatusText(statusCode) == "" {
slog.Error("Unknown HTTP status code", "status_code", statusCode)
statusCode = http.StatusInternalServerError
}
if err.Title == "" {
err.Title = http.StatusText(statusCode)
}
err.Status = statusCode

w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(statusCode)

if encodeErr := json.NewEncoder(w).Encode(err); encodeErr != nil {
slog.Error("Failed to encode error response", "error", encodeErr)
}
}
99 changes: 99 additions & 0 deletions internal/error_envelope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package internal

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestHandleHttpError(t *testing.T) {
tests := []struct {
name string
err ErrorEnvelope
statusCode int
expectedStatus int
expectedBody string
expectedTitle string
expectedType string
Comment thread
jlpdeveloper marked this conversation as resolved.
}{
{
name: "Empty Envelope",
err: ErrorEnvelope{},
statusCode: http.StatusBadRequest,
expectedStatus: http.StatusBadRequest,
expectedTitle: "Bad Request",
expectedType: "about:blank",
},
{
name: "With Detail",
err: ErrorEnvelope{
Detail: "Validation failed",
},
statusCode: http.StatusUnprocessableEntity,
expectedStatus: http.StatusUnprocessableEntity,
expectedTitle: "Unprocessable Entity",
expectedType: "about:blank",
},
{
name: "Custom Type and Title",
err: ErrorEnvelope{
Type: "https://example.com/probs/out-of-credit",
Title: "You do not have enough credit.",
},
statusCode: http.StatusForbidden,
expectedStatus: http.StatusForbidden,
expectedTitle: "You do not have enough credit.",
expectedType: "https://example.com/probs/out-of-credit",
},
{
name: "Zero Status Code Defaults to 500",
err: ErrorEnvelope{Detail: "Something went wrong"},
statusCode: 0,
expectedStatus: http.StatusInternalServerError,
expectedTitle: "Internal Server Error",
expectedType: "about:blank",
},
{
name: "Negative Status Code Defaults to 500",
err: ErrorEnvelope{Detail: "Something went wrong"},
statusCode: -1,
expectedStatus: http.StatusInternalServerError,
expectedTitle: "Internal Server Error",
expectedType: "about:blank",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := httptest.NewRecorder()
HandleHttpError(rr, tt.err, tt.statusCode)

if rr.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
}

contentType := rr.Header().Get("Content-Type")
if contentType != "application/problem+json" {
t.Errorf("expected Content-Type application/problem+json, got %q", contentType)
}

var got ErrorEnvelope
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}

if got.Status != tt.expectedStatus {
t.Errorf("expected envelope status %d, got %d", tt.expectedStatus, got.Status)
}

if got.Title != tt.expectedTitle {
t.Errorf("expected envelope title %q, got %q", tt.expectedTitle, got.Title)
}

if got.Type != tt.expectedType {
t.Errorf("expected envelope type %q, got %q", tt.expectedType, got.Type)
}
})
}
}
50 changes: 28 additions & 22 deletions internal/platform/handler.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package platform

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand All @@ -27,18 +28,18 @@ type platformHandler struct {
func (h *platformHandler) CreatePlatform(w http.ResponseWriter, r *http.Request) {
var req createPlatformRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid request body"}, http.StatusBadRequest)
return
}

if req.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Name is required"}, http.StatusBadRequest)
return
}
contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := h.queries.CreatePlatform(contextWithTimeOut, req.ToParams()); err != nil {
http.Error(w, "Failed to create platform", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to create platform"}, http.StatusInternalServerError)
return
}

Expand All @@ -48,33 +49,33 @@ func (h *platformHandler) CreatePlatform(w http.ResponseWriter, r *http.Request)
func (h *platformHandler) UpdatePlatform(w http.ResponseWriter, r *http.Request) {
var req updatePlatformRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid request body"}, http.StatusBadRequest)
return
}
id, ok := internal.GetIntFromRequestPath("id", r)
if !ok {
http.Error(w, "Invalid platform ID", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid platform ID"}, http.StatusBadRequest)
return
}

if req.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Name is required"}, http.StatusBadRequest)
return
}

if req.ID != id {
http.Error(w, "Platform ID does not match path", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Platform ID does not match path"}, http.StatusBadRequest)
return
}
contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err := h.queries.UpdatePlatform(contextWithTimeOut, req.ToParams(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Platform not found", http.StatusNotFound)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Platform not found"}, http.StatusNotFound)
return
}
http.Error(w, "Failed to update platform", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to update platform"}, http.StatusInternalServerError)
return
}

Expand All @@ -84,20 +85,20 @@ func (h *platformHandler) UpdatePlatform(w http.ResponseWriter, r *http.Request)
func (h *platformHandler) DeletePlatform(w http.ResponseWriter, r *http.Request) {
id, ok := internal.GetIntFromRequestPath("id", r)
if !ok {
http.Error(w, "Invalid platform ID", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid platform ID"}, http.StatusBadRequest)
return
}
contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err := h.queries.DeletePlatform(contextWithTimeOut, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Platform not found", http.StatusNotFound)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Platform not found"}, http.StatusNotFound)
return
}
logger := internal.LoggerFromContext(r.Context())
logger.Error("Failed to delete platform", "error", err, "platform_id", id)
http.Error(w, "Internal server error", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Internal server error"}, http.StatusInternalServerError)
return
}

Expand All @@ -109,43 +110,48 @@ func (h *platformHandler) GetPlatforms(w http.ResponseWriter, r *http.Request) {
defer cancel()
platforms, err := h.queries.GetPlatforms(contextWithTimeOut)
if err != nil {
http.Error(w, "Failed to fetch platforms", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to fetch platforms"}, http.StatusInternalServerError)
return
}
if platforms == nil {
platforms = []Platform{}
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(platforms)
var buf bytes.Buffer
err = json.NewEncoder(&buf).Encode(platforms)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to encode response"}, http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
w.Write(buf.Bytes())
}

func (h *platformHandler) GetPlatform(w http.ResponseWriter, r *http.Request) {
id, ok := internal.GetIntFromRequestPath("id", r)
if !ok {
http.Error(w, "Invalid platform ID", http.StatusBadRequest)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid platform ID"}, http.StatusBadRequest)
return
}
contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
platform, err := h.queries.GetPlatform(contextWithTimeOut, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Platform not found", http.StatusNotFound)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Platform not found"}, http.StatusNotFound)
return
}
http.Error(w, "Failed to fetch platform", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to fetch platform"}, http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(platform)
var buf bytes.Buffer
err = json.NewEncoder(&buf).Encode(platform)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to encode response"}, http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
w.Write(buf.Bytes())
}
15 changes: 12 additions & 3 deletions internal/platform/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"products/internal"
"strconv"
"testing"

Expand Down Expand Up @@ -298,14 +299,14 @@ func TestDeletePlatform(t *testing.T) {
id: "1",
dbErr: errors.New("db error"),
expectedStatus: http.StatusInternalServerError,
expectedBody: "Internal server error\n",
expectedBody: "Internal server error",
},
{
name: "DB Error (pgx.ErrNoRows)",
id: "1",
dbErr: pgx.ErrNoRows,
expectedStatus: http.StatusNotFound,
expectedBody: "Platform not found\n",
expectedBody: "Platform not found",
},
{
name: "Invalid ID (Zero)",
Expand Down Expand Up @@ -351,7 +352,15 @@ func TestDeletePlatform(t *testing.T) {
}

if tt.expectedBody != "" {
if rr.Body.String() != tt.expectedBody {
if rr.Code >= 400 {
var envelope internal.ErrorEnvelope
if err := json.NewDecoder(rr.Body).Decode(&envelope); err != nil {
t.Fatalf("failed to decode error response: %v", err)
}
if envelope.Detail != tt.expectedBody {
t.Errorf("expected detail %q, got %q", tt.expectedBody, envelope.Detail)
}
} else if rr.Body.String() != tt.expectedBody {
t.Errorf("expected body %q, got %q", tt.expectedBody, rr.Body.String())
}
Comment thread
jlpdeveloper marked this conversation as resolved.
}
Expand Down
Loading
Loading