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:
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.
|
||||
Reference in New Issue
Block a user