From 285293a75b238d913f3546170dd66a9debd5847c Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Thu, 19 Mar 2026 13:09:32 +0000 Subject: [PATCH] docs(httputil): correct tier from 3 to 2 httputil depends on xerrors (Tier 0) and valid (Tier 1), placing it at Tier 2. No infrastructure or lifecycle dependencies exist in this module. --- .devcontainer/devcontainer.json | 26 ++ .gitignore | 38 +++ CHANGELOG.md | 27 ++ CLAUDE.md | 110 +++++++ LICENSE | 21 ++ README.md | 59 ++++ doc.go | 11 + docs/adr/ADR-001-generic-typed-handlers.md | 58 ++++ .../ADR-002-xerrors-to-http-status-mapping.md | 60 ++++ go.mod | 19 ++ go.sum | 30 ++ handle.go | 86 ++++++ httputil_test.go | 268 ++++++++++++++++++ response.go | 83 ++++++ 14 files changed, 896 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 doc.go create mode 100644 docs/adr/ADR-001-generic-typed-handlers.md create mode 100644 docs/adr/ADR-002-xerrors-to-http-status-mapping.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handle.go create mode 100644 httputil_test.go create mode 100644 response.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "features": { + "ghcr.io/devcontainers-extra/features/claude-code:1": {} + }, + "forwardPorts": [], + "postCreateCommand": "go version", + "customizations": { + "vscode": { + "settings": { + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + "explorer.compactFolders": false, + "explorer.showEmptyFolders": true + }, + "extensions": [ + "golang.go", + "eamodio.golang-postfix-completion", + "quicktype.quicktype", + "usernamehw.errorlens" + ] + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6013765 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0] - 2026-03-18 + +### Added + +- `Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc` — decodes the JSON request body into `Req`, validates it with the injected `Validator`, calls `fn`, and encodes the result as JSON with HTTP 200; invalid JSON returns 400 +- `HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc` — no body decode or validation; calls `fn` and encodes the result as JSON with HTTP 200; intended for GET and DELETE endpoints +- `HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc` — decodes and validates the JSON request body, calls `fn`, returns HTTP 204 on success; intended for write endpoints with no response body +- `HandlerFunc` type — `func(w http.ResponseWriter, r *http.Request) error`; implements `http.Handler`; on non-nil return, routes the error through `Error(w, err)` for automatic status mapping +- `JSON(w http.ResponseWriter, status int, v any)` — encodes `v` as JSON and writes it with the given status code; sets `Content-Type: application/json` +- `NoContent(w http.ResponseWriter)` — writes HTTP 204 No Content +- `Error(w http.ResponseWriter, err error)` — maps `*xerrors.Err` codes to HTTP status codes and writes a `{"code": "...", "message": "..."}` JSON body; includes any extra fields from the error; falls back to 500 for unknown errors +- `xerrors.Code` to HTTP status mapping (12 codes): `ErrInvalidInput` → 400, `ErrUnauthorized` → 401, `ErrPermissionDenied` → 403, `ErrNotFound` → 404, `ErrAlreadyExists` → 409, `ErrGone` → 410, `ErrPreconditionFailed` → 412, `ErrRateLimited` → 429, `ErrInternal` → 500, `ErrNotImplemented` → 501, `ErrUnavailable` → 503, `ErrDeadlineExceeded` → 504 + +### Design Notes + +- Business functions wrapped by `Handle`, `HandleNoBody`, and `HandleEmpty` have no `http.ResponseWriter` or `*http.Request` in their signatures, making them callable directly in unit tests with `context.Background()` and a typed request value. +- `Error` is the single translation point from `xerrors.Code` to HTTP status; all handler adapters route through it, preventing fragmented status code contracts across the codebase. +- Validation is injected via `valid.Validator` and runs before the business function is called; an invalid request never reaches business logic. + +[0.9.0]: https://code.nochebuena.dev/go/httputil/releases/tag/v0.9.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3531ffa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# httputil + +Typed HTTP handler adapters and response helpers for `net/http`. + +## Purpose + +`httputil` removes HTTP boilerplate from business logic. Generic adapter functions +(`Handle`, `HandleNoBody`, `HandleEmpty`) wrap pure Go functions into +`http.HandlerFunc` values, handling JSON decode, struct validation, JSON encode, +and error-to-status mapping automatically. A single `Error` helper translates any +`*xerrors.Err` to the correct HTTP status and JSON body. + +## Tier & Dependencies + +**Tier:** 2 (transport layer; depends on Tier 0 `xerrors` and Tier 1 `valid`) +**Module:** `code.nochebuena.dev/go/httputil` +**Direct imports:** `code.nochebuena.dev/go/xerrors`, `code.nochebuena.dev/go/valid` + +`httputil` has no logger dependency. It does not import `logz`, `launcher`, or any +infrastructure module. + +## Key Design Decisions + +- **Generic typed handlers** (ADR-001): Three adapter functions cover the common + handler shapes. Business functions are pure Go — no `http.ResponseWriter` or + `*http.Request` in their signature. +- **xerrors → HTTP status mapping** (ADR-002): `Error(w, err)` is the single + translation point. Twelve `xerrors.Code` values map to specific HTTP statuses. + Unknown errors become 500. The JSON body always contains `"code"` and `"message"`. +- **`valid.Validator` is injected** into `Handle` and `HandleEmpty`. Validation + runs before the business function is called; an invalid request never reaches + business logic. +- **`HandlerFunc` for manual handlers**: A `func(w, r) error` type that implements + `http.Handler`. Use it when a handler needs direct HTTP access but still wants + automatic error mapping via `Error`. + +## Patterns + +**Typed handler with request and response:** + +```go +r.Post("/orders", httputil.Handle(validator, svc.CreateOrder)) +// svc.CreateOrder has signature: func(ctx context.Context, req CreateOrderRequest) (Order, error) +``` + +**Read-only handler (no request body):** + +```go +r.Get("/orders/{id}", httputil.HandleNoBody(func(ctx context.Context) (Order, error) { + id := chi.URLParam(r, "id") // r captured from outer scope, or use closure + return svc.GetOrder(ctx, id) +})) +``` + +**Write-only handler (no response body):** + +```go +r.Delete("/orders/{id}", httputil.HandleEmpty(validator, svc.CancelOrder)) +// Returns 204 on success +``` + +**Manual handler:** + +```go +r.Get("/export", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + data, err := svc.Export(r.Context()) + if err != nil { + return err // mapped by Error() + } + w.Header().Set("Content-Type", "text/csv") + _, err = w.Write(data) + return err +})) +``` + +**Writing responses directly:** + +```go +httputil.JSON(w, http.StatusCreated, result) +httputil.NoContent(w) +httputil.Error(w, xerrors.NotFound("order %s not found", id)) +``` + +## What to Avoid + +- Do not put HTTP-specific logic in business functions passed to `Handle`. Keep them + free of `http.ResponseWriter`, `*http.Request`, and `net/http` imports. +- Do not define your own error-to-status mapping alongside `Error`. All error + translation must go through `Error`; custom mappings fragment the status code + contract. +- Do not use `HandleNoBody` for endpoints that need to validate query parameters or + path variables — it skips validation. Read and validate those values inside the + function or use `HandlerFunc`. +- Do not add context fields to `*xerrors.Err` with keys `"code"` or `"message"` — + those names are reserved by the JSON body format and will shadow the error code + and message. +- Do not import `httputil` from business/service layers. It is a transport-layer + package; the dependency should flow inward only (handlers → services, not the + reverse). + +## Testing Notes + +- Business functions wrapped by `Handle` can be tested directly without HTTP. Call + the function with a plain `context.Background()` and a typed request value. +- To test the HTTP adapter itself, use `httptest.NewRecorder()` and call the + returned `http.HandlerFunc` directly. +- `httputil_test.go` covers JSON decode errors, validation errors, business errors + (various `xerrors.Code` values), and successful responses. +- No mock or stub is needed for `valid.Validator` in tests — use + `valid.New(valid.Options{})` directly; it has no external side effects. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..446042b --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# httputil + +Typed HTTP handler adapters and response helpers for stdlib `net/http`. + +## Install + +``` +go get code.nochebuena.dev/go/httputil +``` + +## Typed handlers + +```go +// JSON body + validation + typed response +r.Post("/orders", httputil.Handle(validator, svc.CreateOrder)) + +// No request body (GET / DELETE) +r.Get("/orders/{id}", httputil.HandleNoBody(svc.GetOrder)) + +// Request body, no response body (204) +r.Delete("/orders/{id}", httputil.HandleEmpty(validator, svc.DeleteOrder)) + +// Manual handler with centralised error mapping +r.Get("/raw", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + data, err := svc.Load(r.Context(), chi.URLParam(r, "id")) + if err != nil { + return err + } + return httputil.JSON(w, http.StatusOK, data) +})) +``` + +## Error mapping + +`xerrors.Code` → HTTP status: + +| Code | Status | +|---|---| +| `ErrInvalidInput` | 400 | +| `ErrUnauthorized` | 401 | +| `ErrPermissionDenied` | 403 | +| `ErrNotFound` | 404 | +| `ErrAlreadyExists` | 409 | +| `ErrInternal` | 500 | +| `ErrNotImplemented` | 501 | +| `ErrUnavailable` | 503 | +| `ErrDeadlineExceeded` | 504 | +| unknown | 500 | + +Error response body: +```json +{"code": "NOT_FOUND", "message": "record not found"} +``` +Fields from `xerrors.Err` are merged into the top-level response object. + +## Dependencies + +- `code.nochebuena.dev/go/xerrors` +- `code.nochebuena.dev/go/valid` diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..905785b --- /dev/null +++ b/doc.go @@ -0,0 +1,11 @@ +// Package httputil provides typed HTTP handler adapters and response helpers. +// +// The centrepiece is [Handle], a generics-based adapter that wraps a pure Go +// function into an [net/http.HandlerFunc], handling JSON decode, validation, +// and error mapping automatically: +// +// r.Get("/orders/{id}", httputil.Handle(validator, svc.GetOrder)) +// +// Error responses are mapped from [xerrors.Code] to HTTP status codes. +// All response helpers set Content-Type: application/json. +package httputil diff --git a/docs/adr/ADR-001-generic-typed-handlers.md b/docs/adr/ADR-001-generic-typed-handlers.md new file mode 100644 index 0000000..14078bc --- /dev/null +++ b/docs/adr/ADR-001-generic-typed-handlers.md @@ -0,0 +1,58 @@ +# ADR-001: Generic Typed Handler Adapters + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +Standard `net/http` handlers have the signature `func(http.ResponseWriter, *http.Request)`. +Business logic that lives inside these handlers must manually decode JSON, validate +input, encode responses, and convert errors to status codes — the same boilerplate +repeated for every endpoint. This tightly couples business functions to HTTP and +makes them hard to test in isolation. + +A common mitigation is a generic "controller" framework, but Go generics allow a +lighter approach: thin adapter functions that perform the boilerplate at the HTTP +boundary while leaving business logic free of HTTP types. + +## Decision + +Three generic adapter functions are provided, covering the three common handler +shapes: + +| Function | Request body | Response body | Success status | +|---|---|---|---| +| `Handle[Req, Res]` | JSON-decoded `Req` | JSON `Res` | 200 | +| `HandleNoBody[Res]` | none | JSON `Res` | 200 | +| `HandleEmpty[Req]` | JSON-decoded `Req` | none | 204 | + +Each adapter accepts a plain Go function — e.g. +`func(ctx context.Context, req Req) (Res, error)` — and returns an +`http.HandlerFunc`. The business function receives a `context.Context` (from the +request) and a typed value; it returns a typed value and an error. It has no +knowledge of `http.ResponseWriter` or `*http.Request`. + +`Handle` and `HandleEmpty` also accept a `valid.Validator` parameter. After JSON +decoding, the adapter calls `v.Struct(req)` before invoking the business function. +Validation errors are returned to the caller via `Error(w, err)` without reaching +business logic. + +A `HandlerFunc` type (`func(http.ResponseWriter, *http.Request) error`) is also +provided for handlers that need direct HTTP access but still want automatic error +mapping. + +## Consequences + +- Business functions are pure Go — they take typed inputs and return typed outputs. + They can be called directly in unit tests without constructing an HTTP request. +- Type parameters are inferred at the call site: `Handle(v, svc.CreateOrder)` — no + explicit type arguments needed if the function signature is concrete. +- Validation is enforced before business logic runs. There is no path through + `Handle` or `HandleEmpty` where an invalid `Req` reaches the function. +- `HandleNoBody` skips validation entirely because there is no request body to + validate. Path/query parameters are the caller's responsibility. +- All three adapters share the same error mapping via `Error(w, err)`, so HTTP + status codes are determined consistently by the xerrors code on the returned error. +- The adapters impose a fixed response shape (JSON + fixed status). Handlers that + need streaming, multipart, or redirect responses should use `HandlerFunc` or plain + `http.HandlerFunc` directly. diff --git a/docs/adr/ADR-002-xerrors-to-http-status-mapping.md b/docs/adr/ADR-002-xerrors-to-http-status-mapping.md new file mode 100644 index 0000000..791e46b --- /dev/null +++ b/docs/adr/ADR-002-xerrors-to-http-status-mapping.md @@ -0,0 +1,60 @@ +# ADR-002: xerrors.Code to HTTP Status Mapping + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +HTTP handlers must translate application errors into appropriate HTTP status codes. +Without a shared mapping, each handler or controller does its own ad-hoc conversion, +leading to inconsistent status codes across endpoints (e.g. one handler returning 500 +for a not-found, another returning 404). + +`xerrors` defines stable `Code` constants aligned with gRPC canonical status names. +The transport layer is responsible for translating those codes to HTTP — this was +an explicit decision in `xerrors` ADR-001. + +## Decision + +`Error(w http.ResponseWriter, err error)` is the single entry point for writing +error responses. It uses `errors.As` to unwrap `*xerrors.Err` and reads the `Code` +field. `errorCodeToStatus` maps each code to an HTTP status: + +| xerrors.Code | HTTP Status | +|---|---| +| `ErrInvalidInput` | 400 Bad Request | +| `ErrUnauthorized` | 401 Unauthorized | +| `ErrPermissionDenied` | 403 Forbidden | +| `ErrNotFound` | 404 Not Found | +| `ErrAlreadyExists` | 409 Conflict | +| `ErrGone` | 410 Gone | +| `ErrPreconditionFailed` | 412 Precondition Failed | +| `ErrRateLimited` | 429 Too Many Requests | +| `ErrInternal` | 500 Internal Server Error | +| `ErrNotImplemented` | 501 Not Implemented | +| `ErrUnavailable` | 503 Service Unavailable | +| `ErrDeadlineExceeded` | 504 Gateway Timeout | +| (unknown / nil) | 500 Internal Server Error | + +The JSON response body always has the shape `{"code": "...", "message": "..."}`. +If `*xerrors.Err` carries context fields (from `WithContext`), those fields are +merged into the top-level response body alongside `code` and `message`. + +Errors that do not unwrap to `*xerrors.Err` (plain `errors.New`, third-party errors) +are mapped to 500 with a generic `"INTERNAL"` code and `"internal server error"` +message. The original error is not exposed to the caller. + +## Consequences + +- All endpoints in a service share the same error shape and status mapping. A + `NOT_FOUND` error always becomes 404, regardless of which handler returns it. +- Business logic selects the appropriate `xerrors.Code`; the transport layer picks + the status code. Neither layer needs to know about the other's mapping. +- The `code` field in the JSON response is the stable `xerrors.Code` string value + (e.g. `"NOT_FOUND"`), not an HTTP status integer. Clients can switch on the code + string to distinguish cases within the same status class. +- Context fields from `xerrors.Err.WithContext` are promoted to top-level JSON + fields. This means field names must not collide with `"code"` or `"message"`. + This is a caller responsibility, not enforced by the package. +- Unknown or nil errors produce a 500 with no internal detail leaked — defensive + by default. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f35356a --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module code.nochebuena.dev/go/httputil + +go 1.25 + +require ( + code.nochebuena.dev/go/valid v0.9.0 + code.nochebuena.dev/go/xerrors v0.9.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..acdeb32 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +code.nochebuena.dev/go/valid v0.9.0 h1:o8/tICIoed2+uwBp+TxXa3FE6KmyirU266O4jEUgFCI= +code.nochebuena.dev/go/valid v0.9.0/go.mod h1:SKpLcqpEsLMaEk7K3Y0kFF7Y3W5PHAQF6+U6wleFAhg= +code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE= +code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handle.go b/handle.go new file mode 100644 index 0000000..7446900 --- /dev/null +++ b/handle.go @@ -0,0 +1,86 @@ +package httputil + +import ( + "context" + "encoding/json" + "net/http" + + "code.nochebuena.dev/go/valid" + "code.nochebuena.dev/go/xerrors" +) + +// HandlerFunc is an http.Handler that can return an error. +// On non-nil error the error is mapped to the appropriate HTTP response via Error. +// Useful for manual handlers that don't need the typed adapter. +type HandlerFunc func(w http.ResponseWriter, r *http.Request) error + +func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := h(w, r); err != nil { + Error(w, err) + } +} + +// Handle adapts a typed business function to http.HandlerFunc. +// - Decodes JSON request body into Req. +// - Validates Req using the provided Validator. +// - Calls fn with the request context and decoded Req. +// - Encodes Res as JSON with HTTP 200. +// - Maps any returned error to the appropriate HTTP status via xerrors code. +func Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req Req + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, badRequest("invalid JSON: "+err.Error())) + return + } + if err := v.Struct(req); err != nil { + Error(w, err) + return + } + res, err := fn(r.Context(), req) + if err != nil { + Error(w, err) + return + } + JSON(w, http.StatusOK, res) + } +} + +// HandleNoBody adapts a typed function with no request body (GET, DELETE). +// Calls fn with request context; encodes result as JSON with HTTP 200. +func HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res, err := fn(r.Context()) + if err != nil { + Error(w, err) + return + } + JSON(w, http.StatusOK, res) + } +} + +// HandleEmpty adapts a typed function with a body but no response body (returns 204). +// Decodes JSON body into Req, validates, calls fn. Returns 204 on success. +func HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req Req + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, badRequest("invalid JSON: "+err.Error())) + return + } + if err := v.Struct(req); err != nil { + Error(w, err) + return + } + if err := fn(r.Context(), req); err != nil { + Error(w, err) + return + } + NoContent(w) + } +} + +// badRequest wraps a message in an ErrInvalidInput error. +func badRequest(msg string) error { + return xerrors.New(xerrors.ErrInvalidInput, msg) +} diff --git a/httputil_test.go b/httputil_test.go new file mode 100644 index 0000000..6c980b5 --- /dev/null +++ b/httputil_test.go @@ -0,0 +1,268 @@ +package httputil + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.nochebuena.dev/go/xerrors" +) + +// --- mock validator --- + +type mockValidator struct{ err error } + +func (m *mockValidator) Struct(v any) error { return m.err } + +var okValidator = &mockValidator{} + +// --- helpers --- + +func body(s string) io.Reader { return strings.NewReader(s) } + +func decodeMap(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.NewDecoder(rec.Body).Decode(&m); err != nil { + t.Fatalf("decode: %v", err) + } + return m +} + +// --- Handle --- + +type req struct{ Value string } +type res struct{ Echo string } + +func TestHandle_Success(t *testing.T) { + fn := func(ctx context.Context, r req) (res, error) { return res{Echo: r.Value}, nil } + h := Handle(okValidator, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", body(`{"Value":"hello"}`))) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } + m := decodeMap(t, rec) + if m["Echo"] != "hello" { + t.Errorf("want Echo=hello, got %v", m["Echo"]) + } +} + +func TestHandle_InvalidJSON(t *testing.T) { + fn := func(ctx context.Context, r req) (res, error) { return res{}, nil } + h := Handle(okValidator, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", body(`not json`))) + if rec.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d", rec.Code) + } +} + +func TestHandle_ValidationFails(t *testing.T) { + v := &mockValidator{err: xerrors.InvalidInput("field required")} + fn := func(ctx context.Context, r req) (res, error) { return res{}, nil } + h := Handle(v, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", body(`{}`))) + if rec.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d", rec.Code) + } +} + +func TestHandle_FnError(t *testing.T) { + fn := func(ctx context.Context, r req) (res, error) { + return res{}, xerrors.New(xerrors.ErrNotFound, "not found") + } + h := Handle(okValidator, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", body(`{}`))) + if rec.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", rec.Code) + } +} + +// --- HandleNoBody --- + +func TestHandleNoBody_Success(t *testing.T) { + fn := func(ctx context.Context) (res, error) { return res{Echo: "ok"}, nil } + h := HandleNoBody(fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } +} + +func TestHandleNoBody_FnError(t *testing.T) { + fn := func(ctx context.Context) (res, error) { + return res{}, xerrors.New(xerrors.ErrUnavailable, "service down") + } + h := HandleNoBody(fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("want 503, got %d", rec.Code) + } +} + +// --- HandleEmpty --- + +func TestHandleEmpty_Success(t *testing.T) { + fn := func(ctx context.Context, r req) error { return nil } + h := HandleEmpty(okValidator, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/", body(`{}`))) + if rec.Code != http.StatusNoContent { + t.Errorf("want 204, got %d", rec.Code) + } +} + +func TestHandleEmpty_ValidationFails(t *testing.T) { + v := &mockValidator{err: xerrors.InvalidInput("required")} + fn := func(ctx context.Context, r req) error { return nil } + h := HandleEmpty(v, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/", body(`{}`))) + if rec.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d", rec.Code) + } +} + +func TestHandleEmpty_FnError(t *testing.T) { + fn := func(ctx context.Context, r req) error { + return xerrors.New(xerrors.ErrPermissionDenied, "forbidden") + } + h := HandleEmpty(okValidator, fn) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/", body(`{}`))) + if rec.Code != http.StatusForbidden { + t.Errorf("want 403, got %d", rec.Code) + } +} + +// --- HandlerFunc --- + +func TestHandlerFunc_NoError(t *testing.T) { + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.WriteHeader(http.StatusOK) + return nil + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } +} + +func TestHandlerFunc_WithError(t *testing.T) { + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return xerrors.New(xerrors.ErrUnauthorized, "unauthorized") + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +// --- JSON / Error / NoContent --- + +func TestJSON_SetsContentType(t *testing.T) { + rec := httptest.NewRecorder() + JSON(rec, http.StatusOK, map[string]string{"k": "v"}) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type: want application/json, got %s", ct) + } +} + +func TestJSON_EncodesBody(t *testing.T) { + rec := httptest.NewRecorder() + JSON(rec, http.StatusOK, map[string]string{"hello": "world"}) + m := decodeMap(t, rec) + if m["hello"] != "world" { + t.Errorf("want hello=world, got %v", m) + } +} + +func TestNoContent_Status(t *testing.T) { + rec := httptest.NewRecorder() + NoContent(rec) + if rec.Code != http.StatusNoContent { + t.Errorf("want 204, got %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("want empty body, got %q", rec.Body.String()) + } +} + +func TestError_XerrorsMapping(t *testing.T) { + cases := []struct { + code xerrors.Code + status int + }{ + {xerrors.ErrInvalidInput, 400}, + {xerrors.ErrUnauthorized, 401}, + {xerrors.ErrPermissionDenied, 403}, + {xerrors.ErrNotFound, 404}, + {xerrors.ErrAlreadyExists, 409}, + {xerrors.ErrInternal, 500}, + {xerrors.ErrNotImplemented, 501}, + {xerrors.ErrUnavailable, 503}, + {xerrors.ErrDeadlineExceeded, 504}, + } + for _, tc := range cases { + rec := httptest.NewRecorder() + Error(rec, xerrors.New(tc.code, "msg")) + if rec.Code != tc.status { + t.Errorf("code %s: want %d, got %d", tc.code, tc.status, rec.Code) + } + } +} + +func TestError_UnknownError(t *testing.T) { + rec := httptest.NewRecorder() + Error(rec, errors.New("oops")) + if rec.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d", rec.Code) + } +} + +func TestError_NilError(t *testing.T) { + rec := httptest.NewRecorder() + Error(rec, nil) + if rec.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d", rec.Code) + } +} + +func TestErrorCodeToStatus_AllCodes(t *testing.T) { + cases := []struct { + code xerrors.Code + status int + }{ + {xerrors.ErrInvalidInput, 400}, + {xerrors.ErrUnauthorized, 401}, + {xerrors.ErrPermissionDenied, 403}, + {xerrors.ErrNotFound, 404}, + {xerrors.ErrAlreadyExists, 409}, + {xerrors.ErrGone, 410}, + {xerrors.ErrPreconditionFailed, 412}, + {xerrors.ErrRateLimited, 429}, + {xerrors.ErrInternal, 500}, + {xerrors.ErrNotImplemented, 501}, + {xerrors.ErrUnavailable, 503}, + {xerrors.ErrDeadlineExceeded, 504}, + {"UNKNOWN_CODE", 500}, + } + for _, tc := range cases { + got := errorCodeToStatus(tc.code) + if got != tc.status { + t.Errorf("errorCodeToStatus(%s): want %d, got %d", tc.code, tc.status, got) + } + } +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..789aedc --- /dev/null +++ b/response.go @@ -0,0 +1,83 @@ +package httputil + +import ( + "encoding/json" + "errors" + "net/http" + + "code.nochebuena.dev/go/xerrors" +) + +// JSON encodes v as JSON and writes it with the given status code. +// Sets Content-Type: application/json. +func JSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// NoContent writes a 204 No Content response. +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +// Error maps err to the appropriate HTTP status code and writes a JSON error body. +// Understands *xerrors.Err — extracts Code and Message; fields are included if present. +// Falls back to 500 for unknown errors. +func Error(w http.ResponseWriter, err error) { + if err == nil { + JSON(w, http.StatusInternalServerError, errorBody("INTERNAL", "internal server error", nil)) + return + } + var xe *xerrors.Err + if errors.As(err, &xe) { + status := errorCodeToStatus(xe.Code()) + body := errorBody(string(xe.Code()), xe.Message(), xe.Fields()) + JSON(w, status, body) + return + } + JSON(w, http.StatusInternalServerError, errorBody("INTERNAL", "internal server error", nil)) +} + +func errorBody(code, message string, fields map[string]any) map[string]any { + m := map[string]any{ + "code": code, + "message": message, + } + for k, v := range fields { + m[k] = v + } + return m +} + +// errorCodeToStatus maps a xerrors.Code to an HTTP status code. +func errorCodeToStatus(code xerrors.Code) int { + switch code { + case xerrors.ErrInvalidInput: + return http.StatusBadRequest + case xerrors.ErrUnauthorized: + return http.StatusUnauthorized + case xerrors.ErrPermissionDenied: + return http.StatusForbidden + case xerrors.ErrNotFound: + return http.StatusNotFound + case xerrors.ErrAlreadyExists: + return http.StatusConflict + case xerrors.ErrGone: + return http.StatusGone + case xerrors.ErrPreconditionFailed: + return http.StatusPreconditionFailed + case xerrors.ErrRateLimited: + return http.StatusTooManyRequests + case xerrors.ErrInternal: + return http.StatusInternalServerError + case xerrors.ErrNotImplemented: + return http.StatusNotImplemented + case xerrors.ErrUnavailable: + return http.StatusServiceUnavailable + case xerrors.ErrDeadlineExceeded: + return http.StatusGatewayTimeout + default: + return http.StatusInternalServerError + } +}