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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal 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
38
.gitignore
vendored
Normal 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
27
CHANGELOG.md
Normal 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
110
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
59
README.md
Normal 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
11
doc.go
Normal 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
|
||||
58
docs/adr/ADR-001-generic-typed-handlers.md
Normal file
58
docs/adr/ADR-001-generic-typed-handlers.md
Normal 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.
|
||||
60
docs/adr/ADR-002-xerrors-to-http-status-mapping.md
Normal file
60
docs/adr/ADR-002-xerrors-to-http-status-mapping.md
Normal 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
19
go.mod
Normal 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
30
go.sum
Normal 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
86
handle.go
Normal 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
268
httputil_test.go
Normal 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
83
response.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user