Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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