Feature 01 task 1: Go module, fail-fast config and /health (#2)

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #2.
This commit is contained in:
2026-10-02 22:20:34 -04:00
committed by approver_agent
parent d2dc58a2ec
commit e3d8c4d73d
9 changed files with 831 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
// Package server builds the MealQuest HTTP router.
package server
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
)
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health.
func NewRouter(logger *slog.Logger, version string) http.Handler {
r := chi.NewRouter()
r.Get("/health", health(logger, version))
r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed"))
r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found"))
return r
}
type errorBody struct {
Error apiError `json:"error"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
// apiErrorHandler responds with status and a fixed, non-retryable API error.
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
body, err := json.Marshal(errorBody{Error: apiError{
Code: code,
Message: message,
}})
if err != nil {
panic(err) // fixed string and bool fields cannot fail to marshal
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
}
}
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
func health(logger *slog.Logger, version string) http.HandlerFunc {
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
if err != nil {
panic(err) // two string fields cannot fail to marshal
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write health response", "error", err)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package server
import (
"encoding/json"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
want := map[string]string{"status": "ok", "version": "1.2.3"}
if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] {
t.Errorf("body = %v, want %v", got, want)
}
}
func TestHealthRejectsOtherMethods(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
rec := httptest.NewRecorder()
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got struct {
Error map[string]any `json:"error"`
}
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
want := map[string]any{
"code": "method_not_allowed",
"message": "method not allowed",
"retryable": false,
}
if !maps.Equal(got.Error, want) {
t.Errorf("error body = %v, want %v", got.Error, want)
}
}
func TestUnknownPathNotFound(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
rec := httptest.NewRecorder()
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
if got := rec.Body.String(); got != want {
t.Errorf("body = %s, want %s", got, want)
}
}