Guide: HTTP Server¶
This guide walks through the HTTP server examples in examples/. For the full API reference, see the feature pages.
Features used: - REST API — Routes, Params & OpenAPI — NewRoute, params, BuildPath, OpenAPI spec - Security & Auth — SecurityFunc, bearer JWT, global/per-route - SSE & Streaming — NewSSERoute, templ SSR, chunked streaming - Formats & Serialization — multi-format request/response
examples/rest-api¶
The most comprehensive HTTP server (and client) demo — structured as a real
small project (routes/, handlers/, chiserver/, nethttpserver/,
client/ packages) demonstrating the declare → build server → build
client workflow, with the SAME routes/handlers packages assembled onto
BOTH the net/http and chi adapters unchanged — proving routes and
business logic are adapter-agnostic. Shows the three-layer codec
pipeline:
- Layer 1: shared field codecs (
emailFieldCodec,nameFieldCodec) propagate constraints to all three boundary codecs (request, database, response) - Layer 2: pure domain functions (
handlers.BuildUserRecord,handlers.BuildUserResponse) with zero IO — independently unit-testable - Layer 3: infrastructure (
handlers.UserStoreuses codec for all DB IO;b.Attach(nethttp.NewServerTransport(...))/b.Attach(chiadapter.NewServerTransport(...))+Server.Serve(ctx)— the calls that wire every declared route onto the mux/router and start serving — are the only HTTP lines)
Key patterns:
- rest.RequestFormats(format.JSON(...), format.YAML(...)) declared inline in NewRoute's opts — JSON + YAML bodies
- rest.NewPathParam — declares the path param's spec/validation AND a merge field in one call; the handler receives an already-merged, validated request (req.ID) instead of manually calling r.PathValue("id")/chi.URLParam(r, "id") — see REST API — Path/query/header params with automatic merge
- ResponseHeaderParam + ResponseCookieParam — server-side contract validation on outgoing headers/cookies
- chiadapter.SetCookie/nethttp cookie helpers with .WithCodec() — symmetric read/write validation using the same codec
- Bearer JWT security: middleware.SecurityScheme("bearerAuth", route.BearerScheme("JWT"), scopes, &bearerCodec) declares the scheme + per-route scope requirement in one value, attached via .Use(declMw); per-route scopes are separate middleware.SecurityScheme values ([]string{"profile"} vs []string{"admin"}); enforced server-side via a handlers.ScopesImpl-built middleware.ServerImplementation attached via .HandleMW(&declMw, impl.Fn); supplied client-side via a per-identity credFn (aliceCredFn/adminCredFn) attached via .ClientMW(&declMw, credFn)
- A general-purpose timing middleware (routes.TimingServerMW/routes.TimingClientMW) shown on BOTH server (HandleMW(nil, ...)) and client (ClientMW(nil, ...)) roles — the general-purpose ClientMW hook (via reflect.MakeFunc)
- CountingObserver — in-memory metrics (swap for Prometheus in production), wired via nethttp.Observability/chiadapter.Observability
- handlers.WithDomainLogging decorator — separates logging concern from handler body
- GET /openapi.yaml on both servers via rest.Server.ServeSpec — go-codex's native self-serving spec endpoint, see Self-Serving Spec Endpoint (ServeSpec)
examples/adapters-sse¶
SSE with path param codec validation, invalid event rejection (nothing written on codec failure), stats observer, and OpenAPI spec showing Content-Type: text/event-stream.
examples/adapters-templ + examples/adapters-streaming-sse-templ¶
Same route serves HTML (Accept: text/html) and JSON (Accept: application/json) via content negotiation. The streaming example adds chunked HTML pages and SSE HTML fragments (HTMX sse-swap pattern).
→ examples/adapters-templ · examples/adapters-streaming-sse-templ
Binary payloads (PNG, JPEG, PDF…)¶
Binary request and response bodies — image uploads, document downloads, file transfers — work with the nethttp and chi adapters without any adapter changes. The adapter reads and writes raw []byte bodies, and format.Binary validates the content via magic-byte and size constraints.
Incoming binary request body¶
Use handle.WithRequestFormats to register binary as an accepted Content-Type. The adapter reads the body, negotiates by the request's Content-Type header, then calls format.Binary.Unmarshal which validates and returns the raw bytes:
pngCodec := codex.Bytes().
Refine(validate.MaxBytes(5 * 1024 * 1024)).
Refine(validate.PNG)
uploadHandle, _ := rest.NewRoute[[]byte, ImageMeta]("PUT", "/images/{id}",
pngCodec, imageMetaCodec, ...,
).RegisterHandle(b)
// Accept binary PNG bodies; returns 415 if Content-Type doesn't match
uploadHandle.WithRequestFormats(format.Binary(pngCodec).WithContentType("image/png"))
The adapter returns HTTP 415 Unsupported Media Type if the client sends a Content-Type not in the registered set.
Outgoing binary response body¶
Use handle.WithFormats to register binary as a producible format. The adapter negotiates by the Accept header, calls format.Binary.Marshal (validates constraints), and sets the response Content-Type:
downloadHandle, _ := rest.NewRoute[DownloadReq, []byte]("POST", "/images/{id}/download",
downloadCodec, pngCodec, ...,
).RegisterHandle(b)
// Serve binary PNG responses; Accept: */* or Accept: image/png both match
downloadHandle.WithFormats(format.Binary(pngCodec).WithContentType("image/png"))
Accept: */* (or no Accept header — browsers, curl) matches the first registered format.
Same thing, declared through ports.RESTPattern¶
rest.RequestFormats(...) and rest.Formats(...) are RouteOpts — they slot
directly into RESTPattern.Opts, giving the same one-step declaration when
the route is a ports-wired boundary instead of a hand-built route:
var Images = codex.Must(ports.NewIOPort[[]byte, ImageMeta]("images", pngCodec, imageMetaCodec, ports.PortOptions{
RESTBuilder: restBuilder,
}))
imageHandle, err := Images.PluginRESTPattern(ports.RESTPattern{
Method: "PUT", Path: "/images/{id}",
Opts: []rest.RouteOpt{
rest.RequestFormats(format.Binary(pngCodec).WithContentType("image/png")),
},
})
if err != nil {
panic(err)
}
A type mismatch (formats declared for the wrong type) returns
rest.FormatOptError when the pattern is plugged in.
Pipeline handlers: mapping stream errors to HTTP status¶
For nethttp.PipelineHandler / chi.PipelineHandler, declare per-route error
status mapping once on the route:
handle, _ := rest.NewRoute[Req, Resp]("POST", "/jobs", reqCodec, respCodec,
rest.ErrorStatus[domain.ConflictError](http.StatusConflict),
).RegisterHandle(b)
- First matching rule wins.
- Unmatched pipeline errors stay
500. - No pipeline value defaults to
503(PipelineNoResponseError), overridable withErrorStatus[...](status).
Full typed-error-response declaration is already shipped as rest.ErrorPattern:
- rest.ErrorPattern[E, B](status, codec, mapFn...) declares a codec-backed
typed error response for a matched error type (status + typed error body +
optional mapper), usable by plain and pipeline handlers alike.
- rest.ErrorStatus[E](status) is the status-only shorthand — use it when you
don't need a typed response body.
- Matching precedence is first declaration that matches via errors.As.
- By default a matched ErrorPattern responds directly (rest.ErrorRespond);
use .WithAction(rest.ErrorHandle)/.WithAction(rest.ErrorLog) to fall
through to Options.ErrorHandler instead (still using the pattern's
declared status).
- Typed error response headers/cookies follow the same codec+merge-field
pattern as happy-path response metadata, but scoped to the matched error
status.
Ergonomics: same domain error, no-pipeline vs pipeline¶
Use one domain error type in both paths; only the mapping locus changes.
type domainConflictError struct {
Resource string
Value string
}
func (e domainConflictError) Error() string {
return fmt.Sprintf("%s %q already exists", e.Resource, e.Value)
}
baseErrorHandler := func(w http.ResponseWriter, _ *http.Request, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
No-pipeline (Handler): map status in custom ErrorHandler.
noPipelineErrHandler := func(w http.ResponseWriter, r *http.Request, status int, err error) {
var conflict domainConflictError
if errors.As(err, &conflict) {
status = http.StatusConflict
}
baseErrorHandler(w, r, status, err)
}
noPipelineRoute = noPipelineRoute.WithHandler(noPipelineFn).
WithOptions(nethttp.Options{ErrorHandler: noPipelineErrHandler})
noPipelineRoute.Register(b)
if err := b.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: addr})); err != nil {
log.Fatal(err)
}
_ = b.Serve(ctx) // blocks, owns its own http.Server
Pipeline (PipelineHandler): map status in route declaration; keep custom
ErrorHandler for response-body shaping.
pipelineHandle, _ := rest.NewRoute[Req, Resp]("POST", "/erg/pipeline", reqCodec, respCodec,
rest.ErrorStatus[domainConflictError](http.StatusConflict),
).RegisterHandle(b)
nethttp.RegisterPipeline(mux, pipelineHandle, pipelineFn,
nethttp.Options{ErrorHandler: baseErrorHandler})
Current possibilities matrix (today)¶
| Capability | No-pipeline (plain business handler) | Pipeline (PipelineHandler) |
|---|---|---|
| One-struct-one-call request/response | Yes (Req decode + Resp encode via route codecs) |
Yes (same route codec path; pipeline fn gets typed Req, returns typed Resp stream) |
| Custom error body/envelope | Yes (Options.ErrorHandler) |
Yes (Options.ErrorHandler) |
| Where status code mapping lives | ErrorHandler (or default adapter status) |
Route declaration via ErrorStatus[...] for stream errors; then ErrorHandler writes body |
| Typed route-level error mapping | No (handled in ErrorHandler) |
Yes (rest.ErrorStatus[E](status)) |
| No emitted pipeline value handling | N/A | PipelineNoResponseError default 503 (overridable by route mapping) |
Redirect success path (3xx + Location) |
RespStatus + response header merge / WithResponseHeaders |
Same (pipeline does not change redirect mechanics) |
The code sample above sketches both styles side by side with the same domain error type so you can compare them directly; no standalone runnable example demonstrates this specific comparison today.
MaxBodyBytes and validate.MaxBytes¶
The adapter applies opts.MaxBodyBytes via http.MaxBytesReader before the codec runs:
| What fires | When | HTTP response |
|---|---|---|
opts.MaxBodyBytes exceeded |
Body read fails | HTTP 413 BodyTooLargeError |
Codec validate.MaxBytes exceeded |
Body already read, constraint fails | HTTP 400 ConstraintError |
Recommendation: set opts.MaxBodyBytes ≥ the codec's validate.MaxBytes limit so the codec's typed error reaches the client rather than a generic HTTP 413.
const maxPNG = 5 * 1024 * 1024 // 5 MiB
pngCodec := codex.Bytes().
Refine(validate.MaxBytes(maxPNG)).
Refine(validate.PNG)
// MaxBodyBytes matches the codec limit — codec error fires, not generic 413
uploadRoute = uploadRoute.WithHandler(handler).WithOptions(nethttp.Options{
MaxBodyBytes: maxPNG,
})
uploadRoute.Register(b)
if err := b.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: addr})); err != nil {
log.Fatal(err)
}
_ = b.Serve(ctx) // blocks, owns its own http.Server
See examples/png-upload for a full upload + download route pair with path params, cookie validation, and OpenAPI spec generation.
examples/rest-builder + examples/rest-schema-docs¶
Standalone REST builder and OpenAPI spec generation demos without an HTTP server —
examples/rest-builder demonstrates the transport-agnostic api/rest builder core
(routes, spec, manual Decode/Encode, no HTTP framework); examples/rest-schema-docs
demonstrates schema-only OpenAPI generation straight from a Codec, no routes at all.
See the ## examples/rest-api section above for the full adapter-based server demo.
→ examples/rest-builder · examples/rest-schema-docs
Server.Attach + Server.Serve — the ONLY server startup workflow¶
builder.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: addr})) (or builder.Attach(chi.NewServerTransport(chi.ServerTransportOptions{Router: r, Addr: addr}))) binds a mux/router
+ address to builder as its rest.ServerTransport. From there, builder.Serve(ctx) wires every
route (both plain and SSE) onto the mux/router AND owns its own *http.Server, blocking until
ctx is cancelled (graceful shutdown via http.Server.Shutdown):
builder := rest.NewServer(rest.Info{Title: "My API", Version: "1.0.0"})
_, _ = createUserRoute.Register(builder)
mux := http.NewServeMux()
if err := builder.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: ":8080"})); err != nil {
log.Fatal(err)
}
err := builder.Serve(ctx) // blocks, owns its own http.Server
This is the SOLE public server-startup workflow (per
docs/design/d-0002-pubsub-workflow-simplification.md's Decision 6 — "no escape hatches"): the
lower-level, wire-only Serve(mux, builder)/ServeSSE(mux, builder) functions this guide's
earlier code snippets used to call directly are now unexported internals that
Server.Attach(nethttp.NewServerTransport(...))/Server.Attach(chiadapter.NewServerTransport(...))
call for you — there is no other public entry point for wiring a *rest.Server
onto a mux/router. Server.Attach/Serve give an app ONE unified Attach-then-Serve startup
call across both its REST API and its pub/sub channels (see events.Client.Attach/
.ServeSubscribers in docs/guides/mqtt5.md/docs/guides/zeromq.md) — see
docs/design/d-0001-rest-middleware-workflow-simplification.md's Addendum 5 for the full design.