From da0e64c02ba0ddd73269ea87eabbe47a1077b4d4 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Wed, 24 Jun 2026 23:42:57 +0100 Subject: [PATCH] feat(mw): add content-type and response middleware Add Content-Type enforcement middleware and generic JSON response helpers to standardize API responses. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/mw/contenttype.go | 13 +++++ backend/mw/contenttype_test.go | 96 ++++++++++++++++++++++++++++++++++ backend/mw/response.go | 21 ++++++++ 3 files changed, 130 insertions(+) create mode 100644 backend/mw/contenttype.go create mode 100644 backend/mw/contenttype_test.go create mode 100644 backend/mw/response.go diff --git a/backend/mw/contenttype.go b/backend/mw/contenttype.go new file mode 100644 index 0000000..23392f9 --- /dev/null +++ b/backend/mw/contenttype.go @@ -0,0 +1,13 @@ +package mw + +import "net/http" + +// JsonContentType sets Content-Type: application/json on all API responses. +// Individual handlers that need to override (e.g. binary/image responses) +// should set their own Content-Type header after this middleware runs. +func JsonContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} diff --git a/backend/mw/contenttype_test.go b/backend/mw/contenttype_test.go new file mode 100644 index 0000000..94fe45d --- /dev/null +++ b/backend/mw/contenttype_test.go @@ -0,0 +1,96 @@ +//go:build test +// +build test + +package mw + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestJsonContentType_Middleware verifies that the JsonContentType middleware +// sets Content-Type: application/json on all responses. +func TestJsonContentType_Middleware(t *testing.T) { + t.Parallel() + + // Create a simple handler that writes "ok" + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + // Wrap with JsonContentType middleware + handler := JsonContentType(next) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify Content-Type is set + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("expected Content-Type 'application/json', got %q", ct) + } + + // Verify body is preserved + if w.Body.String() != "ok" { + t.Errorf("expected body 'ok', got %q", w.Body.String()) + } +} + +// TestJsonContentType_HandlerCanOverride verifies that the downstream handler +// can override the Content-Type if needed. +func TestJsonContentType_HandlerCanOverride(t *testing.T) { + t.Parallel() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte("override")) + }) + + handler := JsonContentType(next) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // The handler's Content-Type should win (it was set after middleware) + ct := w.Header().Get("Content-Type") + if ct != "text/plain; charset=utf-8" { + t.Errorf("expected Content-Type 'text/plain; charset=utf-8', got %q", ct) + } + + if w.Body.String() != "override" { + t.Errorf("expected body 'override', got %q", w.Body.String()) + } +} + +// TestJsonContentType_StatusOK verifies that the middleware passes through the +// handler's status code correctly. +func TestJsonContentType_StatusOK(t *testing.T) { + t.Parallel() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id": "abc"}`)) + }) + + handler := JsonContentType(next) + + req := httptest.NewRequest("POST", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d", w.Code) + } + if w.Header().Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type 'application/json', got %q", w.Header().Get("Content-Type")) + } + if w.Body.String() != `{"id": "abc"}` { + t.Errorf("expected body `{\"id\": \"abc\"}`, got %q", w.Body.String()) + } +} diff --git a/backend/mw/response.go b/backend/mw/response.go new file mode 100644 index 0000000..b610c06 --- /dev/null +++ b/backend/mw/response.go @@ -0,0 +1,21 @@ +package mw + +import ( + "encoding/json" + "net/http" +) + +// RespondJSON writes a JSON response with the given status code. +// The Content-Type is always set to application/json, overriding any +// middleware-set value, to ensure consistent JSON error responses. +func RespondJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// RespondError writes a JSON error response with the given status code and message. +// Use instead of http.Error() to ensure error responses are application/json. +func RespondError(w http.ResponseWriter, status int, message string) { + RespondJSON(w, status, map[string]string{"error": message}) +}