Documentation
¶
Overview ¶
Package routing provides a declarative, type-safe HTTP router that generates an OpenAPI 3 specification as routes are registered.
The primary type is Router: a concrete router that owns request decoding, validation, response encoding, error mapping, and OpenAPI accumulation. Routes are declared with typed handlers of the form func(ctx, In) (Out, error) via the package-level generic functions (Get, Post, Put, Patch, Delete, Head). Because Go interface methods cannot be generic, registration is done with these functions rather than methods on the Router.
A Router is backed by a Backend — the pluggable seam that a concrete mux library implements. Implementations ship for chi, the net/http.ServeMux standard library (stdlib), julienschmidt/httprouter, and gin-gonic/gin. The Router builds everything on top of the Backend's primitives and never depends on the library directly, so the underlying router is swappable without touching route code:
backend := chi.NewBackend(cfg, chi.WithLogger(logger), chi.WithTracerProvider(tracerProvider))
r := routing.New(backend, encoder, routing.WithLogger(logger), routing.WithTitle("My API"))
routing.Post(r, "/orgs/{orgID:uint64}/users", createUser, routing.WithSummary("Create user"))
routing.Get(r, "/orgs/{orgID:uint64}", getOrg)
r.MountOpenAPI("/openapi.json", "/docs")
A returned error becomes the platform APIError envelope, with the status derived from the error's platform code. A service with an error wire format of its own replaces that rendering wholesale with WithErrorEncoder, which decides the status and the body while leaving serialization to the route's encoder:
routing.WithErrorEncoder(func(ctx context.Context, err error) (int, any) {
if errors.Is(err, platformerrors.ErrResourceInUse) {
return http.StatusConflict, legacyError{Error: "resource is in use"}
}
return http.StatusInternalServerError, legacyError{Error: err.Error()}
})
How an error is recorded follows the status it is sent as: a 5xx is logged at ERROR and marks the span, a 4xx is logged at WARN and does not. The line between them is between the service failing and a client sending something the route was never going to accept — and on an unauthenticated route, recording the second as the first hands every caller a way to write ERROR lines and error-marked spans into the service's telemetry. A service that draws the line elsewhere passes WithErrorClassifier; DefaultErrorSeverity is the rule it replaces.
Response status ¶
The status a route registers is the one it answers with, per WithResponseStatus, and that is right for almost every route. Where it is not — an upsert answering 201 or 200 over a body that looks the same either way — the handler returns a Result instead of a bare value:
routing.Put(r, "/users/{userID:uuid}", func(ctx context.Context, in upsertUser) (routing.Result[user], error) {
u, created, err := svc.Upsert(ctx, in)
if err != nil {
return routing.Result[user]{}, err
}
if !created {
return routing.Result[user]{Value: u}, nil
}
return routing.Result[user]{
Value: u,
Status: http.StatusCreated,
Header: http.Header{"Location": {"/users/" + u.ID.String()}},
}, nil
}, routing.WithAdditionalResponse(http.StatusCreated, new(user), "created"))
A Result carries response headers for the same reason it carries the status: the ones worth setting per response are the ones a chosen status implies — Location on the 201, Retry-After on a 503. Content-Type, Content-Length, Transfer-Encoding, and Connection are refused rather than honored, because the encoder and net/http set those immediately afterwards and a handler's value would be overwritten or would truncate the body; see ErrReservedResponseHeader.
Opting in changes nothing a client sees: the Result is unwrapped before encoding, so the envelope, the generated schema, and the bytes on the wire are the wrapped type's. A zero Status means the registered one and a nil Header sets nothing, so the wrapper costs nothing on the paths that use neither.
The status rides the return value because it is one. Reaching it through the context would put it where the signature says nothing can be, and would leave a handler called directly in a test silently unable to set it.
Returning an error instead is a different statement, and usually a false one: an unready readiness probe did what it was asked, and an error would be recorded as a service fault on every poll. What a status says is what the caller should do next — retry, re-authenticate, give up — which is also the line severity is drawn on above. Detail about what happened belongs in the body.
Request bodies ¶
A body is decoded into the input struct's JSON fields. The case that does not fit is a body that is itself a document — a GeoJSON polygon, a signed blob — on a route that also binds parameters: there is no field for it to land in next to the bound ones. A RawBody field receives it unparsed:
type putGeoJSON struct {
AreaID uuid.UUID `path:"areaID"`
Document routing.RawBody
}
routing.Put(r, "/areas/{areaID:uuid}/geojson", storeGeoJSON,
routing.WithRequestContentType("application/geo+json"),
routing.WithMaxRequestBody(4<<20))
Every route's body can be bounded, raw or decoded, by WithMaxRequestBody for one route or WithDefaultMaxRequestBody for all of them; a request over the bound is answered 413 without the handler running. A RawBody route with no bound of its own gets DefaultRawBodyLimit, because nothing else between the socket and the handler's []byte forms an opinion about how much to read.
Path parameters use an inline typed syntax — "/users/{id:uint64}" — which drives both runtime binding and the generated parameter schema. Query, header, cookie, and body values are bound from struct tags on the typed input.
A path parameter may carry reserved characters. "{name}" is a single segment, so a value containing a slash goes on the wire percent-escaped — "a/b" as "a%2Fb" — or the URL addresses something else. Every Backend matches on the escaped path, which keeps the escaped separator inside the segment, and hands the handler the decoded value, so a route bound to such a value reads the same on every backend:
// GET /files/reports%2F2026%2Fq1.csv
routing.Get(r, "/files/{key}", func(ctx context.Context, in getFile) (*file, error) {
return store.Fetch(ctx, in.Key) // in.Key == "reports/2026/q1.csv"
})
Security ¶
The router does not model security, in either direction.
Enforcement is middleware, declared where the route is registered, next to the handler it guards:
routing.Get(r, "/recipes/{id:uuid}", readRecipe,
routing.WithMiddleware(authz.Require(ReadRecipesPermission)))
The generated document carries no security requirement either. A service that wants one writes it through Spec(), which returns the live *openapi3.Spec: SetHTTPBearerTokenSecurity, SetAPIKeySecurity, and SetHTTPBasicSecurity declare the common schemes, and Components.SecuritySchemesEns() reaches the rest.
Both omissions are deliberate, and the second is why there is no route option for the first. A requirement recorded on an operation and a requirement enforced at runtime are two different statements, and a registration option can only make one of them: it annotates the operation and never sees the request. An option that made that statement while reading like it made the other would document a route as protected while it served anyone, which is the one documentation bug that costs more than no documentation at all.
Example ¶
Example demonstrates wiring a Router over the chi backend, registering typed routes, and mounting the generated OpenAPI spec.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/primandproper/platform-go/v11/encoding"
"github.com/primandproper/platform-go/v11/routing"
"github.com/primandproper/platform-go/v11/routing/backends/chi"
)
// The input for creating a user. Tags decide where each field is bound:
// - path: taken from the URL, cross-checked against the {orgID:uint64} token
// - query: taken from the query string
// - json (no location tag): part of the request body
type newUserForm struct {
Name string `json:"name"`
Email string `json:"email"`
OrgID uint64 `path:"orgID"`
Notify bool `query:"notify"`
}
// The typed output. It is encoded into the response (enveloped by default).
type person struct {
Name string `json:"name"`
Email string `json:"email"`
ID uint64 `json:"id"`
}
// A typed handler: func(ctx, In) (Out, error). The framework decodes and
// validates In, calls this, then encodes Out — or maps a returned error to an
// HTTP status and error envelope.
func createPerson(_ context.Context, in newUserForm) (person, error) {
return person{ID: in.OrgID*1000 + 1, Name: in.Name, Email: in.Email}, nil
}
func fetchPerson(_ context.Context, in struct {
OrgID uint64 `path:"orgID"`
ID uint64 `path:"userID"`
}) (person, error) {
return person{ID: in.ID, Name: "Ada"}, nil
}
func main() {
// The backend is the swappable seam: chi today, gin/etc. tomorrow. It carries
// the library-specific middleware + OpenTelemetry stack.
backend := chi.NewBackend(&chi.Config{
ServiceName: "example-service",
})
// The Router is the declarative, OpenAPI-generating layer on top of it.
enc := encoding.NewServerEncoderDecoder(encoding.ContentTypeJSON)
r := routing.New(backend, enc,
routing.WithTitle("Users API"),
routing.WithVersion("1.0.0"),
)
// Typed registration is done with the package-level generic functions.
// Path params use an inline typed syntax: {orgID:uint64}.
routing.Post(r, "/orgs/{orgID:uint64}/users", createPerson,
routing.WithSummary("Create a user"),
routing.WithTags("users"),
)
// Group applies a shared path prefix and default tags.
r.Group("/orgs/{orgID:uint64}", func(sub *routing.Router) {
routing.Get(sub, "/users/{userID:uint64}", fetchPerson, routing.WithSummary("Fetch a user"))
}, "users")
// Serve the generated OpenAPI 3 spec (and a docs UI) on the same router.
r.MountOpenAPI("/openapi.json", "/docs")
// Registration errors (if any) surface here; check before serving.
if err := r.Err(); err != nil {
panic(err)
}
// In a real service you would hand r.Handler() to an http.Server (or the
// platform's server/http package). Here we drive one request in-process.
req := httptest.NewRequest(http.MethodPost, "/orgs/7/users?notify=true",
strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`))
req.Header.Set(encoding.ContentTypeHeaderKey, "application/json")
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, req)
fmt.Println("status:", rec.Code)
fmt.Println("body:", strings.TrimSpace(rec.Body.String()))
}
Output: status: 201 body: {"data":{"name":"Ada","email":"ada@example.com","id":7001},"details":{"currentAccountID":"","traceID":""}}
Index ¶
- Constants
- Variables
- func DefaultErrorBody(ctx context.Context, err error) (status int, body any)
- type Backend
- type CodedError
- type Empty
- type ErrorClassifier
- type ErrorEncoder
- type ErrorSeverity
- type Handler
- type Middleware
- type Option
- func WithAdditionalResponse(status int, body any, description string) Option
- func WithContentType(contentType encoding.ContentType) Option
- func WithDeprecated() Option
- func WithDescription(description string) Option
- func WithEnvelope(enabled bool) Option
- func WithMaxRequestBody(n int64) Option
- func WithMiddleware(middleware ...Middleware) Option
- func WithOperationID(id string) Option
- func WithRequestContentType(contentType string) Option
- func WithResponseStatus(status int) Option
- func WithSummary(summary string) Option
- func WithTags(tags ...string) Option
- type ParamSpec
- type RawBody
- type Result
- type Route
- func Delete[In, Out any](r *Router, pattern string, h Handler[In, Out], opts ...Option) *Route
- func Get[In, Out any](r *Router, pattern string, h Handler[In, Out], opts ...Option) *Route
- func Head[In, Out any](r *Router, pattern string, h Handler[In, Out], opts ...Option) *Route
- func Patch[In, Out any](r *Router, pattern string, h Handler[In, Out], opts ...Option) *Route
- func Post[In, Out any](r *Router, pattern string, h Handler[In, Out], opts ...Option) *Route
- func Put[In, Out any](r *Router, pattern string, h Handler[In, Out], opts ...Option) *Route
- type Router
- func (r *Router) Backend() Backend
- func (r *Router) Err() error
- func (r *Router) Group(prefix string, fn func(sub *Router), tags ...string)
- func (r *Router) Handle(method, pattern string, handler http.Handler, middleware ...Middleware)
- func (r *Router) Handler() http.Handler
- func (r *Router) MarshalSpec() ([]byte, error)
- func (r *Router) MountOpenAPI(specPath, uiPath string)
- func (r *Router) Spec() *openapi3.Spec
- func (r *Router) Use(middleware ...Middleware)
- type RouterOption
- func WithDefaultEnvelope(enabled bool) RouterOption
- func WithDefaultMaxRequestBody(n int64) RouterOption
- func WithErrorClassifier(classifier ErrorClassifier) RouterOption
- func WithErrorEncoder(encoder ErrorEncoder) RouterOption
- func WithInfoDescription(description string) RouterOption
- func WithLogger(logger logging.Logger) RouterOption
- func WithServer(url string) RouterOption
- func WithTitle(title string) RouterOption
- func WithTracerProvider(tracerProvider tracing.Provider) RouterOption
- func WithVersion(version string) RouterOption
Examples ¶
Constants ¶
const DefaultRawBodyLimit int64 = 1 << 20 // 1 MiB
DefaultRawBodyLimit is the request-body bound a route with a RawBody field gets when neither it nor its Router sets one.
Only such a route: a decoded body is bounded by whatever the Router or the route says and otherwise not at all, which is what it has always been. A raw body is different in kind — nothing between the socket and the handler's []byte forms an opinion about how much of it to read — so the default is a number rather than "as much as arrives". Routes that carry larger documents say so with WithMaxRequestBody.
Variables ¶
var ErrInvalidResponseStatus = errors.New("response status outside the range an HTTP response can carry")
ErrInvalidResponseStatus reports that a Result named a status an http.ResponseWriter cannot carry.
It travels the error path rather than being quietly replaced by the registered status. A Result carrying 42 is a defect in the handler, and answering the client as though the handler had named nothing hides it for as long as nobody reads the response codes. Zero is not this — see Result.
var ErrReservedResponseHeader = errors.New("response header is not the handler's to set")
ErrReservedResponseHeader reports that a Result carried a header the handler does not get to set.
Four are refused, each because setting it would produce a response that contradicts itself rather than one that carries the handler's intent:
- Content-Type is the route encoder's, and the media type recorded in the generated document. The encoder sets it immediately before writing, so a handler's value would be overwritten without a word — and if it were not, the response would disagree with its own OpenAPI entry.
- Content-Length is computed from what is actually written. A handler's number is either the same one or a truncated response.
- Transfer-Encoding and Connection are framing, which net/http owns for the whole connection rather than for one response.
Refusing is the point: each of these fails silently or corruptly if allowed, and none of them is recoverable once the status is on the wire.
Functions ¶
func DefaultErrorBody ¶
DefaultErrorBody is the rendering a Router uses when given no ErrorEncoder: the platform APIError envelope, with the status from the error's code. Binding errors carry their own code; anything else is mapped through errors/http.
It is exported so an ErrorEncoder can delegate — a service that wants its own format for its own errors and the platform's for everything else returns DefaultErrorBody's result for the cases it does not recognize.
The message sent for a binding failure is the bindError's own, not its Error() string: the wrapped cause is for the operation record, not for the client.
A binding failure is normally sent as the status its code maps to. The one exception is a body over the route's limit, which is a 413: an ErrorEncoder that wants to answer it the same way finds an *http.MaxBytesError in the chain.
Types ¶
type Backend ¶
type Backend interface {
// Handle registers handler for method at pattern. pattern uses the
// "/users/{id}" placeholder syntax (already stripped of any type
// annotation by the Router).
Handle(method, pattern string, handler http.Handler)
// Use installs global middleware, applied to every route.
Use(middleware ...Middleware)
// PathValue returns the decoded value of the named path parameter for
// req, or "" if absent.
//
// Decoded, because a value carrying a reserved character travels
// percent-escaped: "{slug}" is one segment, so a slug of "a/b" arrives
// as "a%2Fb" or it addresses a different resource. An implementation
// therefore matches on the escaped path — so the escaped separator stays
// inside the segment rather than splitting it — and undoes the escaping
// here, handing back the value the caller wrote. Backends that do not
// get this from their library share
// routing/backends/internal/pathvalues.Decode.
PathValue(req *http.Request, name string) string
// Handler returns the composed http.Handler for serving.
Handler() http.Handler
}
Backend is the pluggable HTTP-muxing seam beneath a Router. A concrete router library (chi, gin, ...) implements it; the Router builds every typed route, spec, and lifecycle concern on top of these primitives and never depends on the library directly.
Use must be called before Handle (many muxes, chi included, forbid adding global middleware after routes are registered).
type CodedError ¶
CodedError is an error carrying the platform ErrorCode that determines its status and appears in the platform envelope. The router's own binding failures implement it, so an ErrorEncoder can tell a malformed body (ErrDecodingRequestInput) from a failed validation (ErrValidatingRequestInput) from a handler's own error without matching on unexported types.
type Empty ¶
type Empty struct{}
Empty is a placeholder type for routes that take no meaningful input or produce no response body. A route whose Out is Empty writes only a status code (no body).
type ErrorClassifier ¶
type ErrorClassifier func(ctx context.Context, err error, status int) ErrorSeverity
ErrorClassifier decides how a returned error is recorded, given the status it resolved to. It runs after the ErrorEncoder, so it sees the status the client is actually being sent — including one a custom encoder chose.
It is not asked to render anything and cannot change the response: the error's status and body are already decided by the time it is called.
type ErrorEncoder ¶
ErrorEncoder renders a handler or binding error as the status and body to send. It is the seam for a service whose error wire format predates this router: returning (409, myFlatError{Message: "..."}) produces exactly that body, encoded by the route's encoder, instead of the platform APIError envelope.
It is not asked whether it wants to handle a given error — a Router either has one or does not, so a service cannot end up with two error formats depending on which error was returned. To fall back to the platform envelope for some errors, call DefaultErrorBody and return what it gives.
A nil body writes the status and no body at all. A status outside 100..999 is not a valid HTTP status and would panic the ResponseWriter, so it is written as 500.
type ErrorSeverity ¶
type ErrorSeverity uint8
ErrorSeverity is how a Router records an error it is sending to a client.
The zero value is SeverityError, so a classifier that falls through its own cases over-reports rather than dropping the error silently. Losing a 500 from the logs is the failure mode worth designing against; an extra line is not.
const ( // SeverityError records the error as a service fault: an ERROR log line and // an error-marked span. SeverityError ErrorSeverity = iota // SeverityWarn logs the error at WARN and leaves the span unmarked. SeverityWarn // SeverityInfo logs the error at INFO and leaves the span unmarked. SeverityInfo // SeverityNone records nothing at all. It is the honest setting for an error // that is a normal outcome of a route — a conditional GET answering 304, an // idempotent create answering 409 — and the dishonest one for anything a // person would want to find later. SeverityNone )
func DefaultErrorSeverity ¶
func DefaultErrorSeverity(_ context.Context, _ error, status int) ErrorSeverity
DefaultErrorSeverity is how a Router records errors when given no ErrorClassifier: 5xx as a service fault, 4xx at WARN, anything else at INFO.
The distinction it draws is between the two things a returned error can mean. A 500 is the service failing, and belongs in the logs and on the span as such. A 400 is a client sending something the route would never have accepted, and recording it as a service fault is wrong twice: it is not a fault of the service, and on an unauthenticated route it hands every caller a way to write ERROR lines and error-marked spans into the service's telemetry by sending malformed requests. The information is not discarded — a 4xx is still logged, with the error and the status on the line — it is filed as what it is.
func (ErrorSeverity) String ¶
func (s ErrorSeverity) String() string
String returns the severity's name.
type Handler ¶
Handler is a typed HTTP handler. It receives a decoded, validated input value and returns a typed output or an error. The framework handles decoding In from the request, encoding Out into the response, and mapping a returned error to an HTTP status and error envelope.
type Middleware ¶
Middleware is a standard net/http middleware function.
type Option ¶
type Option func(*routeConfig)
Option customizes a single route's registration and its generated OpenAPI operation.
func WithAdditionalResponse ¶
WithAdditionalResponse documents an additional response (e.g. a 404 with an error body).
func WithContentType ¶
func WithContentType(contentType encoding.ContentType) Option
WithContentType overrides the response content type for this route.
func WithDeprecated ¶
func WithDeprecated() Option
WithDeprecated marks the operation as deprecated.
func WithDescription ¶
WithDescription sets the operation's long description.
func WithEnvelope ¶
WithEnvelope toggles wrapping the response body in errors/http.APIResponse[Out]. Enveloping is on by default (configurable at the Router level).
func WithMaxRequestBody ¶
WithMaxRequestBody bounds this route's request body, in bytes, overriding any Router-wide default from WithDefaultMaxRequestBody.
The bound applies to whichever body the route has, decoded or raw. A request over it is answered 413 without the handler running, and the connection is not left reading a body that has already been refused.
It is per-route because the alternative is one number for every endpoint a service has, which has to be the largest one any endpoint needs: the route that accepts a 10 MiB import sets the ceiling that the login route then also runs under.
A value of zero or less is no bound — including on a RawBody route, which is how one opts out of DefaultRawBodyLimit.
func WithMiddleware ¶
func WithMiddleware(middleware ...Middleware) Option
WithMiddleware applies middleware to this route only.
func WithOperationID ¶
WithOperationID overrides the generated operation ID.
func WithRequestContentType ¶
WithRequestContentType sets the media type the route's request body is documented under.
It is documentation only: nothing checks an incoming request against it, and the body is decoded by content-type negotiation exactly as before. What it changes is the generated operation, which is the reason a route with a RawBody field wants it — a GeoJSON document arrives as "application/geo+json", and a spec that calls it "application/json" sends every generated client the wrong Content-Type header.
Unset, a decoded body is documented as the reflector's default (application/json) and a raw one as application/octet-stream, which is what an unparsed body with no declared media type is.
func WithResponseStatus ¶
WithResponseStatus overrides the success HTTP status (default 200, or 201 for POST).
func WithSummary ¶
WithSummary sets the operation's short summary.
type ParamSpec ¶
ParamSpec is a path parameter parsed out of a typed-path pattern such as "/users/{id:uint64}". Token is the resolved type token ("string" when the pattern omitted an annotation).
type RawBody ¶
type RawBody []byte
RawBody is a request body the router reads but does not parse. A field of this type on an input struct receives the request body verbatim:
type putGeoJSON struct {
AreaID uuid.UUID `path:"areaID"`
Document routing.RawBody
}
It exists for the case the typed model otherwise cannot express: a body that is itself a document rather than an object with fields, on a route that also binds parameters. Decoding into the input struct has nowhere to put such a body, and a handler that takes the whole request instead loses the bound parameters along with everything else the router does.
Exactly one RawBody field is allowed, and only on a method that carries a body, and only on an input with no other body fields — the body is either this document or an object with fields, and a struct claiming both is a registration-time panic rather than a request-time surprise.
The bytes are unvalidated and the request's Content-Type is not checked against anything; the point of the type is that the router forms no opinion about them. What it does enforce is size: see WithMaxRequestBody, which defaults to DefaultRawBodyLimit for a route that has one of these.
Example ¶
ExampleRawBody demonstrates a route whose body is a document rather than an object with fields, bounded to a size the route chooses.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/primandproper/platform-go/v11/encoding"
"github.com/primandproper/platform-go/v11/routing"
"github.com/primandproper/platform-go/v11/routing/backends/chi"
)
// storeArea takes a bound path parameter next to a body the router does not
// parse.
func storeArea(_ context.Context, in struct {
Document routing.RawBody
AreaID uint64 `path:"areaID"`
}) (routing.Empty, error) {
fmt.Println("area:", in.AreaID)
fmt.Println("document:", string(in.Document))
return routing.Empty{}, nil
}
func main() {
r := routing.New(chi.NewBackend(&chi.Config{ServiceName: "example-service"}),
encoding.NewServerEncoderDecoder(encoding.ContentTypeJSON))
routing.Put(r, "/areas/{areaID:uint64}/geojson", storeArea,
routing.WithRequestContentType("application/geo+json"),
routing.WithMaxRequestBody(4<<20),
routing.WithResponseStatus(http.StatusNoContent),
)
if err := r.Err(); err != nil {
panic(err)
}
req := httptest.NewRequest(http.MethodPut, "/areas/12/geojson",
strings.NewReader(`{"type":"Point","coordinates":[0,0]}`))
req.Header.Set(encoding.ContentTypeHeaderKey, "application/geo+json")
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, req)
fmt.Println("status:", rec.Code)
}
Output: area: 12 document: {"type":"Point","coordinates":[0,0]} status: 204
type Result ¶
type Result[T any] struct { // Value is the response body, encoded exactly as a handler returning T // would have had it encoded. Value T // Header is set on the response before it is written, replacing any value // the Router or a middleware had already put under the same name. Nil sets // nothing. // // It is here for the headers that only make sense alongside a chosen // status — Location on the 201 an upsert returns when it created, and // Retry-After on a 503 — which is why the two travel together rather than // through separate mechanisms. // // Content-Type, Content-Length, Transfer-Encoding, and Connection are // refused: see ErrReservedResponseHeader. Header http.Header // Status is the HTTP status to answer with, or zero for the route's // registered status. Status int }
Result pairs a handler's response value with the status it is answered with, for the routes whose status is not fixed at registration.
The status a route registers is right for almost every route: a POST answers 201, a delete answers 204. Two shapes it cannot express are an upsert, which answers 201 or 200 depending on what it did to a body that looks the same either way, and a readiness probe, which reports one body shape with 200 when the service is healthy and 503 when it is not:
routing.Put(r, "/users/{userID:uuid}", func(ctx context.Context, in upsertUser) (routing.Result[user], error) {
u, created, err := svc.Upsert(ctx, in)
if err != nil {
return routing.Result[user]{}, err
}
if !created {
return routing.Result[user]{Value: u}, nil
}
return routing.Result[user]{
Value: u,
Status: http.StatusCreated,
Header: http.Header{"Location": {"/users/" + u.ID.String()}},
}, nil
}, routing.WithAdditionalResponse(http.StatusCreated, new(user), "created"))
It is opt-in per route: a handler returning T is unaffected, and one returning Result[T] is answered exactly as the T inside it would have been, at the status it names. The envelope, the generated schema, and the encoded bytes are the T's — Result is unwrapped before any of them, never encoded.
Status and Header travel together because the headers worth setting per response are the ones a chosen status implies: Location belongs to the 201 an upsert returns when it created something, and Retry-After to a 503. Splitting them would mean two mechanisms that are only ever correct when used together.
Why the status rides the return ¶
Because it is a return value. Reaching the status through the context would put it where nothing in the signature says it can be, and make a handler tested by direct call silently unable to set it. Here a handler that names a status has said so in the value it returns, and a test reads it off the Result without a router.
Zero ¶
A zero Status means "the registered status", so a handler that names one on only some paths does not have to restate the default on the others, and the zero Result returned beside an error names nothing.
Documentation ¶
The registered status is still the documented one. A route that answers more declares the others with WithAdditionalResponse, as above. Nothing can infer them: the status is chosen per response, and the reflected type says only that a Result was returned, not what it will carry.
Example ¶
ExampleResult demonstrates a handler naming the status of one response: an upsert answers 201 when it created the row and 200 when it replaced one, over a body that looks the same either way.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/primandproper/platform-go/v11/encoding"
"github.com/primandproper/platform-go/v11/routing"
"github.com/primandproper/platform-go/v11/routing/backends/chi"
)
// The typed output. It is encoded into the response (enveloped by default).
type person struct {
Name string `json:"name"`
Email string `json:"email"`
ID uint64 `json:"id"`
}
// upsertForm is the body of a PUT that creates or replaces.
type upsertForm struct {
Name string `json:"name"`
ID uint64 `path:"userID"`
}
func main() {
r := routing.New(chi.NewBackend(&chi.Config{ServiceName: "example-service"}),
encoding.NewServerEncoderDecoder(encoding.ContentTypeJSON))
// Pretend the store already holds user 7 and nothing else.
existing := map[uint64]bool{7: true}
routing.Put(r, "/users/{userID:uint64}", func(_ context.Context, in upsertForm) (routing.Result[person], error) {
created := !existing[in.ID]
existing[in.ID] = true
out := person{ID: in.ID, Name: in.Name}
if !created {
return routing.Result[person]{Value: out}, nil
}
// Location is worth setting only on the response that created
// something, which is the same response that chose the 201.
return routing.Result[person]{
Value: out,
Status: http.StatusCreated,
Header: http.Header{"Location": {fmt.Sprintf("/users/%d", in.ID)}},
}, nil
},
routing.WithEnvelope(false),
// The registered status is the documented one; the other is declared.
routing.WithAdditionalResponse(http.StatusCreated, new(person), "created"),
)
if err := r.Err(); err != nil {
panic(err)
}
for _, id := range []string{"7", "8"} {
req := httptest.NewRequest(http.MethodPut, "/users/"+id, strings.NewReader(`{"name":"Ada"}`))
req.Header.Set(encoding.ContentTypeHeaderKey, "application/json")
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, req)
fmt.Println("status:", rec.Code, "location:", rec.Header().Get("Location"),
"body:", strings.TrimSpace(rec.Body.String()))
}
}
Output: status: 200 location: body: {"name":"Ada","email":"","id":7} status: 201 location: /users/8 body: {"name":"Ada","email":"","id":8}
type Route ¶
Route is the descriptor returned by a registration call. It records the concrete method and (annotation-stripped) path the route was registered under, plus the resolved OpenAPI operation ID.
type Router ¶
type Router struct {
// contains filtered or unexported fields
}
Router is the declarative, OpenAPI-generating router. It is the primary type callers use: typed routes are registered with the package-level generic functions (Get, Post, ...), which decode and validate input, encode output, and accumulate an OpenAPI 3 operation. A Router is backed by a Backend, so the underlying mux (chi, gin, ...) is swappable without changing route code.
It is a concrete type, not an interface, because typed registration must be generic and Go does not permit generic interface methods. The swappable seam is Backend; the Router is the one fixed orchestration layer above it.
func New ¶
func New( backend Backend, enc encoding.ServerEncoderDecoder, opts ...RouterOption, ) *Router
New builds a Router over a Backend. The backend carries all library-specific middleware and OpenTelemetry wiring; the encoder decides how request bodies are decoded and responses encoded.
func (*Router) Err ¶
Err returns a joined error of all non-fatal registration failures accumulated so far, or nil if there were none. Check it before serving.
func (*Router) Group ¶
Group creates a sub-Router that shares the backend, reflector, and error accumulator, but applies an additional path prefix and default tags to routes registered through it.
func (*Router) Handle ¶
func (r *Router) Handle(method, pattern string, handler http.Handler, middleware ...Middleware)
Handle registers a raw http.Handler on the backend — an escape hatch for routes that do not fit the typed model (static files, streaming, websockets). It records no OpenAPI operation.
func (*Router) Handler ¶
Handler returns the composed http.Handler for serving, delegating to the backend.
func (*Router) MarshalSpec ¶
MarshalSpec renders the accumulated spec as indented JSON.
func (*Router) MountOpenAPI ¶
MountOpenAPI registers two routes on the backend: specPath serves the spec as JSON, and (when uiPath is non-empty) uiPath serves a self-contained docs UI page that renders it. Both routes go through the backend, so they inherit all of its middleware and instrumentation.
Call this after all typed routes are registered so the served spec is complete.
func (*Router) Spec ¶
Spec returns the accumulated OpenAPI 3 specification. It reflects every route registered so far; call it after registration (and MountOpenAPI) is complete.
It is the live document, not a copy, so it is also where anything the Router does not model gets written. Security is the case that comes up: declare the schemes with SetHTTPBearerTokenSecurity, SetAPIKeySecurity, or SetHTTPBasicSecurity, reach the rest through Components.SecuritySchemesEns(), and put the per-operation requirements on with SetupOperation. Enforcement remains a separate matter, and remains middleware.
func (*Router) Use ¶
func (r *Router) Use(middleware ...Middleware)
Use installs global middleware on the backend. Call it before registering routes.
type RouterOption ¶
type RouterOption func(*routerConfig)
RouterOption configures a Router at construction.
func WithDefaultEnvelope ¶
func WithDefaultEnvelope(enabled bool) RouterOption
WithDefaultEnvelope sets whether responses are wrapped in errors/http.APIResponse[Out] by default (per-route override via WithEnvelope).
func WithDefaultMaxRequestBody ¶
func WithDefaultMaxRequestBody(n int64) RouterOption
WithDefaultMaxRequestBody bounds the request body every route will read, in bytes, for routes that do not set their own with WithMaxRequestBody.
Unset, there is no Router-wide bound and each route decides — which for all but a RawBody route means no bound at all, as it always has. The reason to set one is that the alternative is enforcing it in the encoder, where it is one number for every endpoint the service has: the upload route and the login route get the same ceiling, and it has to be the upload route's.
A value of zero or less is no bound.
func WithErrorClassifier ¶
func WithErrorClassifier(classifier ErrorClassifier) RouterOption
WithErrorClassifier replaces how returned errors are recorded, for a service whose idea of which errors are its own fault differs from the status they are sent as.
Without it, severity follows the resolved status — see DefaultErrorSeverity for what that means and why. With it, the classifier decides, and can reach the error itself: a 502 from a dependency that is known to flap can be recorded at WARN, and a 404 that should never happen on an internal route at ERROR.
It is an option on the Router rather than a per-route one for the same reason WithErrorEncoder is: what a service considers its own fault is a property of the service. A nil classifier leaves the default in place.
func WithErrorEncoder ¶
func WithErrorEncoder(encoder ErrorEncoder) RouterOption
WithErrorEncoder replaces how returned errors are rendered, for services that serve an error wire format this router did not define. The encoder decides the status and the body; the route's encoder still serializes it, so content-type negotiation and the ServerEncoderDecoder seam are unaffected.
Without it, errors are rendered exactly as they always were — the platform APIError envelope, with the status from the code map. With it, they are rendered by the encoder for every error, binding failures included; those implement CodedError, so the encoder can recover the code it would have been given.
It is an option on the Router rather than a per-route one because a service's error format is a property of its API, not of one endpoint. A nil encoder leaves the default in place.
func WithInfoDescription ¶
func WithInfoDescription(description string) RouterOption
WithInfoDescription sets the OpenAPI document description.
func WithServer ¶
func WithServer(url string) RouterOption
WithServer adds a server URL to the OpenAPI document.
func WithTitle ¶
func WithTitle(title string) RouterOption
WithTitle sets the OpenAPI document title.
func WithTracerProvider ¶
func WithTracerProvider(tracerProvider tracing.Provider) RouterOption
WithTracerProvider attaches a tracer provider, enabling spans on every registered route.
func WithVersion ¶
func WithVersion(version string) RouterOption
WithVersion sets the OpenAPI document version.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
backends
|
|
|
chi
Package chi provides a routing.Backend built on go-chi/chi.
|
Package chi provides a routing.Backend built on go-chi/chi. |
|
gin
Package gin provides a routing.Backend built on gin-gonic/gin.
|
Package gin provides a routing.Backend built on gin-gonic/gin. |
|
httprouter
Package httprouter provides a routing.Backend built on julienschmidt/httprouter, a fast radix-tree router.
|
Package httprouter provides a routing.Backend built on julienschmidt/httprouter, a fast radix-tree router. |
|
internal/conformance
Package conformance holds the tests that pin every routing.Backend to one answer, rather than to whatever its underlying library happens to do.
|
Package conformance holds the tests that pin every routing.Backend to one answer, rather than to whatever its underlying library happens to do. |
|
internal/httpmw
Package httpmw holds the net/http middleware stack shared by every routing backend.
|
Package httpmw holds the net/http middleware stack shared by every routing backend. |
|
internal/pathvalues
Package pathvalues holds the one decode step every routing backend that matches on the escaped path owes its callers.
|
Package pathvalues holds the one decode step every routing backend that matches on the escaped path owes its callers. |
|
stdlib
Package stdlib provides a routing.Backend built on the standard library's net/http.ServeMux.
|
Package stdlib provides a routing.Backend built on the standard library's net/http.ServeMux. |
|
Package routingcfg selects and builds a routing backend from configuration: chi, net/http's ServeMux, httprouter, or gin.
|
Package routingcfg selects and builds a routing backend from configuration: chi, net/http's ServeMux, httprouter, or gin. |
|
Package routingmock provides mock implementations of the routing package's interfaces (currently the Backend seam), for testing routers without a real mux library.
|
Package routingmock provides mock implementations of the routing package's interfaces (currently the Backend seam), for testing routers without a real mux library. |