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.
This commit is contained in:
2026-03-19 13:09:32 +00:00
commit 285293a75b
14 changed files with 896 additions and 0 deletions

View File

@@ -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"
}

38
.gitignore vendored Normal file
View File

@@ -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

27
CHANGELOG.md Normal file
View File

@@ -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

110
CLAUDE.md Normal file
View File

@@ -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.

21
LICENSE Normal file
View File

@@ -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.

59
README.md Normal file
View File

@@ -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`

11
doc.go Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

19
go.mod Normal file
View File

@@ -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
)

30
go.sum Normal file
View File

@@ -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=

86
handle.go Normal file
View File

@@ -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)
}

268
httputil_test.go Normal file
View File

@@ -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)
}
}
}

83
response.go Normal file
View File

@@ -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
}
}