Middleware Workflow — Current-State Audit & Simplification Proposal¶
Status: FULLY IMPLEMENTED —
middleware,api/rest,adapters/nethttp,adapters/chiall ship the unifiedHandleMW/ClientMW/Serve/ServeSSE/ServeOne/Call/CallWithHandledesign described below, PLUS the typedRequestParams/ResponseParamsfields (HeaderParamSpec/etc. inmiddleware,FromHeaderParam/etc. bridges inapi/rest) and the generalized spec-declaring middleware constructors these same bridge functions also satisfy; every non-example package plus all ~60 examples build,go vet, andgo testclean, and every example runs to completion. Old-door removal — DONE. The OLD, pre-redesignHandler/Register/SSEHandler/RegisterSSEper-route functions have been DELETED from bothadapters/nethttpandadapters/chi;Serve/ServeOne/ServeSSEare now the SOLE public server-side entry points, with zero capability lost — see "Decision:Serveis the only public server-side entry point" below, updated to reflect the final state. Closing this required fixing two genuine gaps discovered mid-migration inServe/ServeSSE's reflect-based dispatch (request/responseFormatscontent negotiation, including streaming formats; fullErrorPatternresponse building viaErrorResponseForon the handler-error path) — both are now fully implemented, so no capability regression exists relative to the oldHandler/Registerdoors.RouteHandle.WithHandler/SSERouteHandle.WithHandlerwere added asRoute.WithHandler's post-registration equivalents, for routes whose handler depends on runtime state unavailable at package-level var-init time (seeexamples/sensor-service). Also explicitly OUT of scope (separate roadmap docs, not implementation gaps of THIS doc): Events/ReqReply/Ports-beyond- pattern-building and SSE CLIENT consumption — see the sibling docs cross-referenced at the bottom of this summary. Started as a factual audit of the SHIPPED workflow (post the now-deleteddeclarative- middleware.md's "Revision 2 — the declare/ implement split", content since absorbed into MCP and Ports Declarative Middleware); all 6 open questions raised during that audit have since been worked through collaboratively and resolved into 5 concrete decisions (a 6th confirmed the status quo as correct, no change). Scope: REST (api/rest/adapters/nethttp/adapters/chi) only —api/events's own equivalent shipped separately (see Pub/Sub Workflow Simplification);api/reqreply's equivalent is designed in D-0004 — ReqReply Workflow Simplification, deliberately deferred until THIS doc's implementation ships. The "Step 1–4"/"escape hatches" sections below describe the OLD, currently- shipped workflow being replaced; the "## Decision: ..." sections describe the NEW, agreed design. ← Back to Design DocumentsSummary of the 6 questions' decisions: (1) whole-API declarative wiring —
Route.HandleMW/.WithHandler()chain intoRoute.Register(builder) error(no more*RouteHandlereturned), a newnethttp.Serve(mux, builder)/ServeSSEwire the whole API in one call; (2) closed as a side effect of (1) —HandleMW(mw, fn)takes the actual declaredmwvalue, closing a typo-risk gap; (3) symmetric client-side wiring —Route.ClientMW(mw, fn)+ a singlenethttp.Call(ctx, caller, route, req, opts)replaceClientMiddleware/UseClient/Call/CallHandle/CallVia/CallHandleViaentirely; (4)Serve/ServeSSEbecome the ONLY public server-side entry points —Handler/Register/SSEHandler/RegisterSSEare removed; (5) closed as a side effect of (3) — the four client entry-point functions collapse into one; (6) confirmed theany-typedFn/fndesign is the correct tradeoff, not a gap — no change.Then a second "compromises" evaluation pass re-checked every one of the 12 escape hatches against decisions 1–4, surfacing two underestimated compromises and three further elimination opportunities, ALL now resolved:
FromSecuritySchemebridges manual security declarations into the new mechanism, ANDrest.WithSecuritySchemeis removed entirely (redundant once the bridge exists) — the ONLY escape hatch actually eliminated outright, not just replaced; an unexportedcallWithHandleprimitive keepsports' high-throughput call pattern regression-free with zero new public API;Middleware'sRequestParams/ResponseParams []anybecome typed fields, eliminatingParamContributionShapeErrorat compile time; and a new.Implement()chainable method +Wrap/Transformconstructors make general-purpose (empty-Satisfies) implementations explicit and give them a simpler attachment path than security's.Use()+.HandleMW()pairing (this also fixed a genuine bug found later in an earlier draft of this same idea — see "Decision:.Implement()..." below).Every follow-on design question raised across all decisions has since been resolved (
Serve's whole-builder failure semantics — strict fail-fast, routes without.WithHandler()skipped silently;Options→ per-route.WithOptions();.ClientMW()validation timing — insideCall, matchingServe;chi's client-side story — none, permanently; single-route test ergonomics — newServeOneconvenience wrapper;ValidateRoute— kept unchanged, a clean two-tier split withServeOne). Zero open design questions remain from that pass.A further review round ("Review round: PathParams/HeaderParams/ CookieParams and middleware chaining") confirmed route-level merge fields, middleware-contributed validate-only params, and the existing
*Req-pointer chaining/codex.DecodeVarsmechanism are ALL fully preserved and unaffected by Decisions 1–8 — "one struct, one call" holds unchanged at the handler boundary. One genuine gap was found (no conflict detection for two chainedFns writing the sameReqfield) and deliberately left undetected, for principled reasons (would reintroduce the reflective/type-erased machinery just eliminated elsewhere) — closed via a documented naming/ContextFieldconvention instead of enforcement.A final review round across the three middleware use-case categories (security / observer / general custom middleware) found a genuine bug in the ORIGINAL
middleware.Wrapdecision (returned the wrong type —Middlewareinstead ofServerImplementation) and used it as the occasion to generalize: a NEW.Implement(impl)chainable method gives declare-time-content-free implementations (observability,Req-cleansing/transformation via the newTransformconstructor) a simpler attachment path than security's.Use()+.HandleMW()pairing, since there's nothing for them to "match." Also generalizednethttp.HeaderParam(previously framed as part of the API-key story) into the coremiddlewarepackage, alongside its missingCookieParam/QueryParam/ResponseHeaderParam/ResponseCookieParamsiblings — confirmed these need NO coverage check, a fundamentally different risk profile than security (worst case: a validated-but- unused declaration, not a security hole).A final critical review pass — immediately before implementation — found and resolved 8 concrete issues (3 Critical, 2 High, 2 Medium, 1 Low):
ServeOne's strayOptionsparameter (contradicted the.WithOptions()decision, dropped);Serve's missing duplicate Method+Path detection (a REALmux.Handlepanic risk, closed via a newDuplicateRouteError+ defensiverecover());Transform's Fn shape clarified to WRAP into the existing security shape rather than introduce a third one; aHandleMW-to-.Use()pairing check folded intoRegister's existing merge pass (order-independent, zero ergonomics cost); the ctx pre-allocation checklist broadened fromContextFieldalone to all FOUR ctx keysHandler's current body sets up;SSERoute's full chainable method set spelled out explicitly (spinning SSE CLIENT consumption — confirmed to not exist anywhere in go-codex today — into its own new SSE Client Consumption roadmap doc, since shipped and removed — see this doc's own addendum below);Servergains an internal mutex for concurrentRegistercalls (an explicit first step toward, not a solution to, the separately-tracked Dynamic Port Rebinding gap); andMultiRouteError's exact shape finalized (Unwrap() []error, Go 1.20+ multi-error support). Zero open items remain from this pass either.A second, fresh full read-through — checking specifically for inconsistencies introduced by the pass above — found 3 more items: the
HandleMW-pairing fix was SIMPLIFIED from per-mwbookkeeping to a reverse-Satisfies check (a sibling toCheckCoverage, needing zero new bookkeeping and correctly covering.Implement()-attached misuse too);SSERoute.Register's merge-pass parity withRoute.Register's was made explicit (it shares every check, including the reverse- Satisfies one); and the SAMEmw-mismatch ambiguity was found onClientMWtoo — resolved by giving client-side implementations their OWNSatisfiesconcept via a NEW, EXPLICITmiddleware.ClientImplementation{Name, Satisfies, Fn}type (the TRUE successor to the removedClientMiddleware, not just "removed with nothing replacing it"), gating WHICH implementationsCallruns (mirroring server-side gating exactly) — a correctness improvement that PREVENTS the mismatch rather than merely detecting it. Zero open items remain from this pass either — except one EXPLICITLY-DEFERRED question (does the client side also need its own.Implement()- equivalent for truly declare-time-content-free implementations, flagged but NOT decided as a side effect of this pass).A FOURTH pass found and closed the deferred question above, via a bigger unification than first planned. Investigating
HandleMW/ClientMWfor a genuine, previously-undocumented nil-pointer-panic risk (mw.Security.SchemeNameaccessed with no nil guard) led to a better fix than a nil check alone:mwbecomes a NILABLE*middleware.Middlewareon BOTH methods, and.Implement(impl)is REMOVED ENTIRELY —HandleMW(nil, fn)/ClientMW(nil, fn)now cover everything it used to, with nil sanctioned as "nothing to pair against" rather than a misuse case needing detection.Wrapis ALSO removed — a caller passes a rawfunc(http.Handler) http.Handlerclosure directly toHandleMW(nil, fn).Transformandnethttp.Observabilityboth simplify to return the bare wrapped closure instead of aServerImplementation, sinceHandleMWnow builds that internally. This closes the previous round's deferred question as a direct side effect —ClientMW(nil, fn)IS the client-side.Implement()-equivalent that was asked about. Zero open items remain from this pass either.A FIFTH pass — verifying the FOURTH pass's unification actually propagated everywhere — found 2 more items, both now fixed. Both of the doc's own fully-worked example code blocks ("The full workflow," "The full symmetric workflow") still passed
mwBY VALUE (middleware.Middleware) toHandleMW/ClientMW, which now require a POINTER (*middleware.Middleware) per the fourth pass's own unification — a genuine compile error in the doc's own worked examples, fixed by taking&mwat all three call sites. Separately, the "Serve's whole-builder failure semantics" section's Part 1 wording was ambiguous about whether a route with.HandleMW()attached but NO.WithHandler()is still skipped as spec-only — the heading already said.WithHandler()'s presence ALONE is the gating signal, but the body's phrasing read as if BOTH had to be absent; reworded for clarity (it IS skipped, exactly like a.Use()-only route — there's no handler to wire either way). Zero open items remain from this pass either.Implementation planning found and resolved 3 more pre-implementation gaps, all now written in below. (1)
Register's "no more*RouteHandlereturned... nothing downstream consumes one directly" claim was CODE-VERIFIED FALSE —ports/handle.godirectly callsroute.Register(b)/SSERoute.Register(b)and consumes the returned handle for both REST-ingest and SSE pattern wiring — resolved with a NEW, dedicatedRegisterHandlemethod on bothRouteandSSERoute(identical validation, ALSO returns the handle) forports-style direct wiring that bypassesServe/ServeSSEentirely;Register(b) errorstays the pureServe-consumed path. (2)middleware.Scopes/nethttp.Scopes/nethttp.APIKeyare now REDUNDANT under the fourth pass'sHandleMWunification (their only job — wrapping an extract/ verify closure into aServerImplementation— now happens INSIDEHandleMWitself) — all three REMOVED. (3).WithOptions(opts Options)'sOptionstype was left ambiguous —api/restcannot importadapters/nethttp.Optionswithout an import cycle — resolved as type-erasedWithOptions(opts any), asserted atServe/chi.Servetime with a newOptionsShapeErroron mismatch, mirroring the existingFormatOptErrorpattern. Zero open items remain from this pass either.A further gap surfaced mid-implementation:
Serve's generic dispatch is not achievable with plain Go generics.Serve(mux, b) errorwalks a HETEROGENEOUS collection of routes (each with a DIFFERENTReq/Resppair), but building each one'shttp.Handlerneeds Req/Resp-typed code — Go cannot instantiate a generic function with type parameters known only at runtime. Resolved:reflect.Value.Call, used ENTIRELY insidenethttp/chi's ownServe/ServeSSE— Go cannot reflectively INSTANTIATE a generic function, but CAN reflectively CALL an already-concrete function value by its dynamic type, and every closureServeneeds (Decode/Encode/HandlerFn/eachServerImplementation.Fn) is already such a value, stored in an exported field. Zero logic moves out ofHandler/chi.Handler(they stay untouched);api/restgains only 2 small, reflection-free accessors (Server.RouteEntries/SSEEntries) and stays exactly asnet/http/reflect-free as every other decision in this doc requires. Zero open items remain from this pass either — ready for implementation.See each "## Decision: ..." / "## Review round: ..." section below for full API surface, rationale, and resolution history.
The three core types (middleware package)¶
// Declare-time-only. The ONLY type that can contribute to a route's spec.
// Attached via rest.WithMiddleware(...) / Route.Use(...).
type Middleware struct {
Name string
Security *SecurityDeclaration // nil = no spec contribution
RequestParams []any // e.g. rest.HeaderParam{...}
ResponseParams []any
}
type SecurityDeclaration struct {
SchemeName string
Scheme route.SecurityScheme // e.g. route.BearerScheme("JWT")
Scopes []string
Codec *codex.Codec[string] // credential format validator
}
// Register-time-only, SERVER-side. Attached via an adapter's
// Register/Handler/SSEHandler/RegisterSSE's own variadic `impls` param.
// NEVER via Route.Use.
type ServerImplementation struct {
Name string
Satisfies []string // scheme names this Fn verifies; empty = general-purpose (always runs)
Fn any // func(http.Handler) http.Handler OR
// func(ctx, *http.Request, *Req) (map[string][]string, error)
}
// Register/build-time, CLIENT-side. Attached via Route.UseClient(...) or
// directly to Call/CallHandle/CallVia/CallHandleVia's own variadic.
type ClientMiddleware struct {
Name string
Fn any // func(ctx, []route.SecurityRequirement) (http.Header, error)
}
Constructors¶
// middleware package — declare-time, adapter-agnostic:
func SecurityScheme(schemeName string, scheme route.SecurityScheme,
scopes []string, codec *codex.Codec[string]) Middleware
// middleware package — register-time, adapter-agnostic core (Raw is the
// adapter's raw wire-request type, e.g. *http.Request):
func Scopes[Raw, Req any](schemeName string,
extract func(ctx context.Context, raw Raw, req *Req) (map[string][]string, error),
) ServerImplementation
// nethttp package — register-time, PINS Raw = *http.Request:
func Scopes[Req any](schemeName string,
extract func(ctx context.Context, r *http.Request, req *Req) (map[string][]string, error),
) middleware.ServerImplementation
// nethttp package — declare-time half of an API-key style header check:
func HeaderParam(headerName string) middleware.Middleware
// nethttp package — register-time half (general-purpose, empty Satisfies):
func APIKey[Req any](headerName string,
verify func(ctx context.Context, key string) error,
) middleware.ServerImplementation
// nethttp package — register-time, general-purpose (logging/metrics/tracing):
func Observability(obs stats.Observer) middleware.ServerImplementation
Authorization helper (called ONCE by the adapter, never per-Fn)¶
func CheckScopes(reqs []route.SecurityRequirement, granted map[string][]string) error
// -> UnsatisfiedScopesError{Requirements, Granted} on mismatch
Step 1 — Declare the route + security (server-authoring side)¶
declMw := middleware.SecurityScheme("bearerAuth", route.BearerScheme("JWT"),
[]string{"profile"}, &bearerCodec) // codec validates credential FORMAT
route := rest.NewRoute[GetProfileReq, ProfileResp]("GET", "/profile",
reqCodec, respCodec,
rest.RouteMeta{OperationID: "getProfile"},
).Use(declMw) // == rest.WithMiddleware(declMw) passed as a NewRoute opt
Route.Use / SSERoute.Use:
func (r Route[Req, Resp]) Use(mws ...middleware.Middleware) Route[Req, Resp]
func (s SSERoute[Req, Event]) Use(mws ...middleware.Middleware) SSERoute[Req, Event]
- Returns a NEW
Route(immutable, chainable:.Use(a).Use(b)≡.Use(a,b)). rest.WithMiddleware(mws...) RouteOptis the underlying primitive;.Useis chi-style sugar for it, usable afterNewRouteinstead of only inline.
At Use/WithMiddleware attachment time: NOTHING happens yet except
appending to an internal slice (rb.middlewares). All merging/validation
is deferred to ONE later pass.
Step 2 — Freeze the route: Register (server) or ClientHandle (client)¶
handle, err := route.Register(builder) // *rest.RouteHandle[Req, Resp], error
// OR, client-only, no spec needed:
handle := route.ClientHandle() // *rest.RouteHandle[Req, Resp]
Both run the same merge pass (Register calls
applyMiddlewareDeclarations directly; ClientHandle calls
applyMiddlewareSecurityForClient, a narrower variant — see the
divergence table below), which:
- Detects conflicting Security declarations for the SAME scheme name
(manual
RouteMeta.Security/WithSecuritySchemevs. middleware, AND middleware vs. middleware) →ConflictingSecurityDeclarationError. - Merges every middleware's
Securityinto ONE combined (AND)route.SecurityRequirement+ registers its scheme intoSecuritySchemes. - Detects conflicting
RequestParams/ResponseParamscontributions →ConflictingParamContributionError/ParamContributionShapeError. - Applies every middleware-contributed param entry not already manually declared.
Does NOT check that every declared scheme has an enforcing
implementation — that check is DEFERRED (see Step 3). A route that only
ever calls .Use(middleware.SecurityScheme(...)), with NO
ServerImplementation supplied ANYWHERE, legitimately PASSES
Register/ClientHandle — this is the by-design "declared but not yet
implemented" intermediate state (used for routes describing an EXTERNAL
system's API this codebase never serves itself, e.g.
examples/go-edge-models's Docker registry client).
RouteHandle[Req, Resp] — the fields relevant here¶
type RouteHandle[Req, Resp any] struct {
Descriptor route.Route // .Security lives here
SecuritySchemes map[string]SecurityScheme // populated by BOTH Register & ClientHandle
GlobalSecurity []route.SecurityRequirement // builder-level fallback; Register only, always nil on ClientHandle
Middlewares []middleware.Middleware // Register ONLY; nil on ClientHandle
ClientMiddlewares []middleware.ClientMiddleware // populated by BOTH
// ... Decode/Encode/EncodeRequest/DecodeResponse, params, merge fields, etc.
}
Register vs. ClientHandle divergence (exact)¶
| Field | Register(builder) |
ClientHandle() |
|---|---|---|
Descriptor.Security |
set from merged middleware + manual RouteMeta.Security |
SAME (via applyMiddlewareSecurityForClient) |
SecuritySchemes |
populated | populated (same mechanism) |
GlobalSecurity |
builder.globalSecurity clone |
always nil (no builder) |
Middlewares |
[]middleware.Middleware attached, cloned |
always nil — a client handle never carries server declarations, only used for the coverage-check server-side |
ClientMiddlewares |
populated | populated |
| conflict detection | full (ConflictingSecurityDeclarationError etc.) |
NONE — ClientHandle stays infallible (no error return); only the merge runs, not validation |
ValidateRoute[Req, Resp](meta RouteMeta, opts ...RouteOpt) error — a
dry-run of the IDENTICAL validation Register runs, without needing a
live *Server — same conflict errors, same "coverage not checked here"
rule.
Step 3 — Server: supply the implementation at Register/Handler time¶
implMw := nethttp.Scopes[GetProfileReq]("bearerAuth",
func(ctx context.Context, r *http.Request, req *GetProfileReq) (map[string][]string, error) {
// pure authentication: return granted scopes, however obtained
return map[string][]string{"bearerAuth": nil}, nil
})
err = nethttp.Register(mux, handle, getProfileFn, nethttp.Options{}, implMw)
// OR, for a bare http.Handler without eager validation:
h := nethttp.Handler(handle, getProfileFn, nethttp.Options{}, implMw)
Exact signatures (adapters/nethttp, adapters/chi mirrors identically)¶
func Handler[Req, Resp any](handle *rest.RouteHandle[Req, Resp],
fn HandlerFunc[Req, Resp], opts Options,
impls ...middleware.ServerImplementation) http.Handler
func Register[Req, Resp any](mux *http.ServeMux, handle *rest.RouteHandle[Req, Resp],
fn HandlerFunc[Req, Resp], opts Options,
impls ...middleware.ServerImplementation) error
func SSEHandler[Req, Event any](handle *rest.SSERouteHandle[Req, Event],
fn SSEHandlerFunc[Req, Event], opts Options,
impls ...middleware.ServerImplementation) http.HandlerFunc
func RegisterSSE[Req, Event any](mux *http.ServeMux, handle *rest.SSERouteHandle[Req, Event],
fn SSEHandlerFunc[Req, Event], opts Options,
impls ...middleware.ServerImplementation) error
Register/RegisterSSE do THREE things Handler/SSEHandler do NOT¶
validateImplementationShapes[Req](impls)— EAGERLY type-asserts everyimpl.Fnagainst the two recognized shapes;MiddlewareShapeErrorif neither matches.- Resolve
secReqs := handle.Descriptor.Security; if nil { secReqs = handle.GlobalSecurity }. rest.CheckCoverage(routeLabel, secReqs, impls)— every scheme NAMED insecReqsmust have someimpls[i]whoseSatisfiescontains it →MissingSecurityMiddlewareErrorif not. This is the coverage check deferred from Step 2.
Handler/SSEHandler (called directly, bypassing Register) do NEITHER
of these — a malformed Fn is silently skipped per-request instead of
failing at wiring time; a missing implementation is silently a no-op
security check (i.e. request passes through unauthenticated) rather than
a construction-time error. This asymmetry is PRE-EXISTING (shape
validation was Register-only even before Revision 2) and intentional — not
something Revision 2 changed.
Runtime dispatch inside Handler's returned closure (per-request)¶
func applyGeneralMiddleware(h http.Handler, impls []middleware.ServerImplementation) http.Handler
// wraps h with every impl.Fn matching func(http.Handler) http.Handler,
// OUTERMOST-in, in attachment order (impls[0] = outermost, runs first).
func runSecurityMiddleware[Req any](ctx, r *http.Request, req *Req,
impls []middleware.ServerImplementation, secReqs []route.SecurityRequirement) error
// runs every impl.Fn matching func(ctx, *http.Request, *Req) (map[string][]string, error)
// in ATTACHMENT order (fail-fast on the FIRST error), merges ALL grants
// into ONE map, then calls middleware.CheckScopes ONCE (never per-Fn).
Gating rule (the one genuinely tricky bit of the current design): an
implementation with Satisfies EMPTY runs UNCONDITIONALLY, regardless of
whether the route declares any Security at all — this is how
nethttp.APIKey/nethttp.Observability (general-purpose,
logging/rate-limiting/presence-checks) get expressed under the SAME type
as security-verifying implementations. An implementation with Satisfies
NON-EMPTY only runs when the route actually has a non-empty secReqs —
an unsecured route never invokes a scope-granting Fn.
Step 4 — Client side: fulfilling the same declared requirement¶
Path A — ambient default, declared once¶
credMw := middleware.ClientMiddleware{
Fn: func(ctx context.Context, reqs []route.SecurityRequirement) (http.Header, error) {
h := make(http.Header)
h.Set("Authorization", "Bearer "+token)
return h, nil
},
}
handle := route.Use(declMw).UseClient(credMw).ClientHandle()
func (r Route[Req, Resp]) UseClient(mws ...middleware.ClientMiddleware) Route[Req, Resp]
// rest.WithClientMiddleware(mws...) RouteOpt is the underlying primitive.
UseClient-attachedClientMiddlewares land inRouteHandle.ClientMiddlewares— populated by BOTHRegisterandClientHandle.- NEVER affects the spec (
ClientMiddlewarehas no Security/Param fields at all — structurally cannot).
Path B — one-off, per-call override (the escape hatch)¶
caller := nethttp.NewCaller(httpClient, baseURL) // built once
resp, err := nethttp.CallVia(ctx, caller, handle, req, vars, nethttp.CallOptions{}, credMw)
Exact client signatures¶
type CredentialFunc = func(ctx context.Context, reqs []route.SecurityRequirement) (http.Header, error)
// a type ALIAS naming the one Fn shape Call/CallHandle/CallVia/CallHandleVia recognize.
func Call[Req, Resp any](ctx context.Context, client *http.Client, baseURL string,
handle *rest.RouteHandle[Req, Resp], req Req, vars map[string]string,
opts CallOptions, mws ...middleware.ClientMiddleware) (Resp, error)
func CallHandle[Req, Resp any](ctx context.Context, client *http.Client, baseURL string,
handle *rest.RouteHandle[Req, Resp], req Req,
opts CallOptions, mws ...middleware.ClientMiddleware) (Resp, error)
// derives vars/QueryParams/HeaderParams/CookieParams from req automatically
// via merge fields; Call is the lower-level primitive with no merge fields.
type Caller struct { /* unexported client, baseURL, defaultMws */ }
func NewCaller(client *http.Client, baseURL string, defaultMws ...middleware.ClientMiddleware) *Caller
func CallVia[Req, Resp any](ctx context.Context, c *Caller,
handle *rest.RouteHandle[Req, Resp], req Req, vars map[string]string,
opts CallOptions, extraMws ...middleware.ClientMiddleware) (Resp, error)
func CallHandleVia[Req, Resp any](ctx context.Context, c *Caller,
handle *rest.RouteHandle[Req, Resp], req Req,
opts CallOptions, extraMws ...middleware.ClientMiddleware) (Resp, error)
Call's exact internal step order (the numbered comments in the code)¶
0. allMws := handle.ClientMiddlewares + mws (variadic) — validateClientMiddlewareShapes(allMws) EAGERLY
1. BuildPath(vars) → rest.PathParamError / MissingPathVarError
2. ValidateQuery(opts.QueryParams) → rest.QueryParamError
3. ValidateCookies(opts.CookieParams) → rest.CookieParamError
4. ValidateHeaders(opts.HeaderParams) → rest.HeaderParamError
5. build full URL (+ query string)
6. secReqs := handle.Descriptor.Security ?? handle.GlobalSecurity
if len(secReqs) > 0: mergeCredentialHeaders(ctx, secReqs, allMws)
— runs EVERY attached credential Fn, merges returned http.Header
— conflicting header values from two Fns → ConflictingCredentialHeaderError
7. encode request body (POST/PUT/PATCH only)
8. build *http.Request, set Content-Type/Accept, merge header params,
extra headers, credential headers, cookies
8b. validate outgoing credential FORMAT against handle.SecuritySchemes[name].Codec
(gated on len(credHeaders) > 0, not on secReqs alone)
→ rest.SecurityCredentialError, locally, before any network call
9. client.Do(...) → RequestError (transport failure)
10. decode response body → typed ErrorPatternResponse / UnexpectedStatusError / ResponseBodyError
CallVia/CallHandleVia do NOTHING but combine c.defaultMws +
extraMws (defaultMws first) then call Call/CallHandle with the
combined slice — literally mws := append(slices.Clone(c.defaultMws),
extraMws...). No new behavior, no new validation.
Every escape hatch that exists today (exhaustive)¶
- Manual security declaration —
rest.WithSecurityScheme(name, scheme)+rest.RouteMeta{Security: ...}set directly, bypassingmiddleware.SecuritySchemeentirely. Still fully supported, cross-checked for conflicts against any ALSO-attached middleware declaration (ConflictingSecurityDeclarationErrorif they disagree). middleware.SecuritySchemewith no matchingServerImplementationever supplied — a legitimate way to document an EXTERNAL system's security requirement (client-only usage) without server-side enforcement machinery. Register/ClientHandle allow it; onlynethttp.Register/RegisterSSE(notHandler/SSEHandler) reject it viaMissingSecurityMiddlewareError.Handler/SSEHandlercalled directly instead ofRegister/RegisterSSE— skips BOTH eager shape validation AND the coverage check. A malformedFnis silently ignored per-request; a missing implementation silently authenticates nothing. Pre-existing asymmetry, not new.ServerImplementation.Fnasany— type-erased, resolved via a runtime type-switch (validateImplementationShapes). A wrong-shapedFnfails withMiddlewareShapeError, but only atRegister/RegisterSSEtime (see #3).ClientMiddleware.Fnasany— same type-erasure/escape-hatch pattern client-side (validateClientMiddlewareShapes), checked eagerly insideCall/CallHandleitself (both entry points, unlike the server-side Handler/Register split — no separate "Call vs. eager-Call" distinction exists client-side).- Per-call
ClientMiddlewareoverride — passed directly toCall/CallHandle/CallVia/CallHandleVia's trailing variadic instead of (or in addition to)Route.UseClient's ambient default; used to test a different credential for one specific call. nethttp.Call/CallHandleused directly instead ofCaller/CallVia/CallHandleVia— needed byports.Pattern's REST binding machinery (owns its own client/baseURL viaPortOptions, noCaller), and for genuinely one-off calls or calls against a different client/baseURL than a persistentCaller.RequestParams/ResponseParamsas[]any— type-erased spec contributions, resolved viarequestParamInfo/responseParamInfo(onlyHeaderParam/CookieParam/QueryParamrecognized) →ParamContributionShapeErrorfor anything else.- Conflicting param contributions (two middlewares declaring the
same header/cookie/query name with different
kind/required) →ConflictingParamContributionError; IDENTICAL redundant declarations are silently allowed (deduped). - General-purpose
ServerImplementation(emptySatisfies) as the unified mechanism for logging/observability/rate-limiting/API-key presence checks — NOT security-verifying, always runs. No separate "non-security middleware" type exists; it's the SAMEServerImplementationstruct with an empty slice field as the only distinguishing signal. ValidateRoute— a dry-run validator usable with no live*Server, for pre-flight checking a route declaration's conflicts/shape before ever callingRegister.middleware.ContextField[V](not detailed above, exists separately) — a codec-typed ctx value bus for cross-middleware data sharing, an escape hatch for passing derived data between a security Fn and the business handler without a dedicated Req field.
Decision: whole-API declarative wiring (resolves Question 1, closes Question 2)¶
Status: IMPLEMENTED. This supersedes Question 1's narrow "combine 2
constructors" framing with a bigger, generalized redesign: the ROUTE
itself (still the SAME Route[Req, Resp] type throughout — no new type
introduced) accumulates its own business handler AND every attached
middleware's implementation as part of the SAME declarative chain that
already builds .Use(mw); Register becomes the single moment
everything (spec + handler + middleware implementations) is bound into
the Server; and a NEW builder-level adapter call performs the actual
transport wiring (mux.Handle(...)) for every accumulated route in one
shot.
The full workflow¶
mw := middleware.SecurityScheme("bearerAuth", scheme, scopes, codec)
route := rest.NewRoute[Req, Resp]("GET", "/profile", reqCodec, respCodec, meta).
Use(mw). // 1. declare middleware on route (spec only, no IO)
HandleMW(&mw, func(ctx context.Context, r *http.Request, req *Req) (map[string][]string, error) {
return extractScopes(ctx, r) // 2. attach an implementation to THAT middleware — &mw: HandleMW takes *middleware.Middleware (nilable)
}).
WithHandler(func(ctx context.Context, req Req) (Resp, error) { // 3. attach the route's own business handler
return getProfile(ctx, req)
})
err := route.Register(builder) // 4. binds spec + handler + middleware impls into builder — error only, no *RouteHandle returned
// ... repeat declare→Use→HandleMW→WithHandler→Register for every route in the API ...
mux := http.NewServeMux()
err = nethttp.Serve(mux, builder) // 5. walks EVERY accumulated route, performs mux.Handle(...) for each, in one call
http.ListenAndServe(":8080", mux) // 6. run
API surface (confirmed names/signatures)¶
// api/rest — Route stays the SAME generic type; two NEW chainable
// methods alongside the existing .Use()/.UseClient() — pure
// accumulation into the route's own opts list, no IO until Register.
// WithHandler attaches THIS route's own business handler.
func (r Route[Req, Resp]) WithHandler(fn func(ctx context.Context, req Req) (Resp, error)) Route[Req, Resp]
// HandleMW — the ONLY server-side implementation-attachment method
// (UNIFIED during a fourth critical review pass — see "Decision:
// HandleMW/ClientMW unification" below for the full rationale). mw is
// NILABLE:
// - non-nil AND mw.Security != nil: PAIRED — fn is matched against a
// PREVIOUSLY-.Use()'d security declaration, mw being the SAME
// middleware.Middleware value (not a re-typed string) — matched
// internally, closing the "typo risk" from Question 2 entirely:
// there is no independent re-declaration of the scheme name at the
// implement call site, mw IS the declaration.
// - nil (or mw.Security == nil): UNPAIRED, general-purpose — fn runs
// unconditionally, nothing to satisfy.
func (r Route[Req, Resp]) HandleMW(mw *middleware.Middleware, fn any) Route[Req, Resp]
// Register's signature changes — no more *RouteHandle returned to the
// caller. Its job is now to bind everything (spec, handler, middleware
// implementations) into the builder; a caller wiring routes through
// Serve doesn't need a per-route handle anymore — the NEXT step
// operates on the whole builder.
func (r Route[Req, Resp]) Register(b *Server) error
// adapters/nethttp (adapters/chi mirrors identically) — NEW,
// builder-level. Walks every route the builder has accumulated (each
// already carrying its own handler + middleware implementations, bound
// at Register time above) and performs the actual mux.Handle(...) call
// for each — the ONLY place literal transport wiring happens.
func Serve(mux *http.ServeMux, b *rest.Server) error
RESOLVED — a dedicated RegisterHandle for direct-wiring callers (ports), found during implementation planning¶
Status: IMPLEMENTED. Planning this doc's
actual implementation surfaced a REAL, code-verified conflict: Register's
"no more *RouteHandle returned... nothing downstream consumes one
directly" claim above is FALSE. ports/handle.go directly calls
route.Register(b) (both its roleSource/REST-ingest and
roleSink/SSE branches) and stores the RETURNED handle for adapter
binding (nethttp.IngestAdapter/SubscribeAdapter-equivalent machinery
consumes it directly, bypassing Serve entirely — ports never mounts
routes on a *http.ServeMux itself). This is a real, current caller, not
a hypothetical — the claim above was simply wrong.
Considered and rejected: (a) reverting Register to keep returning a
handle — reintroduces the exact ambiguity Decision 4 removed ("does the
caller need to also call Serve, or is the handle enough on its own?");
(b) having ports route through Serve somehow — ports doesn't own a
*http.ServeMux/router at all, this doesn't fit its shape.
Resolved: Route.Register is for building the API (the Serve path)
— ports-style direct wiring gets its OWN, separate method that shares
Register's validation but ALSO returns the handle:
// api/rest — ports-facing (and any other direct-wiring caller that
// bypasses Serve/ServeSSE entirely). Does IDENTICAL spec/handler/impl
// binding + validation as Register — shares the same internal
// implementation — but ALSO returns the handle, for callers that need
// to wire an adapter directly instead of going through Serve.
func (r Route[Req, Resp]) RegisterHandle(b *Server) (*RouteHandle[Req, Resp], error)
func (s SSERoute[Req, Event]) RegisterHandle(b *Server) (*SSERouteHandle[Req, Event], error)
ports/handle.go's two call sites (roleSource/roleSink) switch from
route.Register(b) to route.RegisterHandle(b) — a one-line rename
each, zero other changes needed; every other ports behavior (spec
accumulation, security scheme population, path/topic codec validation)
is unaffected since RegisterHandle performs the SAME work Register
always did, just also handing back the handle.
Mechanics¶
.Use()/.HandleMW()/.WithHandler()are pure accumulation — same discipline as today's.Use(): append to an internal opts slice, return a newRoutevalue, no IO performed yet.HandleMW'sfn anymirrorsServerImplementation.Fn's existing type-erasure discipline — no generics needed at the call site; the closure literal's own concrete type is inferred by Go directly. The adapter still validates the shape atRegister/Servetime via the same type-switch mechanism (MiddlewareShapeErroron mismatch).Route.Register(b *Server)'s wiring (binding handler+impls to the route) happens immediately, per-route, at theRegistercall — not deferred toServe.Serveonly performs the literalmux.Handle(...)registration, walking whatRegisteralready bound.- The Server becomes both the OpenAPI-spec accumulator (unchanged,
existing role) AND the handler/implementation registry — extending the
SAME accumulation discipline
Serveralready has for security schemes/global security to also cover handlers and middlewareFns, keyed per route. Servergains an internalsync.RWMutex, making concurrentRegistercalls safe — closed during a final critical review pass. Confirmed via code:Server's entire mutation surface is THREE call sites (b.entries = append(...)×2, one perRoute.Register/SSERoute.Register;b.schemas[name] = s, schema registration) — everything else (OpenAPISpec, and the newServe/ServeSSE/ServeOne) only READSb.entries/b.schemasby ranging over them. The fix is small and mechanical: onemu sync.RWMutexfield; the 3 write sites wrapped in.Lock()/.Unlock(); read-only iteration methods wrapped in.RLock()/.RUnlock(). No recursive/nestedServer-method calls exist anywhereRegisterdoesn't already return before the next one starts — zero deadlock risk to reason about.
Explicit boundary — what this DOES and does NOT buy: this
supports an app that fans Register calls out across GOROUTINES (one
per feature-module, say) before a SINGLE later Serve call — multiple
goroutines concurrently building up the SAME route set safely. It does
NOT support hot-adding routes to an ALREADY-Serve'd, ALREADY-running
mux — that is a fundamentally different feature (dynamic route
rebinding on a live server), already tracked separately and explicitly
flagged as an acknowledged, not-yet-designed gap in
Dynamic Port Rebinding ("REST/events/
reqreply's immutable RouteHandle/ChannelHandle middleware hot-swap
remains an acknowledged gap in both docs, not yet designed"). This
synchronized Server is a deliberate FIRST STEP toward that direction
— concurrent-safe accumulation — not a claim that hot-reload itself is
solved; a future round extending toward live rebinding would build on
this foundation rather than starting from an unsynchronized Server.
- Scope: designed generically so api/events/api/reqreply/api/mcp
could adopt the identical pattern later (Channel.WithHandler/
Channel.HandleMW/Channel.Register(builder) error +
mqtt5.Serve(client, builder)/mcpgo.Serve(server, builder), etc.) —
only REST is actually being redesigned now; api/mcp/ports remain
Phase 2+, tracked in MCP and Ports Declarative
Middleware.
HandleMW-to-.Use() pairing validation — RESOLVED, refined across THREE successive critical review passes¶
Pass 1 — the original finding. HandleMW(mw, fn) reads
mw.Security.SchemeName directly off whatever mw VALUE is passed to
IT — nothing inherently stops a caller from passing a mw that was
never .Use()'d on the SAME route at all (e.g. a copy-paste mistake,
reusing a DIFFERENT route's mw by accident). Such a mismatched
implementation would still RUN (the general-purpose/security gating
rule only checks secReqs is non-empty overall, not per-scheme),
silently contributing an irrelevant grant, while CheckCoverage would
separately (and correctly, but confusingly) still flag the ACTUAL
declared scheme as uncovered.
Considered generalizing chain-method validation broadly (a "sticky
error" pattern — an internal err error field on Route, every chain
method no-ops once set, surfaced at .Register()) and REJECTED for this
specific check: an immediate, at-call-time check using only what's
accumulated SO FAR would introduce a NEW call-ORDER dependency
(.HandleMW(mw, fn) before a LATER .Use(mw) would be incorrectly
flagged, even though the fully-assembled route is valid) — contradicting
WithMiddleware's existing, explicitly-documented ORDER-INDEPENDENT
merge semantics (Register's single merge pass already processes the
COMPLETE, final opts list regardless of attachment order).
Pass 2 — simplified into a reverse-Satisfies check. Resolved by
extending Register's EXISTING merge pass — the same pass that already
detects ConflictingSecurityDeclarationError/param conflicts — with a
SIMPLER, more robust mechanism than originally drafted: rather than
tracking WHICH mw value was passed to WHICH specific .HandleMW()
call (bookkeeping that would need a new, per-route data structure), the
check filters the route's FINAL, fully-accumulated impls list for
entries with NON-EMPTY Satisfies, and verifies EACH ONE corresponds to
an actually-.Use()'d Security scheme name ON THE SAME ROUTE — a
REVERSE-direction sibling to the EXISTING CheckCoverage (which checks
"every DECLARED scheme has a covering implementation"; this checks
"every IMPLEMENTED scheme was actually declared"). Needs ZERO new
per-route bookkeeping — it cross-checks two lists (.Use()'d Security
declarations, accumulated impls) that already exist in their FINAL,
fully-merged form by the time Register's pass runs, order-independent
exactly like every other check in this pass.
A NEW error (UnknownMiddlewareImplementationError or similar — exact
name TBD at implementation time) is returned by Register(b *Server)
error itself — surfacing PER-ROUTE, at Register time, which is far
EARLIER than Serve (batched, potentially much later/farther away in
main.go). Zero chain-method signature changes, zero order-dependency
introduced, zero ergonomics cost — this reuses Register's pre-existing
role as "the one place a route's own internal consistency is checked,"
exactly like every other cross-check in this design already does. This
check — and every other check Register's merge pass already performs
— applies IDENTICALLY to SSERoute.Register (see "Decision: SSERoute's
full chainable method set" below).
Pass 3 — mw becomes nilable, and this check STAYS unaffected, cleanly.
Investigating this check's exact mechanics for implementation-readiness
surfaced a related, previously-undocumented risk: NEITHER HandleMW NOR
ClientMW guarded against mw.Security itself being nil (e.g. a
general-purpose Middleware built via middleware.HeaderParam(...)
mistakenly passed to HandleMW) — a genuine nil-pointer-dereference
PANIC waiting to happen, not a graceful error. Investigating the fix led
to a bigger, better redesign — see "Decision: HandleMW/ClientMW
unification" below — where mw becomes a NILABLE *middleware.Middleware,
and passing nil (or a non-nil mw with Security == nil) becomes the
SANCTIONED signal for "general-purpose, nothing to pair against,"
replacing .Implement()/Wrap entirely rather than needing a new
misuse-detecting error at all.
This reverse-Satisfies check (Pass 2) needs NO changes under Pass 3's
unification — it only ever inspects entries with NON-EMPTY Satisfies,
which by construction can ONLY arise from a HandleMW(mw, fn) call where
mw is non-nil AND mw.Security != nil (the paired case). Every
general-purpose implementation (attached via HandleMW(nil, fn) — a
raw func(http.Handler) http.Handler closure, Transform's output, or
Observability's output all pass through this SAME nil-mw path)
always carries EMPTY Satisfies and is correctly, automatically exempt
from this check — nil mw was never the misuse case this check exists
to catch; a non-nil mw whose scheme was never actually .Use()'d is.
Follow-on questions this raises¶
What happens to the EXISTING per-routeRESOLVED — see "Decision:nethttp.Register/Handler?Serveis the only public server-side entry point" below. Both are removed entirely, no escape hatch retained.- RESOLVED —
Optionsbecomes per-route, via a new.WithOptions(opts any) Route[Req, Resp]chainable method. Confirmed against real usage:examples/adapters-nethttp/main.goalready uses DIFFERENTErrorHandlers for different routes on the SAME server — per-route customization is a real, currently-used pattern, not hypothetical..WithOptions()fits the same "declare everything on the route beforeRegister" pattern as.WithHandler()/.HandleMW()/.ClientMW()— defaults to zero-valueOptionsif never called, stored in theServeralongside the handler/impls.Serve(mux, builder) errortakes NOOptionsparameter at all — it uses whatever each route declared.optsis type-erased (any), notnethttp.Optionsdirectly — found during implementation planning.api/restcannot importadapters/nethttp(nethttp already importsapi/rest— that would be a cycle).WithOptionsstoresoptstype-erased, exactly likeRequestFormats/HandleMW'sfn/RequestParamsalready are;nethttp.Serve/chi.Servetype-assert it to their ownOptionstype at wiring time, returning a newOptionsShapeError(mirroring the existingFormatOptErrorpattern inbuilder.go) on mismatch. Every earlier pseudocode signature in this doc showingWithOptions(opts Options)means this type-erasedanyform, not a literal cross-packageOptionsreference. DoesRESOLVED — see "Decision:SSERouteget.WithHandler()/.HandleMW(), and doesServehandle bothRouteandSSERoute?Serveis the only public server-side entry point" below. SSE gets its OWNServeSSE(mux, builder) error, not folded intoServe.ClientHandle()is UNCHANGED byWithHandler/HandleMW/Register/Serve(a client-only route never touches those four) — see "Decision: symmetric client-side declarative wiring" below for what a client-only route DOES now attach (.ClientMW()) and howClientHandle()'s role shifts from "the user's own explicit step" to "an internal building block the newCallcalls for you."- RESOLVED —
Server.OpenAPISpec()stays UNAFFECTED. It reads only spec state (Descriptor,SecuritySchemes,GlobalSecurity, etc.) that existed before this redesign and is populated byRegisterexactly as today; the new handler/impl bindingsRegisterALSO now stores are simply never read byOpenAPISpec()— no construction changes anything about spec generation. - RESOLVED —
chi's mirror ischi.Serve(router gochi.Router, b *rest.Server) error(andchi.ServeSSE), identical shape tonethttp.Serve/ServeSSE— consistent with every other decision in this doc, wherechimirrorsnethttp's redesigned surface exactly (HandleMW/WithHandler/WithOptions/ClientMWare all declared once onrest.Route, adapter-agnostic; only the terminalServe/ServeSSE/ServeOnefunctions are adapter-specific, one pair per adapter).
Decision: symmetric client-side declarative wiring (resolves Question 3, closes Question 5)¶
Status: IMPLEMENTED. This mirrors "Decision: whole-API declarative wiring"
above onto the CLIENT side, closing the discoverability gap Question 3
raised. It turned out to also fully resolve Question 5 (four client-side
entry-point functions) as a side effect — the whole Call/CallHandle/
CallVia/CallHandleVia family collapses into ONE function.
The three-layer problem this replaces¶
Before this decision, client-side credential middleware could be attached at THREE separate, independently-combinable layers, with no single obvious default for a new user to reach for:
route.UseClient(mw)— per-route ambient defaultnethttp.NewCaller(client, baseURL, defaultMws...)— per-Caller ambient default, applied to every route called through itCallVia(..., extraMws...)— per-call override
All three combined at Call's own merge step (handle.ClientMiddlewares
then Caller.defaultMws then CallVia's own variadic), and nothing
prevented attaching the same or CONFLICTING credentials at more than one
layer simultaneously (caught only at runtime, via
ConflictingCredentialHeaderError).
The full symmetric workflow¶
// Shared declaration — identical either way
mw := middleware.SecurityScheme("bearerAuth", scheme, scopes, codec)
route := rest.NewRoute[Req, Resp]("GET", "/profile", reqCodec, respCodec, meta).Use(mw)
// SERVER branch (see "Decision: whole-API declarative wiring" above):
err := route.HandleMW(&mw, extractFn).WithHandler(businessFn).Register(builder)
err = nethttp.Serve(mux, builder)
// CLIENT branch (this decision):
clientRoute := route.ClientMW(&mw, credentialFn) // client-side fulfillment — DISTINCT method from HandleMW; &mw: ClientMW takes *middleware.Middleware (nilable)
caller := nethttp.NewCaller(httpClient, baseURL) // now JUST a client+baseURL holder
resp, err := nethttp.Call(ctx, caller, clientRoute, req, opts) // ONE function, takes Route directly
API surface (confirmed names/signatures)¶
// api/rest — a NEW chainable method, DISTINCT from HandleMW (deliberately
// NOT reused — a reader should be able to tell server-verification from
// client-fulfillment apart at the call site without inspecting fn's
// closure signature). Pure accumulation, same discipline as .Use()/
// .HandleMW()/.WithHandler() — no IO until Call is actually invoked.
//
// fn is `any` — the ONE shape nethttp.Call recognizes is
// func(ctx context.Context, reqs []route.SecurityRequirement) (http.Header, error)
// (the former CredentialFunc/ClientMiddleware.Fn shape), validated
// EAGERLY inside Call, same MiddlewareShapeError discipline as today.
// Internally builds a middleware.ClientImplementation (see "Client-side
// Satisfies-gated implementations" below) — the TRUE, EXPLICIT successor
// to the removed middleware.ClientMiddleware, mirroring
// ServerImplementation's shape. mw is NILABLE — mirrors HandleMW's own
// unification (see "Decision: HandleMW/ClientMW unification" below):
// non-nil + Security != nil PAIRS against a declared scheme; nil (or
// Security == nil) is a general-purpose client implementation, ALWAYS
// runs, nothing to pair against.
func (r Route[Req, Resp]) ClientMW(mw *middleware.Middleware, fn any) Route[Req, Resp]
// adapters/nethttp — Caller is now JUST a client/baseURL holder, no
// defaultMws field at all (credential fulfillment moved entirely onto
// the route via ClientMW).
type Caller struct { /* unexported client, baseURL */ }
func NewCaller(client *http.Client, baseURL string) *Caller
// THE single client-side entry point — REPLACES Call, CallHandle,
// CallVia, AND CallHandleVia entirely. Takes the ROUTE directly (not a
// pre-built *RouteHandle) — builds the handle internally via
// route.ClientHandle(), invisibly; the user never calls ClientHandle()
// themselves anymore. ALWAYS auto-derives path/query/header/cookie
// values from the route's declared merge fields (folding in what
// CallHandle used to do exclusively) — there is no manual-vars variant
// anymore.
func Call[Req, Resp any](ctx context.Context, c *Caller,
route rest.Route[Req, Resp], req Req, opts CallOptions) (Resp, error)
What disappears entirely¶
middleware.ClientMiddleware(the standalone type — REPLACED bymiddleware.ClientImplementation{Name, Satisfies, Fn}, see "Client-sideSatisfies-gated implementations" below; not removed with nothing in its place)Route.UseClient/rest.WithClientMiddleware- The old
Call(ctx, client, baseURL, handle, req, vars, opts, mws...) CallHandle(ctx, client, baseURL, handle, req, opts, mws...)CallVia(ctx, c, handle, req, vars, opts, extraMws...)CallHandleVia(ctx, c, handle, req, opts, extraMws...)Caller.defaultMws(Caller keeps only client + baseURL)- The per-call credential override escape hatch (see "confirmed tradeoffs" below — deliberately NOT kept)
What stays, with a narrowed role¶
Route.ClientHandle() *RouteHandle[Req, Resp]— stays EXPORTED (used internally by the newCall, and by other consumers likeportsthat need a*RouteHandledirectly without dialing HTTP) — but a typical user callingnethttp.Callnever invokes it themselves anymore.
Confirmed tradeoffs (deliberately accepted, not oversights)¶
- No more per-call credential override. Since
ClientMWdeclares fulfillment on the route itself (mirroring the server side's declare-then-register discipline exactly), there is no remaining slot for "use a different credential just for this one call" —Calltakes nomws/extraMwsvariadic at all. A caller needing a genuinely different credential for one specific call must build a DIFFERENTRoutevalue (via a fresh.ClientMW(mw, differentFn)) rather than override at the call site. - No manual-vars fallback.
Callalways auto-derives path/query/ header/cookie values from the route's declared merge fields — a route with a path template variable but NO correspondingrest.NewPathParammerge field declared will fail with a path-build error at call time. Every route intended for client use must declare merge fields for every path/query/header/cookie value it needs — there is no rawvars map[string]stringescape hatch anymore.
Client-side Satisfies-gated implementations — RESOLVED, closed during a second final critical review pass¶
ClientMW(mw, fn) had the EXACT same mw-mismatch ambiguity as
HandleMW(mw, fn) (see "HandleMW-to-.Use() pairing validation"
above) — a copy-paste mistake could attach a DIFFERENT route's mw to
this route's ClientMW. Unlike the server side, this had NO direct fix
available: Call runs EVERY attached credential Fn UNCONDITIONALLY
whenever secReqs is non-empty at all (mergeCredentialHeaders has no
per-scheme filtering) — there was no Satisfies concept on client-side
implementations to check against in the first place, since the OLD
middleware.ClientMiddleware{Name, Fn} (and the informal representation
ClientMW produced in its place) never carried one.
Resolved by giving client-side implementations a Satisfies concept
too, for the SAME reason server-side has one: user-facing consistency.
// middleware package — the TRUE, EXPLICIT successor to the removed
// middleware.ClientMiddleware, mirroring ServerImplementation's shape
// field-for-field.
type ClientImplementation struct {
Name string
Satisfies []string // scheme name(s) this Fn supplies a credential for; empty = general-purpose, ALWAYS runs
Fn any // func(ctx, []route.SecurityRequirement) (http.Header, error)
}
ClientMW(mw, fn) derives Satisfies from mw.Security.SchemeName
internally (identical mechanism to HandleMW) WHEN mw is non-nil AND
mw.Security != nil; producing a ClientImplementation, accumulated
into the route exactly like HandleMW's output is. Since mw is
NILABLE (see "Decision: HandleMW/ClientMW unification" below), nil
(or a non-nil mw with Security == nil) is the SANCTIONED signal for
"general-purpose, nothing to pair against" — Satisfies is simply
empty in that case, not an error.
This is a genuine, deliberate BEHAVIOR CHANGE from today's
mergeCredentialHeaders, not just a validation-only addition — Call
now GATES which attached implementations run, mirroring
runSecurityMiddleware's exact gating rule: an implementation with
EMPTY Satisfies (e.g. a general-purpose tracing-header injector,
unrelated to any credential) ALWAYS runs; one with NON-EMPTY Satisfies
only runs if AT LEAST ONE of its schemes appears in the route's declared
secReqs. This is a CORRECTNESS improvement, not just consistency for
its own sake: it directly, structurally PREVENTS Finding C's original
concern rather than merely detecting it after the fact — a
mismatched/mis-attached ClientMW for an irrelevant scheme simply never
fires at all, contributing nothing, wasting no credential fetch, risking
no spurious ConflictingCredentialHeaderError. Confirmed harmless for
every legitimate use case: an AND-combined requirement ({bearerAuth AND
apiKey}) still needs and runs BOTH; an OR-combined requirement
([{bearerAuth}, {apiKey}]) already tolerates either or both credentials
being sent unconditionally today, so gating changes nothing there
either — it ONLY changes behavior for the misuse case this exists to
catch.
Reverse-check parity, mirroring Finding A, checked INSIDE Call
itself (there is no client-side Register-equivalent batched step to
defer to — consistent with the ALREADY-established "Call is where
client-side validation happens, mirroring Serve's role" resolution):
every attached ClientImplementation with NON-EMPTY Satisfies must
have at least one scheme appearing in the route's declared secReqs,
or Call returns an error — the reverse-direction sibling to the
gating rule above, same relationship CheckCoverage/Finding A's check
have server-side.
Deliberately NOT extended to a coverage-style requirement ("every
declared scheme must have SOME attached ClientMW") — this would
contradict the ALREADY-established, deliberate "a nil/absent credential
on a secured route is not an error" precedent (a route can legitimately
be called with no credential attached at all, e.g. to exercise the 401
path, or because auth is optional in some deployment). This asymmetry
from the server side (which DOES require coverage) is intentional, not
an oversight — the client never enforces its own authorization, only
the server does.
RESOLVED (was previously flagged as an open question in an earlier
round): does the client side need its own .Implement()-equivalent for
a TRULY declare-time-content-free client implementation (e.g. a
tracing-header injector with no mw/Security pairing at all)? YES —
and the HandleMW/ClientMW unification (below) answers it directly:
ClientMW(nil, fn) IS that equivalent. No separate method needed; this
was the SAME underlying gap .Implement()'s removal closes server-side,
just not yet connected to this question when it was first raised.
Follow-on notes (all design questions resolved; one implementation heads-up remains)¶
DoesRESOLVED — see "Decision: unexported handle-based primitive" below.portsneed to be rewritten against the new singleCall?ports'binding.goadapters keep their EXACT current construction pattern (build/accept a*rest.RouteHandleonce, reuse across many calls) and call an unexportedcallWithHandledirectly — zero rewrite needed beyond the call-site rename, sincebinding.goandclient.goare confirmed to be the same Go package.- RESOLVED —
adapters/chigets NO client-side story at all, now confirmed as a permanent design decision, not an accident. Confirmed via repo-wide search:chihas ZERO client-side functions today, and the only mentions ofchi.Call/chi.Calleranywhere are in this doc's own hypothetical question.chiwrapsgo-chi/chi/v5purely as a SERVER-side router (chosen for its own performance/routing features over plainnet/http) — an HTTP client call has nothing to do with which router served the request on the other end.adapters/nethttp'sCall/Callerremain the ONLY client-side entry point, regardless of whether the target server was built withchi, plainnet/http, or anything else. - RESOLVED —
.ClientMW()stays PURE ACCUMULATION, no validation at attachment time;middleware.MiddlewareShapeErrorvalidation forClientMW'sfnhappens INSIDECallitself. Consistent with the server side:.HandleMW()/.WithHandler()are also pure accumulation, with all validation deferred toServe— the one place with full visibility. The client side has no equivalent whole-API gathering step (eachCallis a standalone, one-route operation), soCallitself is the only place validation CAN happen, mirroringServe's role exactly. This also preserves the fluent chaining style — every declare-time method (.Use(),.HandleMW(),.WithHandler(),.WithOptions(),.ClientMW()) returns justRoute[Req, Resp], never(Route[Req, Resp], error)— adding eager validation to.ClientMW()alone would have been the one inconsistent exception. examples/adapters-nethttp-client(the example most affected — see its own extensiveCall/CallHandle/Callerdemo sections) will need a full rewrite once this ships; not a design question, just a heads-up for the implementation phase.
Decision: Serve is the only public server-side entry point (resolves Question 4)¶
Superseded by Addendum 5 below. This section remains accurate as a HISTORICAL record of the intermediate state it describes (
Serve/ServeSSEas the sole public entry points, both still exported and wire-only) — a LATER step unexportedServe/ServeSSEtoserve/serveSSE, makingnethttp.AttachMux/chi.AttachRouter(Addendum 5) the TRUE sole public server-side entry point today.ServeOneremains a confirmed public exception throughout.
Status: FULLY IMPLEMENTED (at the time; see the superseded-by note
above for the subsequent step). Serve(mux, b) error/ServeSSE(mux,
b) error are the SOLE public server-side entry points in both
adapters/nethttp and adapters/chi — the OLD Handler/Register/
SSEHandler/RegisterSSE per-route functions have been DELETED
entirely (their internal implementations survive as unexported
handlerFunc/sseHandlerFunc, reused by Serve/ServeSSE's reflect
dispatch AND by HandlerLatest/PipelineHandler/SSEAdapter in
stream.go/binding.go). This closes Question 4 by removing the
asymmetric choice entirely — mirroring exactly how "Decision: symmetric
client-side declarative wiring" made Call the only client-side door.
Closing this required first fixing two genuine gaps discovered mid-migration
in Serve/ServeSSE's reflect-based dispatch (documented in "Decision:
Serve's generic dispatch mechanism" below): request/response Formats
content negotiation (including streaming formats) via
WithRequestFormats/WithFormats, and full ErrorPattern response
building (typed payload + response header/cookie merge) via
ErrorResponseFor on the handler-error path. Both are now fully
implemented in buildRouteHandler's reflect dispatch (identically in
adapters/nethttp/serve.go and adapters/chi/serve.go) — so removing
the old doors causes ZERO capability regression.
A related structural case was also resolved: examples/sensor-service
declares routes as package-level vars via RegisterHandle BEFORE any
handler exists (the real handler needs a runtime-constructed store).
RouteHandle.WithHandler/SSERouteHandle.WithHandler were added as
Route.WithHandler's post-registration equivalents (mirroring the
existing WithFormats/WithRequestFormats post-registration pattern) —
attach the handler once the runtime dependency exists, any time before
Serve/ServeSSE runs.
The asymmetry this replaced¶
Handler/SSEHandler (bare http.Handler, no error) skipped BOTH the
eager shape-validation (validateImplementationShapes) AND the coverage
check (rest.CheckCoverage) that Register/RegisterSSE (return
error) performed. Reaching for "just give me an http.Handler" was a
silent opt-out of both safety checks — a sharp edge for anyone who didn't
realize the two pairs behaved differently. Serve/ServeOne/ServeSSE
run BOTH checks unconditionally — there is no lower-safety door anymore.
Confirmed: no capability actually lost for the two concerns raised¶
- "Compose with a non-go-codex middleware before mounting" — NOT
lost.
HandleMW's general-purpose shape (func(http.Handler) http.Handler, emptySatisfies— the same shapenethttp.Observabilityalready uses) lets ANY external middleware (otel, custom logging, whatever) wrap a route's handler; it flows throughServeautomatically as just another attached implementation. - "A route not part of any
Server" (ad hoc handler, mounted on a DIFFERENT router such as gorilla/mux) — CONFIRMED DROPPED. Every route intended to actually serve traffic must go throughRegister(builder) Serve(mux, builder)(orServeOnefor a single route). There is no more standalone "give me a bare handler for a route with no Server" path.
SSE stays a separate function¶
Serve(mux, builder) error handles ONLY Route entries. SSE gets its
own ServeSSE(mux, builder) error (not collapsed into one dispatching
function) — SSE's distinct streaming lifecycle (long-lived connection,
send func(Event) error callback shape, no request body) stays cleanly
separated from the regular request/response Route path.
What was removed¶
nethttp.Handler[Req, Resp any](handle, fn, opts, impls...) http.Handlernethttp.Register[Req, Resp any](mux, handle, fn, opts, impls...) errornethttp.SSEHandler[Req, Event any](handle, fn, opts, impls...) http.Handlernethttp.RegisterSSE[Req, Event any](mux, handle, fn, opts, impls...) error- (all four had a
chimirror — removed identically there) - All ~128 existing test call sites across both packages were migrated
onto
ServeOne/Serve/ServeSSE; every example inexamples/that used the old doors was migrated to the same shape (7 examples plussensor-service's runtime-attached-handler case).
What replaces them¶
func Serve(mux *http.ServeMux, b *rest.Server) error // Route entries
func ServeOne[Req, Resp any](r rest.Route[Req, Resp]) (http.Handler, error) // single-route sugar
func ServeSSE(mux *http.ServeMux, b *rest.Server) error // SSERoute entries
ServeOne, not a new
mechanism. Confirmed the scale of this need: adapters/nethttp's
OWN test suite (adapter_test.go) has 66 direct calls to
nethttp.Handler(...) for isolated single-route testing — this is
foundational, not hypothetical.
// adapters/nethttp — pure sugar. Implemented as LITERALLY "build a
// scratch, single-route Server, register route into it, call Serve,
// return the resulting mux" — reuses Serve's exact validation path,
// zero bypass, zero new mechanism. Still consistent with "Serve is the
// only door" (Decision 4) — this is a convenience wrapper AROUND that
// one door, not a second door.
//
// Takes NO Options parameter — fully consistent with "Options becomes
// per-route via .WithOptions()" (see this decision's own follow-on
// notes above): a caller wanting custom Options for a single-route
// test calls route.WithOptions(opts) FIRST, then ServeOne(route). An
// EARLIER draft of this signature also took an `opts Options`
// parameter directly — a leftover inconsistency, caught during a
// final critical review pass and corrected here: `Serve` takes no
// Options, so `ServeOne` ("literally Serve on a scratch single-route
// builder") cannot either, without a special case this doc's own
// "fewer doors" discipline argues against.
func ServeOne[Req, Resp any](route rest.Route[Req, Resp]) (http.Handler, error)
A test (or any caller) wanting a bare http.Handler for exactly one
route calls ServeOne instead of manually spinning up a scratch
Server/mux/Serve sequence by hand — but the underlying mechanism
is IDENTICAL either way; ServeOne is not a bypass.
- RESOLVED — Server does NOT need a route-lookup-by-label
mechanism. ServeOne's scratch builder contains EXACTLY the one
route passed to it — it never searches an existing, larger Server
at all, so there is no need for Server to support looking up one
route among many by label/operation-ID. Server stays a write-only
accumulator, walked in full only by Serve/ServeSSE/ServeOne.
Decision: Serve's generic dispatch mechanism — reflect, isolated to nethttp/chi, found and resolved during implementation¶
Status: IMPLEMENTED. Implementation planning
found a genuine Go-generics feasibility gap in the decision above:
Serve(mux, b) error is non-generic and walks a HETEROGENEOUS
collection (b's accumulated routes, each with a DIFFERENT Req/Resp
pair) — but building an http.Handler for any one of them requires
calling code that is Req/Resp-typed (decode body into Req, call the
business handler, encode Resp). Go has no mechanism to instantiate a
generic function (Handler[Req, Resp]) with type parameters known only
at runtime — every alternative considered (erased closures captured at
register time, callback interfaces, registered-builder patterns) still
requires knowing Req/Resp at a point that doesn't have them.
Resolved: reflect.Value.Call, used ENTIRELY inside nethttp/chi's
own Serve/ServeSSE, never in api/rest. The key fact that makes
this tractable: Go cannot reflectively INSTANTIATE a generic function,
but it CAN reflectively CALL an already-concrete function VALUE
(reflect.Value.Call), matching arguments/returns by their DYNAMIC
types — regardless of whether the caller's own code knows those types
statically. Every closure Serve needs (RouteHandle.Decode, .Encode,
.HandlerFn, each middleware.ServerImplementation.Fn) is ALREADY a
concrete function value stored in an EXPORTED field — reflect.Value.Call
invokes each one directly, with ZERO logic moved out of
nethttp.Handler/chi.Handler (which stay exactly where they are,
unchanged) and ZERO new net/http/reflect dependency in api/rest.
// api/rest — sealed exported interfaces (unexported marker method
// prevents any OTHER package from implementing them, while still
// letting nethttp/chi range over them) distinguishing Route entries
// from SSERoute entries cleanly in Server's single internal list.
type RouteEntry interface {
Method() string
Path() string
HasHandler() bool
Handle() any // *RouteHandle[Req, Resp], Req/Resp erased
isRouteEntry()
}
type SSERouteEntry interface {
Method() string // always "GET"
Path() string
HasHandler() bool
Handle() any // *SSERouteHandle[Req, Event], erased
isSSERouteEntry()
}
func (b *Server) RouteEntries() []RouteEntry // read-only, RLock-guarded
func (b *Server) SSEEntries() []SSERouteEntry // read-only, RLock-guarded
nethttp.Serve/ServeSSE (per handler-bearing entry): dereference
reflect.ValueOf(e.Handle()).Elem(), then reflect-call Decode (body →
Req), the ALREADY non-generic ValidatePathParams/ValidateQuery/etc.
methods NORMALLY (no reflect needed — their signatures never named
Req/Resp), reflect-call the Req-typed merge step, dispatch general-purpose
implementations via an ORDINARY type assertion (func(http.Handler)
http.Handler has nothing Req-specific in it), dispatch security-paired
implementations via reflect.Value.Call (matches impl.Fn's concrete
func(context.Context, *http.Request, *Req) (...) shape by dynamic
type), reflect-call HandlerFn, reflect-call Encode, write the
response — the SAME pipeline Handler[Req,Resp] already runs today,
just invoked via reflect.Value.Call instead of static generic calls.
ServeSSE mirrors this for *SSERouteHandle[Req,Event]'s
EncodeEvent/ValidateEvent/send func(Event) error shape.
Confirmed acceptable tradeoff. Real per-request reflect.Value.Call
overhead, and a mismatched Fn shape becomes a runtime panic (caught by
Part 3's existing recover() safety net) rather than a compile error —
but validateImplementationShapesReflect ALREADY eagerly type-switch-checks
every Fn at Serve-call time (before any request arrives), so this is
caught at wiring time, not silently at first-request time. Isolated
entirely to nethttp/chi's private Serve/ServeSSE — zero public
API surface change, and api/rest itself remains exactly as
net/http/reflect-free as every other decision in this doc requires
(the 2 new RouteEntry/SSERouteEntry accessors above are ordinary,
reflection-free Go).
Two gaps found and closed during old-door removal (not part of the original plan)¶
Migrating the existing test suites and examples off Handler/Register
onto Serve/ServeOne surfaced two features the FIRST version of
buildRouteHandler's reflect dispatch never implemented — silently
regressing relative to handlerFunc for any route using them:
- No request/response
Formatscontent negotiation. The first version always calledDecodeMerged/EncodeMerged(the route's default JSON codec), never consultingRequestFormats/Formatsat all — confirmed via 6+ failing tests when migrated blindly. Fixed by reflecting over the[]format.Format[Req]/[]format.Format[Resp]slice fields directly: eachformat.Format[T]value's EXPORTED methods (ContentType,Unmarshal,Marshal,Validate,IsStreamable,MarshalTo) are reflect-callable regardless ofT(Go generics are monomorphized per concrete type at compile time, so areflect.Valuewrapping an already-concreteformat.Format[T]value has a full, callable method set) — no new codex/format API needed.RouteHandle.ApplyMergeFields/EncodeResponseMergeFieldswere added (splittingDecodeMerged/EncodeMerged's var-merge half from their body-codec half) so the merge-field step still runs when the body is decoded/encoded via a negotiated format instead of plainDecode/Encode. - No full
ErrorPatternresponse on the handler-error path. The first version only calledErrorStatusFor(status-code-only mapping), neverErrorResponseFor(typed payload + response header/cookie merge) — confirmed via 2 failing tests. Fixed by reflect-callingErrorResponseFor(already non-generic in its return shape —ErrorPatternResponse{Status, Body, Value any, Action}— so no reflect needed for the pattern match itself) and addingRouteHandle.EncodeResponseMergeFields(used both by the handler-success path AND the matched-ErrorPattern-payload path, type-checkingpattern.Value's concrete type againstRespviareflect.TypeOfbefore applying merge fields — the SAME parity rulehandlerFunc'swriteErrorPatternResponsealready enforced).
Both fixes are isolated to serve.go's reflect dispatch (identically in
nethttp/chi) plus 3 small new non-generic-signature RouteHandle
methods (ApplyMergeFields, EncodeResponseMergeFields,
WithHandler) — zero change to handlerFunc/sseHandlerFunc or any
other public API. A third, SEPARATE parity bug was found and fixed in
the same pass: negotiateFormatReflect (SSE's own Accept-header
negotiation, shared with Serve's Formats negotiation) compared the raw
Accept token against a format's FULL ContentType() string without
stripping ;-parameters, unlike the non-reflect negotiateFormat —
causing false 406s for content types with parameters (e.g. text/html;
charset=utf-8). Fixed to strip parameters from both sides before
comparing, matching negotiateFormat exactly.
Decision: SSERoute's full chainable method set (server-side only — closed during a final critical review pass)¶
Status: IMPLEMENTED. A final critical
review pass found that, while Serve/ServeSSE's split was already
resolved, SSERoute's OWN new chainable methods were never given
explicit signatures anywhere in this doc — only narratively implied
("SSE gets its OWN ServeSSE... treatment"). This closes that gap,
SERVER-side only.
SSERoute mirrors Route's full chain, one-for-one, with exactly ONE
signature difference (WithHandler, below):
// api/rest — SSERoute[Req, Event]'s new chainable methods. HandleMW's
// mw is NILABLE, mirroring Route's own unified HandleMW exactly (see
// "Decision: HandleMW/ClientMW unification" below) — .Implement() never
// existed as a SEPARATE SSERoute method; this same HandleMW(nil, fn)
// covers it.
func (s SSERoute[Req, Event]) HandleMW(mw *middleware.Middleware, fn any) SSERoute[Req, Event]
func (s SSERoute[Req, Event]) WithOptions(opts any) SSERoute[Req, Event]
// WithHandler DIFFERS from Route's — takes SSEHandlerFunc, not
// func(ctx, req) (Resp, error), since an SSE route streams MULTIPLE
// events per request instead of returning ONE Resp.
func (s SSERoute[Req, Event]) WithHandler(fn SSEHandlerFunc[Req, Event]) SSERoute[Req, Event]
// Register — same signature SHAPE as Route's (error only, no
// *SSERouteHandle returned), binding spec+handler+impls into the SAME
// Server Route.Register(b) already accumulates into.
func (s SSERoute[Req, Event]) Register(b *Server) error
// RegisterHandle — same ports-facing addition as Route's own (see
// "RESOLVED — a dedicated RegisterHandle..." above); ports' roleSink/SSE
// branch uses this instead of Register.
func (s SSERoute[Req, Event]) RegisterHandle(b *Server) (*SSERouteHandle[Req, Event], error)
SSERoute.Register's merge pass is IDENTICAL to Route.Register's —
made explicit here since it wasn't stated anywhere else. Every check
Route.Register performs (ConflictingSecurityDeclarationError, param
conflicts, and the reverse-Satisfies HandleMW-pairing check from
"HandleMW-to-.Use() pairing validation" above) applies UNCHANGED to
SSERoute.Register, since SSERoute.HandleMW shares the EXACT same
nilable-mw signature shape as Route's (shown above) — there is
nothing SSE-specific about any of these checks; only WithHandler's
signature differs, and that difference doesn't affect what gets
validated.
.Use() is UNCHANGED (already existed, already correctly signatured —
see "Step 1" above). No .ClientMW()/client-side Call equivalent is
defined for SSERoute here — see "Explicitly deferred" below.
Explicitly deferred: SSE client-side consumption¶
While designing the above, confirmed there is NO first-class,
declarative SSE CLIENT-consumption mechanism anywhere in go-codex
today — examples/adapters-sse/main.go reads its own SSE responses via
a hand-rolled readSSELines(resp *http.Response) []string helper (raw
line parsing), and adapters/nethttp/stream_errors.go still carries
SSEConnectError/SSEParseError types whose doc comments reference a
function called SSEClientStream that does NOT exist anywhere in the
current codebase — an apparent leftover from an earlier, since-removed
stream-bridge helper that was never given a declarative replacement.
This is NOT a mirror of an existing mechanism (unlike security/client-
credentials, which HAD an old design to redesign) — it would be a
GENUINELY NEW capability. Deliberately NOT designed as part of this
review pass; captured instead as its own roadmap doc, SSE Client
Consumption — to be picked up AFTER this doc's implementation ships,
same deferral pattern already established for
D-0004 — ReqReply Workflow Simplification.
(api/events's own equivalent has since shipped — see
Pub/Sub Workflow Simplification
— and this doc's own addendum below.)
Decision: any-typed Fn/fn parameters are the correct tradeoff, not a gap (resolves Question 6)¶
Status: confirmed — NOT a design change, a closed investigation.
Unlike the other five decisions above, this one concludes NO API change
is warranted — the current any-typed shape is the only one that
satisfies two constraints simultaneously, and no alternative design can
satisfy both at once.
Why a compile-time-typed alternative isn't reachable¶
- Go does not allow generic methods. A method cannot introduce type
parameters beyond its receiver's own.
Route[Req, Resp]is already generic overReq/Resp; a hypotheticalfunc (r Route[Req, Resp]) HandleMW[Raw any](mw Middleware, fn func(ctx context.Context, raw Raw, req *Req) (map[string][]string, error)) Route[Req, Resp]— which would letRaw(e.g.*http.Request) be compile-time-checked — is simply not valid Go. This is the SAME constraint that already forcedCaller.Call/CallHandleto be free functions (CallVia/CallHandleVia) rather than methods, documented elsewhere in this codebase. MakingHandleMW/ClientMW'sRaw-typed shape compile-time-safe would require abandoning the fluent.Use().HandleMW().WithHandler()chain (Decisions 1–3) in favor of a free-function alternative — reintroducing exactly the two-shapes-for- one-concept friction those decisions eliminated. api/restandmiddlewaredeliberately never importnet/httpor anyadapters/*package (confirmed via repo-wide grep — zero hits outside test files) — this layering is what lets the SAMEmiddleware.Middleware/ServerImplementationtypes generalize to future non-HTTP adapters (events/reqreply/MCP), per Decisions 1–3's explicit scope. The general-purpose shape (func(http.Handler) http.Handler) is inherently HTTP-specific; encoding it as a concrete (non-any) parameter type onRoute.HandleMW/ClientMWwould forceapi/restto importnet/http, breaking that layering for every future adapter, not just nethttp/chi.
Since compile-time safety for the general-purpose shape requires breaking
constraint 2, and compile-time safety for the security-verifying shape
requires breaking constraint 1 (Go's generic-method limitation), there is
no design that recovers static typing for Fn/fn here without
sacrificing either the adapter-agnostic core or the fluent chaining API
established in Decisions 1–3. The current design — any + eager
runtime shape validation via MiddlewareShapeError, checked at Serve
time (per Decision 4, the only remaining entry point) — is confirmed as
the correct tradeoff for this constraint set, not an oversight. No
follow-on questions; this closes Question 6 with no open thread.
Compromises evaluation — every escape hatch, re-checked against Decisions 1–4¶
After Decisions 1–4 were agreed, every one of the 12 escape hatches inventoried earlier in this doc was re-evaluated twice: once for "does the new design still support this," and once more for "can the new, simpler workflow actually ELIMINATE this, not just tolerate it." Two NEW, previously-underestimated compromises surfaced in the first pass (EH1 and EH7) — both more significant than the audit's original framing suggested. Three more elimination candidates surfaced in the second pass (all three accepted). All five are resolved below.
Severity scale used throughout: Critical (breaks a common pattern silently, no workaround) / High (breaks a real pattern, workaround exists but the loss is structural) / Medium (narrow audience, real but bounded impact) / Low (implementation risk, not a design flaw, cheap to prevent).
| # | Escape hatch | Purpose | New-design implication | Severity | Verdict |
|---|---|---|---|---|---|
| 1 | Manual WithSecurityScheme+RouteMeta.Security |
Declare security spec without touching middleware at all |
Confirmed in code: today's coverage check (rest.CheckCoverage) matches PURELY by scheme-name string, with zero dependency on any Middleware value — a fully-manual declaration and a Scopes("bearerAuth", fn) implementation satisfy each other today with no connection between them. HandleMW(mw, fn) requires an actual mw value; a manual declaration produces none — so under Decisions 1–2, a manually-declared scheme has no way left to attach a server-side implementation at all. |
Critical (until resolved below) | Resolved — see "Decision: eliminate manual per-route security declaration" below (goes further than a bridge: the escape hatch is removed, not patched) |
| 2 | SecurityScheme declared, never implemented (external-API docs) |
Document a scheme with zero enforcement, on purpose | Still valid in isolation; converges with EH1 under the new design — both become "declared, unimplemented" cases. | High (was, until resolved) | Resolved — see "RESOLVED: Serve's whole-builder failure semantics" below. A route with no .WithHandler() attached is unambiguously spec-only and is SKIPPED entirely by Serve (never validated, never an error) — this is exactly EH2's use case, now a first-class, zero-friction outcome rather than an unresolved tension |
| 3 | Handler/SSEHandler bypass |
Cheap unchecked handler, or standalone (no-Server) usage |
Already eliminated (Decision 4); mitigations already confirmed sufficient | — | Already decided: stays dropped, no change from this pass |
| 4 | ServerImplementation.Fn as any |
Adapter-agnostic type erasure | Unaffected; Decision 6 re-confirmed it's mandatory | — | Keep — foundational, not a "hatch" in the droppable sense |
| 5 | Client fn as any (now on .ClientMW()) |
Same, client side | Unaffected, relocated | — | Keep — same reasoning as #4 |
| 6 | Per-call credential override | One-off/test credential swap | Already eliminated (Decision 3) — workaround is building a new Route value with a different .ClientMW() |
Low–Medium | Confirmed fine as-is — considered and rejected a lightweight test-only override helper; building a fresh Route is acceptable ceremony for this narrow case |
| 7 | ports' direct Call/CallHandle |
Build a *rest.RouteHandle once, call it per-item at high throughput |
Confirmed in code: adapters/nethttp/binding.go's port adapters (CallAdapter, DrainCallAdapter, etc.) already accept a pre-built *rest.RouteHandle at construction time, reused across unboundedly many calls (e.g. a ticker loop). The new unified Call(ctx, caller, route, req, opts) would rebuild the handle via route.ClientHandle() on EVERY call if ports were forced through it — a real, measurable regression for this narrow, high-throughput audience. |
Medium | Resolved — see "Decision: unexported handle-based primitive" below |
| 8 | RequestParams/ResponseParams as []any |
Type-erased spec contribution | Unaffected by Decisions 1–4, BUT re-examined in the second pass: unlike Fn, these entries are already concrete, non-generic api/rest types — no obstacle to making them compile-time-typed |
Low (as a compromise); genuine simplification opportunity | ELIMINATED — see "Decision: typed RequestParams/ResponseParams fields" below |
| 9 | Conflicting param contribution detection | Safety net for typo/clash | Unaffected — still runs inside Register, now operating on typed inputs instead of type-switched any entries (per #8's resolution) |
— | Keep — the safety net itself is still needed, just simplified |
| 10 | General-purpose empty-Satisfies implementation |
Unify logging/observability/rate-limiting under ONE mechanism | Unaffected — and is the EXPLICIT, already-confirmed mitigation for "compose external middleware" now that Handler is gone (Decision 4). Re-examined in the second pass: the "empty Satisfies = general-purpose" signal is implicit, worth making explicit given how load-bearing it now is. Re-examined AGAIN in a later review round: forcing this case through .Use()+.HandleMW() (mirroring security's pairing) was itself the bug — general-purpose implementations declare nothing, so there's nothing to match. Re-examined a THIRD time, in a fourth review round: even the interim .Implement(impl) fix was superseded — HandleMW's mw became NILABLE, unifying the general-purpose case directly into HandleMW/ClientMW themselves, no separate method needed at all. |
— | Keep the mechanism, now UNIFIED directly into HandleMW/ClientMW — see "Decision: HandleMW/ClientMW unification" below (supersedes the interim .Implement()/Wrap fix, which itself superseded and fixed an earlier, buggy middleware.Wrap draft) |
| 11 | ValidateRoute dry-run |
Pre-flight check, no live Server needed |
RESOLVED — kept completely unchanged, no code change needed. Confirmed applyMiddlewareDeclarations (what ValidateRoute calls) only ever reads rb.middlewares/security/param registries — the new opt kinds (.HandleMW(), .WithHandler(), .WithOptions(), .ClientMW()) populate OTHER fields it never touches, so it naturally, structurally ignores them. Considered extending it to also simulate Serve's shape/coverage check, and explicitly rejected: ValidateRoute (declaration-only, no handler needed — useful for a "contract-first" workflow validating a shared route/middleware declaration in CI before any handler exists) and ServeOne (full check, requires .WithHandler()) now form a clean, non-overlapping two-tier validation story. A third "full check without building a handler" tier would be marginal, extra surface for a rare need — cuts against the "fewer doors" discipline established throughout this doc. |
Low | Keep as-is, unchanged — no extension |
| 12 | middleware.ContextField[V] |
Cross-middleware/handler data sharing, no Req pollution |
Depends on EnsureContextFields(ctx) being called as the OUTERMOST step per route — currently done by Register/Handler (both removed by Decision 4). Broadened during a final critical review pass: confirmed via direct code inspection that Handler's CURRENT body actually sets up FOUR ctx keys before any Fn runs, not just this one — middleware.EnsureContextFields(ctx), raw *http.Request access (contextKey{}), the response-headers box (responseHeadersKey{}), and the response-cookies box (responseCookiesKey{}, backing nethttp.WithResponseHeaders/WithResponseCookies). Real risk of silent loss for ALL FOUR, not just ContextField, if whatever replaces Handler's body doesn't carry every one of them forward. |
Low | Keep — tracked as an explicit implementation-checklist item (ALL FOUR ctx pre-allocation steps, not just ContextField/EnsureContextFields), not a design question |
Decision: eliminate manual per-route security declaration (resolves EH1's critical finding)¶
Status: IMPLEMENTED. Rather than merely
bridging the manual escape hatch into the new mechanism, this goes
further: the manual "declare a NEW per-route security requirement without
a Middleware value" pattern is removed entirely, since it structurally
cannot produce anything HandleMW can attach an implementation to.
RouteMeta.Security's OTHER two states are UNRELATED to scheme
declaration and are kept unchanged:
- nil — inherit global security (via Server.AddGlobalSecurity)
- []route.SecurityRequirement{} (empty slice) — explicit opt-out, "no
auth required" for this route
Only the THIRD state — a non-empty RouteMeta.Security manually
declaring a NEW requirement, paired with rest.WithSecurityScheme to
register the scheme's metadata — is eliminated. Every route that wants an
actual security requirement now goes through middleware.SecurityScheme
(building a Middleware from scratch) or rest.FromSecurityScheme
(bridging an existing rest.SecurityScheme value — see below), attached
via .Use().
// api/rest package — NOT middleware (found during implementation
// planning: rest.SecurityScheme is an api/rest-only type bundling
// route.SecurityScheme + an optional Codec; middleware cannot import
// api/rest without a cycle, since api/rest already imports middleware
// for Middleware/ServerImplementation/etc. rest.FromSecurityScheme lives
// alongside that type and bridges it into a real middleware.Middleware
// with a one-line internal call to middleware.SecurityScheme, passing
// scheme.SecurityScheme and scheme.Codec through) — bridges an existing
// rest.SecurityScheme value (e.g. a package-level var shared across
// several routes) into a real Middleware, usable with .Use()/.HandleMW()
// exactly like one built via middleware.SecurityScheme(...) directly.
func FromSecurityScheme(schemeName string, scheme SecurityScheme, scopes []string) middleware.Middleware
Confirmed consequence: rest.WithSecurityScheme is REMOVED entirely.
There is no Server-level scheme registration independent of routes
(confirmed — no Server.AddSecurityScheme exists), so WithSecurityScheme's
only remaining purpose (registering a scheme's spec metadata) is fully
subsumed by FromSecurityScheme/SecurityScheme — nothing is lost,
there is no longer a second, parallel path to the same result.
Confirmed consequence: the manual-vs-middleware conflict-detection
machinery is dead code, removed. ConflictingSecurityDeclarationError's
"manual" source case (securityContribution{source: "manual"} and its
cross-checks against middleware-contributed declarations) becomes
unreachable once manual declaration itself is gone — this is a genuine
simplification of api/rest/middleware.go, not just an API surface
reduction. ConflictingSecurityDeclarationError itself may still be
needed for middleware-vs-middleware conflicts (two DIFFERENT
SecurityScheme/FromSecurityScheme values disagreeing on the SAME
scheme name) — only the "manual" source variant is dead.
RESOLVED: Serve's whole-builder failure semantics (escape hatch #2)¶
Status: IMPLEMENTED.
Part 1 — .WithHandler()'s presence ALONE is the exact gating signal.
Whether a route ever had .WithHandler() called on it is precisely
"was this route ever meant to be served" — regardless of how many
.Use()/.HandleMW()/.ClientMW() calls it also accumulated. A route
missing .WithHandler() is unambiguously spec-only (escape hatch #2's
external-API-documentation case) and is SKIPPED by Serve (and
ServeSSE) entirely — no mux.Handle call, no shape/coverage
validation, no error — EVEN IF it also has one or more .HandleMW(nil, ...)
general-purpose implementations attached (e.g. an observability wrapper
declared on a route with no business handler yet): there is still no
handler to wire, so the SAME skip applies, exactly as if only .Use()
had been called. The shape/coverage check ONLY runs for routes where
.WithHandler() WAS called — that is the only case where "declared
security with no implementation" is an actual bug rather than
intentional documentation. This cleanly separates EH2's legitimate use
case (never validated, never wired, never an error) from a genuine
misconfiguration (a route the caller DOES want served, missing a
required implementation).
Part 2 — strict fail-fast, all-or-nothing, for routes that DO have a
handler attached. Serve/ServeSSE validate EVERY handler-bearing
route in the builder FIRST (shape + coverage checks), collecting ALL
failures into ONE aggregate error (a new MultiRouteError or similar,
carrying every individual MissingSecurityMiddlewareError/
MiddlewareShapeError found, not just the first) — and wire NOTHING
(the *http.ServeMux receives zero mux.Handle calls) if even ONE
handler-bearing route fails. This mirrors how every other check in this
design already behaves (MissingSecurityMiddlewareError,
ConflictingSecurityDeclarationError, etc. all fail loudly and
immediately, never degrade silently) — "the whole server either starts
correctly or doesn't start at all" is a far easier production mental
model than "some unknown subset of my API silently isn't there,"
discovered only via unexplained 404s later. A caller wanting partial
degradation (e.g. one team's broken route shouldn't block another
team's working ones) can still achieve it explicitly — by building
SEPARATE Servers per independently-deployable group of routes and
calling Serve once per Server/mux — rather than Serve silently
doing this partitioning on their behalf.
Part 3 — duplicate Method+Path detection, closing a gap found during a
final critical review pass. *http.ServeMux.Handle PANICS on a
conflicting pattern — a failure mode Part 2's "validate shape/coverage
first, wire only if all pass" design does not cover on its own, since
duplicate-path collision isn't a shape or coverage problem. Left
unaddressed, this would have TWO consequences: (1) nothing proactively
catches a duplicate Method+Path before wiring begins (rest.Route.Register
itself doesn't detect duplicates either — confirmed only
reqreply.Route.Register/apimcp.Tool.Register do, an existing,
unrelated asymmetry); and (2) a panic occurring PARTWAY through the
mux.Handle(...) wiring loop would leave the mux PARTIALLY wired
(earlier routes in the loop already registered, since *http.ServeMux
has no rollback) — directly violating Part 2's all-or-nothing guarantee.
Resolved with BOTH a proactive check and a defensive safety net, layered:
- Proactive duplicate detection, folded into Part 2's SAME
pre-validation pass. Before any
mux.Handlecall,Serve/ServeSSEadditionally walk every handler-bearing route checking for Method+Path collisions among them, contributing a newDuplicateRouteError{Method, Path string}entry into the SAME aggregateMultiRouteErrorPart 2 already builds — a duplicate is reported with exactly the same loud,errors.As-navigable, all-or-nothing treatment as a missing-coverage or shape error, not a raw panic message. - A defensive
recover()around the wiring loop, as a last-resort safety net — not the primary defense. Even with proactive detection for the KNOWN panic cause (duplicates),*http.ServeMux.Handlecan panic for OTHER reasons this design doesn't enumerate (e.g. a malformed pattern string) —Serve/ServeSSEwrap the wiring loop in arecover(), converting any such panic into a returned error instead of crashing the calling process. Since proactive detection (1) already prevents the wiring loop from ever starting when duplicates exist, this recover only needs to handle genuinely unanticipated panics — it is a safety net, not a substitute for (1).
Together, these close the gap Part 2 alone left open: EVERY known and
unknown mux.Handle failure mode is now either caught before wiring
starts (1) or converted to a graceful error if it somehow still occurs
during wiring (2) — "the whole server either starts correctly or doesn't
start at all" now holds unconditionally, not just for shape/coverage
errors.
Part 4 — MultiRouteError's exact shape, finalized during a final
critical review pass. Parts 2 and 3 above both feed into the SAME
aggregate error — previously left as "a new MultiRouteError or
similar," now a concrete design:
// adapters/nethttp — aggregate error returned by Serve/ServeSSE when
// one or more handler-bearing routes fail validation. Carries EVERY
// individual failure found during the pre-wiring validation pass — not
// just the first — so a caller sees the COMPLETE list of what's wrong
// in one error, not one-at-a-time via repeated fix-rebuild-fail cycles.
type MultiRouteError struct {
// Errors is one entry per failing route — each individually
// errors.As-navigable to MissingSecurityMiddlewareError,
// MiddlewareShapeError, or DuplicateRouteError.
Errors []RouteError
}
// RouteError pairs a route's identity with what went wrong on it.
type RouteError struct {
Method string
Path string
Err error
}
func (e MultiRouteError) Error() string // e.g. "3 routes failed validation: ..."
func (e MultiRouteError) Unwrap() []error // Go 1.20+ multi-unwrap — lets errors.As/Is reach into ANY individual route's error
func (e MultiRouteError) LogValue() slog.Value
Unwrap() []error (Go 1.20+'s multi-error unwrap support) rather than a
custom-only Errors field means a caller can errors.As(err,
&missingErr) DIRECTLY against the top-level MultiRouteError and it'll
find a match in ANY of the per-route errors — no manual loop required,
standard-library-idiomatic, consistent with every other typed error in
this design being errors.As-navigable.
Decision: unexported handle-based primitive (resolves EH7's confirmed regression)¶
Status: IMPLEMENTED. adapters/nethttp/
binding.go (where every ports adapter lives) is in the SAME Go package
as client.go (where Call lives) — confirmed via head -1 on both
files. This means the fix needs ZERO new public API surface: an
UNEXPORTED, handle-based call primitive, shared internally by BOTH the
new public Call and ports' adapters.
// adapters/nethttp — UNEXPORTED. The actual call logic, unchanged from
// today's Call in every respect except its name/visibility. Takes an
// ALREADY-BUILT *rest.RouteHandle — no route.ClientHandle() call inside
// it at all.
func callWithHandle[Req, Resp any](ctx context.Context, c *Caller,
handle *rest.RouteHandle[Req, Resp], req Req, opts CallOptions) (Resp, error)
// The new PUBLIC Call — calls route.ClientHandle() ONCE, then delegates.
// This is the ONLY new cost versus today, and only for callers using the
// public Call directly (a human calling occasionally) — not for ports,
// which never goes through this path at all.
func Call[Req, Resp any](ctx context.Context, c *Caller,
route rest.Route[Req, Resp], req Req, opts CallOptions) (Resp, error) {
return callWithHandle(ctx, c, route.ClientHandle(), req, opts)
}
ports' binding.go adapters are UNCHANGED in their own construction
pattern (still accept/build a *rest.RouteHandle once, at construction
time) — they simply call callWithHandle directly instead of the old
public Call, entirely inside the SAME package, invisible to any
external consumer. No performance regression, no new public surface, no
compromise left for this escape hatch.
Decision: typed RequestParams/ResponseParams fields (eliminates EH8)¶
Status: IMPLEMENTED — with one design correction found during
implementation. Unlike ServerImplementation.Fn (which genuinely
cannot be made compile-time-typed — see Decision 6's Go-generics/layering
argument), HeaderParam/CookieParam/QueryParam are already concrete,
non-generic api/rest types. There is no obstacle to replacing the
type-erased []any fields with typed ones — but this section's ORIGINAL
sketch (directly below, struck through) had an import-cycle bug: the
middleware package cannot import api/rest (api/rest already imports
middleware), so []rest.HeaderParam cannot compile as a middleware
package field type.
// ORIGINAL SKETCH — DOES NOT COMPILE, kept for history. middleware cannot
// import api/rest (api/rest already imports middleware for Middleware/
// ServerImplementation/etc.) — the exact same constraint that already
// keeps SecurityScheme/FromSecurityScheme split across the two packages.
type Middleware struct {
Name string
Security *SecurityDeclaration
RequestHeaderParams []rest.HeaderParam // ← does not compile
RequestCookieParams []rest.CookieParam // ← does not compile
RequestQueryParams []rest.QueryParam // ← does not compile
ResponseHeaderParams []rest.ResponseHeaderParam // ← does not compile
ResponseCookieParams []rest.ResponseCookieParam // ← does not compile
}
Shipped design: middleware-package-local typed spec structs +
api/rest bridge functions, mirroring SecurityDeclaration/
FromSecurityScheme's existing split exactly.
// middleware package (middleware/params.go) — typed mirrors of
// rest.HeaderParam/CookieParam/QueryParam/ResponseHeaderParam/
// ResponseCookieParam's field shape, using only codex (which middleware
// already imports) — no rest import needed.
type HeaderParamSpec struct {
Name, Description string
Required bool
Codec *codex.Codec[string]
}
// CookieParamSpec/QueryParamSpec/ResponseHeaderParamSpec/
// ResponseCookieParamSpec — identical shape, one type per kind.
// middleware package — Middleware's typed fields, using the spec types above.
type Middleware struct {
Name string
Security *SecurityDeclaration
RequestHeaderParams []HeaderParamSpec
RequestCookieParams []CookieParamSpec
RequestQueryParams []QueryParamSpec
ResponseHeaderParams []ResponseHeaderParamSpec
ResponseCookieParams []ResponseCookieParamSpec
}
// api/rest package — bridge functions wrapping an EXISTING rest.XParam
// value into a Middleware, mirroring FromSecurityScheme's "wrap what you
// already have" pattern (also closes "Decision: generalized spec-
// declaring middleware constructors" below in the SAME change).
func FromHeaderParam(p HeaderParam) middleware.Middleware
func FromCookieParam(p CookieParam) middleware.Middleware
func FromQueryParam(p QueryParam) middleware.Middleware
func FromResponseHeaderParam(p ResponseHeaderParam) middleware.Middleware
func FromResponseCookieParam(p ResponseCookieParam) middleware.Middleware
Eliminates ParamContributionShapeError entirely — a wrong-type
entry becomes a Go compile error, not a runtime errors.As-navigable
one. requestParamInfo/responseParamInfo's type-switch-over-any
logic is removed along with it.
ConflictingParamContributionError/its detection logic (escape hatch
9) stays — the safety net for two DIFFERENT middlewares declaring the¶
SAME param name is still needed, it now simply operates over typed
fields instead of type-switched any entries.
Decision: HandleMW/ClientMW unification (supersedes .Implement(), Wrap, and the original buggy middleware.Wrap draft)¶
Status: IMPLEMENTED. This is the THIRD generation of this exact idea, each one superseding the last as a deeper issue was found underneath the previous fix:
- Original
middleware.Wrapdraft — buggy:func Wrap(fn func(http.Handler) http.Handler) middleware.Middleware— butMiddlewareis the PURE declare-time type (noFnfield at all, per the whole declare/implement split); it cannot carryfn. .Implement(impl)fix — introduced a SEPARATE chainable method for declare-time-content-free implementations, since.HandleMW(mw, fn)was designed around "attach an implementation that SATISFIES a previously-.Use()'d declaration" and general-purpose implementations (nethttp.Observability, logging, rate-limiting) declare NOTHING — there was nothing for them to "match," so forcing them through.Use(marker) → .HandleMW(marker, fn)was ceremony with no payoff.- THIS unification — found while investigating
HandleMW/ClientMWfor a DIFFERENT, unrelated bug (neither guarded againstmw.Securitybeingnil, a genuine nil-pointer-panic risk — see "HandleMW-to-.Use()pairing validation"'s "Pass 3" above). Discussing the fix led to a simpler, better design: rather than adding a nil-guard THEN keeping.Implement()as a separate method, makemwitself NILABLE onHandleMW/ClientMWand FOLD.Implement()'s entire role into them — nil becomes the SANCTIONED signal for "nothing to pair against," not a misuse case..Implement()is REMOVED, not deprecated-alongside.
Confirmed there are actually TWO distinct general-purpose (empty
Satisfies) Fn shapes, both now unified under HandleMW(nil, fn):
| Shape | Runs | Can access *Req? |
Constructor |
|---|---|---|---|
func(http.Handler) http.Handler |
Wraps the WHOLE handler, before body/param decode | NO — Req doesn't exist yet | None needed — pass the closure DIRECTLY to HandleMW(nil, fn) |
func(ctx, raw Raw, req *Req) (map[string][]string, error), empty Satisfies, returns (nil, nil) |
After decode/merge, before the business handler | YES | Transform (see below) — returns the bare wrapped closure, not a ServerImplementation |
The resolution¶
// api/rest — HandleMW is the ONLY server-side implementation-attachment
// method (Implement is REMOVED). mw is NILABLE:
// - non-nil AND mw.Security != nil: PAIRED, security case, unchanged
// mechanics (Satisfies derived from mw.Security.SchemeName).
// - nil (or mw.Security == nil): UNPAIRED, general-purpose — Satisfies
// empty, fn runs unconditionally. Replaces EVERYTHING .Implement()
// used to cover.
func (r Route[Req, Resp]) HandleMW(mw *middleware.Middleware, fn any) Route[Req, Resp]
// ClientMW — identical unification, client-side (see "Client-side
// Satisfies-gated implementations" above).
func (r Route[Req, Resp]) ClientMW(mw *middleware.Middleware, fn any) Route[Req, Resp]
Wrap is REMOVED entirely — a caller passes their func(http.Handler)
http.Handler closure DIRECTLY to HandleMW(nil, myWrapFn); there is
nothing left for a separate Wrap constructor to add.
middleware.Scopes[Raw, Req], nethttp.Scopes[Req], and
nethttp.APIKey[Req] are ALSO REMOVED entirely — found during
implementation planning. All three exist ONLY to wrap a raw
extract/verify closure into a middleware.ServerImplementation{Satisfies,
Fn} — that exact wrapping now happens INSIDE HandleMW itself
(deriving Satisfies from mw.Security.SchemeName when mw.Security !=
nil, empty otherwise). A caller now passes the raw extract/verify
closure straight to HandleMW/ClientMW, e.g.
route.HandleMW(&mw, extractScopesFn) instead of
route.Implement(nethttp.Scopes[Req]("bearerAuth", extractScopesFn)) —
one fewer indirection, and one fewer constructor per Raw/Req pinning to
maintain.
Transform SURVIVES, but simplifies — instead of returning a full
ServerImplementation, it now returns the BARE wrapped closure directly,
since HandleMW itself builds the ServerImplementation internally now:
// middleware package — adapter-agnostic core. Returns `any` (the bare
// wrapped closure), NOT a ServerImplementation — HandleMW/ClientMW build
// that internally from whatever `fn` (and `mw`) they receive.
func Transform[Raw, Req any](fn func(ctx context.Context, raw Raw, req *Req) error) any {
return func(ctx context.Context, raw Raw, req *Req) (map[string][]string, error) {
return nil, fn(ctx, raw, req)
}
}
// nethttp/chi — pins Raw = *http.Request:
func Transform[Req any](fn func(ctx context.Context, r *http.Request, req *Req) error) any
// usage:
route = route.HandleMW(nil, nethttp.Transform(func(ctx context.Context, r *http.Request, req *Req) error {
req.CorrelationID = r.Header.Get("X-Correlation-ID")
return nil
}))
nethttp.Observability gets the SAME simplification — instead of
returning a ServerImplementation, it now returns the bare
func(http.Handler) http.Handler closure directly:
// nethttp — simplifies alongside Wrap's elimination.
func Observability(obs stats.Observer) func(http.Handler) http.Handler
// usage:
route = route.HandleMW(nil, nethttp.Observability(obs))
(The "three core types" section's mention of Observability(obs)
middleware.ServerImplementation near the TOP of this doc describes
TODAY's ACTUALLY-SHIPPED signature — historical, unaffected by this
decision, not a stale cross-reference.)
Transform's stored Fn shape still WRAPS into the EXISTING security
shape, does NOT introduce a third one — unaffected by this
unification, still true for exactly the same three reasons as before:
- Zero dispatch-loop changes.
runSecurityMiddlewarealready handles "emptySatisfies, returns(nil, nil)on success" correctly today — that IS the general-purpose gating rule from Step 3. A genuine third stored shape would require updatingvalidateImplementationShapes's type-switch AND adding a new, separate dispatch pass — neither is needed. - Correct interleaving with security Fns, for free. Since
Transform-built andHandleMW-built (security) implementations share the IDENTICAL stored shape and land in the SAMEimplsslice, they naturally interleave in ATTACHMENT ORDER through the ONE existing dispatch loop — aTransformderivingreq.TenantIDcan run before or after a security Fn reading it, purely byHandleMWcall order on the route. A genuine third shape, needing its own separate pass, would break this ordering guarantee entirely. Transform's public signature stays simpler than the stored shape. A caller writing a transform never needs to know or care about themap[string][]stringgrants machinery —Transformabsorbs that entirely; onlymiddleware-internal code (andnethttp/chi's Raw-pinned wrapper) ever sees the wrapped form.
Bonus: this fully answers a question left open in an earlier round¶
"Does the client side need its own .Implement()-equivalent?" (flagged
as explicitly deferred in "Client-side Satisfies-gated implementations"
above) — YES, and ClientMW(nil, fn) IS that equivalent. No separate
method needed; the same unification closes both sides at once.
Decision: generalized spec-declaring middleware constructors (the third sub-case of "general custom middleware")¶
Status: IMPLEMENTED — with the SAME import-cycle correction as the
previous section, and the constructors relocated to api/rest instead of
middleware. Reviewing the "may or may not add to headers/cookies"
sub-case of general custom middleware found that the (now-removed)
nethttp.HeaderParam (declare-time half of the API-key story)
generalizes cleanly beyond that framing — and was missing several
obvious siblings. The original sketch below proposed relocating it INTO
middleware as middleware.HeaderParam(p rest.HeaderParam) Middleware
— this has the exact same import-cycle bug as the previous section's
original sketch (middleware cannot import api/rest).
// ORIGINAL SKETCH — DOES NOT COMPILE, kept for history.
func HeaderParam(p rest.HeaderParam) Middleware // ← does not compile in package middleware
func CookieParam(p rest.CookieParam) Middleware // ← does not compile
func QueryParam(p rest.QueryParam) Middleware // ← does not compile
func ResponseHeaderParam(p rest.ResponseHeaderParam) Middleware // ← does not compile
func ResponseCookieParam(p rest.ResponseCookieParam) Middleware // ← does not compile
Shipped as api/rest.FromHeaderParam/etc. instead — these are the
EXACT SAME functions introduced in "Decision: typed RequestParams/
ResponseParams fields" above; this section's "generalize beyond
HTTP-specific framing" goal and that section's "typed fields" goal turned
out to be the SAME change, closed together:
// api/rest package (api/rest/middleware.go) — wraps an EXISTING
// rest.XParam value directly, mirroring FromSecurityScheme's "wrap what
// you already have" pattern. Lives in api/rest, not middleware, for the
// SAME import-cycle reason FromSecurityScheme does.
func FromHeaderParam(p HeaderParam) middleware.Middleware
func FromCookieParam(p CookieParam) middleware.Middleware
func FromQueryParam(p QueryParam) middleware.Middleware
func FromResponseHeaderParam(p ResponseHeaderParam) middleware.Middleware
func FromResponseCookieParam(p ResponseCookieParam) middleware.Middleware
adapters/nethttp/scopes.go's old HeaderParam(headerName string)
(which only took a bare NAME, not a full rest.HeaderParam value, and had
zero call sites anywhere in the repo) was REMOVED entirely in favor of
rest.FromHeaderParam(rest.HeaderParam{Name: headerName, Required: true}).
Naming note: rest.FromHeaderParam (function, returns middleware.Middleware)
intentionally sits alongside rest.HeaderParam (struct type) in the SAME
package — the SAME naming pattern already used for rest.FromSecurityScheme/
rest.SecurityScheme.
The full workflow for this case — declare and implement are DECOUPLED, no matching needed¶
// DECLARE — pure spec, wraps an existing rest.HeaderParam value directly.
corrIDMw := rest.FromHeaderParam(rest.HeaderParam{
Name: "X-Correlation-ID", Required: true, Codec: &uuidCodec,
})
route := rest.NewRoute[Req, Resp](...).Use(corrIDMw)
// IMPLEMENT — completely OPTIONAL, and has NO reference back to corrIDMw
// at all — unlike security's HandleMW(mw, fn) pairing, there is nothing
// here to "satisfy." Uses HandleMW's UNIFIED, nil-mw form (see
// "Decision: HandleMW/ClientMW unification" below) — .Implement() no
// longer exists as a separate method.
route = route.HandleMW(nil, nethttp.Transform(func(ctx context.Context, r *http.Request, req *Req) error {
req.CorrelationID = r.Header.Get("X-Correlation-ID")
return nil
}))
Confirmed: no coverage check is needed for this case, and this is a
DIFFERENT risk profile than security, not an oversight. rest.CheckCoverage
operates PURELY over secReqs []route.SecurityRequirement — it has ZERO
awareness of general param declarations, today or in anything decided
this session. Declaring a header for OpenAPI documentation purposes with
NO custom runtime behavior is completely legitimate: the framework's own
pipeline auto-validates format via ValidateHeaders regardless of
whether any custom Fn exists for it. This is unlike security, where
"declared but unenforced" is a genuine hole (an unauthenticated caller
gets through) — here, the worst case is a validated-but-otherwise-unused
declaration, not a security incident. .Use(mw) and .HandleMW(nil,
...) for this case correctly need NO shared reference between them.
Review round: PathParams/HeaderParams/CookieParams and middleware chaining¶
A dedicated review of how PathParam/HeaderParam/CookieParam merge
fields, and middleware-to-middleware "chaining," interact with the
redesigned workflow (Decisions 1–4) and the "one struct, one call"
principle — since none of the decisions above explicitly addressed this,
it's covered here as its own review pass.
Baseline: route-level merge fields are fully unaffected¶
The 9-layer request pipeline (body → query → cookie → header → path →
security) runs BEFORE any HandleMW-attached Fn executes — by the time
the FIRST Fn runs, Req is ALREADY fully merged from every route-declared
rest.NewPathParam/NewRequiredHeaderParam/etc. merge field. None of
Decisions 1–8 touch this pipeline ordering or the merge-field mechanism
itself. "One struct, one call" at the HANDLER boundary — func(ctx, req
Req) (Resp, error), everything the handler needs already merged into
req — is completely untouched by this whole redesign.
Middleware-contributed HeaderParam/CookieParam stay validate-only — confirmed deliberate, not a gap¶
Middleware.RequestParams/ResponseParams (typed, per "Decision: typed
RequestParams/ResponseParams fields" above) can only ever produce
spec-level, format-validated entries — never merge fields — because
Middleware isn't generic over Req (it's reused across routes with
different Req types; a merge field needs a concrete getter/setter
pair). This was initially suspected to be a fixable gap, but the
ORIGINAL declarative-middleware.md design doc already explicitly
rejected exactly that fix, for a correctness reason, not an oversight:
wiring a VERIFYING/transforming codec (e.g. one that decodes a bearer
token into a Claims struct) through a route-declared merge field
renders the WRONG OpenAPI schema — the spec would document the header's
schema as the DECODED shape (Claims, an object with sub/exp/etc.),
not "a bearer token string." Confirmed still correct, unaffected by
anything decided this session — no change needed.
The actual chaining mechanism — already designed, fully preserved by HandleMW¶
HandleMW's fn — matching the shape func(ctx context.Context, raw
Raw, req *Req) (map[string][]string, error) — is UNCHANGED from
today's security-verifying Fn shape, and this IS the "chain middlewares
accessing/setting header or cookie data" mechanism:
- A Fn reads the raw value directly off
Raw(e.g.r.Header.Get(...),r.Cookie(...)). - It can decode+verify via ITS OWN dedicated codec, using the
already-public
codex.DecodeVars/RequiredField/OptionalField— zero new API needed (e.g. real JWT verification producing aClaimsvalue from the rawAuthorizationheader). - It writes the result directly onto
*req— visible to the business handler, and to any LATER-attached Fn on the SAME route, since everyHandleMW-attached Fn shares the SAME*Reqpointer and runs in ATTACHMENT ORDER. This is genuine chaining: Fn2 (attached after Fn1) can read whatever Fn1 already wrote.
None of this changes under HandleMW — the Fn signature, the pointer
sharing, and the attachment-order execution are IDENTICAL to today's
mechanism, just reached via a different attachment API (.HandleMW()
chained on Route instead of a raw impls variadic passed to
nethttp.Register).
Response-side asymmetry — necessary, not a flaw¶
Resp doesn't exist yet when any HandleMW-attached Fn runs (Fns
execute BEFORE the business handler) — so response-side contributions
cannot use a *Resp pointer-write the way request-side Fns use *Req.
They go through the EXISTING ctx-based WithResponseHeaders/
WithResponseCookies mechanism instead. This asymmetry is REQUIRED by
Resp's lifecycle, not a design gap, and is unaffected by this session's
decisions.
PathParam specifically¶
Middleware CANNOT contribute new PathParam declarations (only
HeaderParam/CookieParam/QueryParam are recognized by
requestParamInfo/the typed fields from the EH8 decision) — a route's
path template is fixed once, at NewRoute(method, path, ...) time, and
this is unaffected. A Fn CAN read an already-merged path-derived Req
field (e.g. checking a JWT's subject claim against the path's :id —
a common authorization pattern) since path merge fields are applied
before any Fn runs, same as header/cookie merge fields.
The one genuine gap: no conflict detection for RUNTIME *Req writes — RESOLVED as an accepted limitation, documented via convention¶
Every SPEC-level disagreement in this design is a loud, immediate error
(ConflictingSecurityDeclarationError, ConflictingParamContributionError).
If TWO chained Fns both write to the SAME Req field with different
values, there is no equivalent check — plain Go mutation, silent
last-write-wins. Considered adding field-ownership tracking to close
this, and REJECTED, for principled reasons, not just convenience:
- Fundamentally different in kind from every other conflict check.
Every existing check operates on DECLARATIVE metadata — a list of
typed values the framework can iterate and compare BEFORE any code
runs. A runtime
*Reqwrite is an arbitrary line inside a user-supplied Go closure; there is no declarative surface to check against, and Go gives no cheap way to know "which fields will this closure write to" without executing or instrumenting it. - Any real fix would reintroduce exactly what "Decision: typed
RequestParams/ResponseParamsfields" just eliminated. Detecting this would require*Reqdirect-field-access to become a tracked/audited write path (e.g.req.Set("TenantID", value)instead ofreq.TenantID = value) — the same reflective/type-erased machinery this session moved AWAY from elsewhere, reintroduced here to catch a narrower problem. - Lower real-world risk than the spec-level case. Two middleware
VALUES accidentally sharing a scheme-name STRING (the spec-level
case) can genuinely happen across independent authors/packages that
have never seen each other's code. Two Fns writing the SAME
Reqfield requires ONE person to have deliberately attached both to a route whose struct they also defined — closer to "a bug in your own wiring code" than "two strangers' declarations coincidentally clashed." - Cost/benefit does not favor building this. Reflection-based
diffing of
Reqafter every attached Fn, on every request, is real per-request overhead for a scenario that is rare and self-inflicted.
Resolution: leave undetected, close the gap via documented convention
instead of enforcement. When implemented, docs/concepts/middleware.md
(and this doc, for now) should recommend: give each Fn a DISTINCTLY-NAMED
Req field when contributing derived data (e.g. TenantIDFromJWT vs.
TenantIDFromHeader, never a shared generic name two unrelated Fns might
both reach for), and reach for middleware.ContextField[V] instead of a
shared Req field when multiple Fns genuinely need to communicate a
value — ContextField at least makes the sharing EXPLICIT rather than
an implicit same-field collision.
Overall verdict¶
"One struct, one call" is FULLY PRESERVED at the handler boundary: by
the time the business handler runs, req carries every route-declared
merge field's value AND whatever any attached Fn additionally derived
and wrote, all in ONE struct, via ONE handler call signature — no ctx
lookup required for anything modeled as a Req field. The mechanism
achieving this (multiple Fns mutating shared state before the handler
runs) is an internal implementation detail invisible to the handler
author. Nothing in Decisions 1–8 changes this guarantee, weakens it, or
strengthens it — it was already correctly designed, and remains so.
Open questions for a simplification pass¶
These are raw observations framed as QUESTIONS — nothing here is decided. Bring your own priorities; this is the starting point for a discussion, not a recommendation.
-
RESOLVED — see "Decision: whole-API declarative wiring" above.
Is three separate constructors for one concept worth its cost?SecurityScheme+Scopes+ a route needing BOTH.Use()and the adapter's variadic is more ceremony than the OLD bundledRequireScopesfor the common case (a route this codebase both declares AND enforces). The split only pays off for the external-API-description case (escape hatch #2). -
RESOLVED — see "Decision: whole-API declarative wiring" above (
HandleMW(mw, fn)takes the ACTUAL declaredmwvalue, not a re-typed string — the typo risk described below is structurally closed).Should the scheme-name string have a compile-time link between declare and implement?"bearerAuth"appears inSecurityScheme(...),Scopes(...), and implicitly inSatisfies— no compile-time connection; a typo silently produces "declared but never covered," caught only atRegister/RegisterSSEtime. -
RESOLVED — see "Decision: symmetric client-side declarative wiring" above.
Is the ambient-vs-override client wiring choice discoverable enough?Route.UseClient(ambient default) vs. passing directly toCallVia(one-off override) had no single obvious default — there were actually THREE stacking layers, not two (route-level, Caller-level, per-call) — all three now collapse into ONE (.ClientMW(), declared on the route, no per-call override). -
RESOLVED — see "Decision:
Serveis the only public server-side entry point" above.Is theReaching for "just give me anHandler/Registervalidation asymmetry a trap?http.Handler" (Handler/SSEHandler) silently opted out of BOTH the shape check and the coverage check — this asymmetry is now CLOSED entirely:Handler/Register/SSEHandler/RegisterSSEhave been DELETED from bothadapters/nethttpandadapters/chi;Serve/ServeOne/ServeSSE(always fully checked, including the coverage check — see "Lessons Learned" below for a regression this closure itself caused and fixed) are the ONLY remaining public server-side entry points. -
RESOLVED — see "Decision: symmetric client-side declarative wiring" above (closed as a side effect of resolving Question 3).
Four client-side entry-point functions — too many?Call/CallHandle/CallVia/CallHandleViacollapse into ONECall(ctx, caller, route, req, opts)function that takes theRoutedirectly and always auto-derives merge fields. -
RESOLVED — see "Decision:
any-typedFn/fnparameters are the correct tradeoff, not a gap" above.Areany-typedfn/Fnparameters the right tradeoff?ServerImplementation.Fn, andHandleMW's/ClientMW'sfn anyparameters, all sacrifice compile-time safety for adapter-agnosticism — CONFIRMED as the only design that satisfies both Go's no-generic-methods constraint andapi/rest/middleware's adapter-agnostic layering simultaneously; no API change follows from this question.
Lessons Learned (post-implementation retrospective)¶
This section is added AFTER the design above was fully implemented and the old
doors (Handler/Register/SSEHandler/RegisterSSE) were deleted — it
records what actually went wrong during execution that the design and its
review passes did NOT anticipate, for future maintainers planning a
similarly-shaped removal (an old, multi-responsibility public API replaced by
a new one). None of this changes the shipped design; it changes how the NEXT
one should be planned and reviewed.
1. "Equivalent" claims about generated/reflective code need runtime proof, not review sign-off¶
The roadmap doc's own "Decision: Serve's generic dispatch mechanism" section
stated plainly that buildRouteHandler "runs the SAME pipeline Handler
runs, invoked via reflect.Value.Call instead of static generic calls." This
was written in good faith, reviewed multiple times, and was false — the
reflect dispatch never implemented request/response Formats content
negotiation, and never called ErrorResponseFor on the handler-error path.
Both gaps were silent: Serve/ServeOne built successfully, wired
successfully, and served ordinary requests successfully. They only surfaced
when the OLD test suite (128+ tests exercising Handler/Register directly)
was migrated wholesale onto the new entry points and specific tests started
failing. A smaller, incremental migration (a handful of tests at a time,
declared "good enough" after each batch passed) would very plausibly have
shipped with these gaps permanently baked in and undetected.
Takeaway: when a design claims a new mechanism is a drop-in equivalent for an old one, that claim is a hypothesis, not a fact, until every existing caller of the old mechanism has been ported and re-verified against it. Plan for "migrate everything, then see what breaks" as the actual verification step — not code review of the new mechanism in isolation.
2. Deleting an old function can silently delete a responsibility nobody tracked as separate¶
Register/RegisterSSE did two things: wire the handler onto the
mux/router, AND call rest.CheckCoverage to reject a route whose declared
security scheme had no matching implementation. The roadmap's planning and
every review pass treated "delete Register/RegisterSSE" as pure code
removal of the wiring responsibility — nobody re-derived the full list of
side effects the old function had, so the coverage check's removal was
invisible until a dedicated regression test was written specifically to
probe it (see the review-go-codex Round 125 finding G4). Until that test
existed, Serve/ServeSSE would silently wire a misconfigured security
route with zero error, deferring the failure to every individual runtime
request instead of failing loudly at wiring time.
Takeaway: before deleting a function, enumerate ALL of its responsibilities — not just the one implied by its name or its most obvious call site — and verify each one has an explicit new home. A multi-responsibility function is a bundle; deleting the bundle without unbundling it first drops whatever wasn't the primary focus of attention.
3. Documentation is not verified by any build step — it rots silently and at scale¶
Deleting 4 exported functions left 31 dangling godoc [Symbol] bracket-links
across 7 Go files, plus stale, non-compiling code snippets in 8 separate
docs/*.md pages, 2 examples' leftover explanatory comments, a README table,
and 2 of this repo's own skill files — none of which go build, go vet,
go test, or staticcheck ever flagged, because none of them parse Markdown
or resolve godoc comment syntax. This required TWO separate full-repo grep
sweeps (one that caught the adapters/ package files, a second deeper pass
that caught everything else) after the code-level work was already declared
"done and verified."
Takeaway: "the build is green" is necessary but not sufficient evidence
that a symbol removal is complete. Any plan that removes an exported symbol
must include an explicit, scheduled step to grep the ENTIRE repository — not
just the package being changed — for the symbol's name, across *.go doc
comments, docs/**/*.md, examples/**/*.go, and .github/**/*.md.
4. The removal's true blast radius was far larger than what was planned for¶
The original roadmap doc's status banner tracked the removal's cost as "128+
combined test call sites" (83 in adapters/nethttp/adapter_test.go + 1 in
stream_test.go + 44 in adapters/chi/adapter_test.go) — a code-only,
test-only count. The actual cleanup touched: those 128 test call sites, 7
examples using the old doors directly, 1 example (sensor-service) with a
structurally different blocker entirely, 3 Go source files' doc comments
outside adapters/, 8 docs/*.md pages, 2 examples' stale explanatory
comments, a README, and 2 skill files — a genuinely larger surface than the
plan's own cost estimate, discovered only by working through it rather than
by the original scoping pass.
Takeaway: when scoping the cost of an API removal, the code-call-site count is a floor, not a ceiling. Documentation, examples, and tooling configuration that reference the symbol are real, uncounted cost — budget for a discovery pass, not just the known call sites, before declaring a removal "small" or "large."
5. Curated test suites don't surface every real-world integration shape¶
Every existing unit test exercising Handler/Register assumed the ordinary
shape: build a route, attach a handler, register it. examples/sensor-service
did something none of the tests did: declare routes as PACKAGE-LEVEL VARS via
RegisterHandle at Go var-init time, then attach the real handler LATER in
main() once a runtime dependency (a database store) existed — an ordering
the new Serve/ServeOne design had implicitly assumed away ("WithHandler
before Register"). This wasn't found by reading the roadmap doc, the
tests, or any other example — it was found only by attempting to actually
migrate that ONE real, "in the wild" example. It required a genuinely new,
small piece of API (RouteHandle.WithHandler/SSERouteHandle.WithHandler as
Route.WithHandler's post-registration equivalent) that the original design
never anticipated needing.
Takeaway: a representative-looking test suite is not a substitute for migrating every REAL consumer of an API being changed, including the ones that don't look like the tests. Structural assumptions ("handler always exists before registration") should be treated as unverified until every existing example — not just every existing test — has been checked against them.
6. Independent, parallel exploration finds bugs a targeted review doesn't¶
While migrating examples/adapters-templ (one of several migrations run as
parallel background agents, each independently exercising a different
example's code paths), one agent found that negotiateFormatReflect (SSE's
Accept-header format negotiation, shared with Serve's own dispatch) failed
to strip ;-parameters from a content-type before comparing it against the
Accept header — a bug unrelated to either of the 2 gaps this round's review
was explicitly looking for. It was caught only because that agent's specific
example exercised Accept: text/html against a format whose ContentType()
included ; charset=utf-8, a case the review's own test-writing hadn't
targeted.
Takeaway: running independent migrations/explorations in parallel, each against a genuinely different real consumer, finds classes of bugs a single reviewer working through a fixed checklist will not — because the checklist can only test for what someone already thought to ask about.
7. Environment reliability assumptions should be made explicit and have a fallback¶
ask_user calls made throughout this implementation frequently returned
"user not available" — including at moments intended to confirm significant,
hard-to-reverse decisions (e.g., whether to accept feature loss vs. fix the 2
Serve gaps before deleting the old doors). The practical resolution was an
explicit fallback policy: when ask_user is unavailable, choose the more
conservative option, state the assumption clearly in the response, and keep
working rather than blocking. This was not planned for at the start of the
implementation and had to be improvised mid-session.
Takeaway: any implementation plan spanning a long session should assume interactive confirmation may be unavailable at the moment it's needed, and should pre-decide a conservative default for its highest-stakes open questions rather than relying on being able to ask in the moment.
Addendum: this design as the foundation for SSE client consumption¶
Added after the SSE Client Consumption roadmap doc (docs/roadmap/
sse-client-consumption.md) shipped its Phase 1 (a separate feature, planned
and implemented in a later session) — recorded here so the lineage is
discoverable from this document, since the newer feature's own roadmap doc
did not itself narrate where its core mechanism came from. That roadmap doc
has SINCE BEEN DELETED (per its own 3-way delete/keep/promote graduation
policy — a single-feature roadmap doc, fully shipped, with a confirmed
zero-gap review, and no lasting cross-cutting design value of its own beyond
what is captured here); this addendum is the durable record of the design
lineage that remains after that deletion.
SSERoute.ClientHandle()/ClientMW() — the pair that lets a single
rest.SSERoute declaration serve BOTH the server side (ServeSSE) and the
client side (Client.Consume/CallSSEAdapter, per Addendum 4 below —
originally nethttp.Consume, since retired) with no separate
declaration — is not a new design. It is a direct, mechanical application of
two decisions already made and shipped by THIS document:
- "Decision: symmetric client-side declarative wiring" (above) is what
established
Route.ClientHandle()/ClientMW()as the client-side mirror ofRegister/HandleMWin the first place, for the plain request/responseRoutecase.SSERoute.ClientHandle()/ClientMW()apply the identical pattern to the stream case — same struct-literal-construction shape, same infallible-with-panic-on-type-mismatch contract, sameapplyMiddlewareSecurityForClientreuse for security scheme parity with the server side. - "Decision: unexported handle-based primitive" (above) is what
established
callWithVars/CallWithHandleas the layeringCallis built on.adapters/nethttp/binding.go'sconsumeSSE/consumeSSEOnceprimitive that bothConsume(route-based convenience) andCallSSEAdapter(handle-based, port-adapter-facing) delegate to is the same layering shape, one level up (a long-lived reconnecting loop instead of a single call), for exactly the same reason: one primitive, two public entry points at different levels of convenience.
A dedicated review-go-codex code-review pass (Round 127, prompted by the
user's explicit request to check sse-client-consumption.md against its
implementation for open gaps) confirmed zero gaps between that roadmap
doc's committed scope and the shipped code, tests, and documentation — every
API surface item, unit-test-plan item, and "in scope"/"out of scope" line
was verified present and correct, and the "out of scope" Last-Event-ID/
pluggable-retry-policy items were confirmed correctly carried forward into
their own follow-on roadmap doc (docs/roadmap/sse-resume-and-retry-policy.md)
rather than silently dropped. This is presented as evidence that the two
Decisions above generalize cleanly to a second, structurally different
boundary (a long-lived stream instead of a single request/response) without
needing new design work — the strongest practical validation this document's
core patterns could receive short of a third independent adapter adopting
them.
Addendum: Client.Attach's Observer + ErrorPattern parity fix¶
Added after a dedicated review of error handling and stats.Observer
integration across REST and events under the "thin adapter" Client.Attach
workflow (Decision 5, this doc's own nethttp.Attach/AttachMux design),
triggered by a direct user request. Companion events-side fix documented in
docs/design/d-0002-pubsub-workflow-simplification.md's Decision 8.
Confirmed via code inspection: server-side Attach had NO gap.
nethttp.AttachMux/chi.AttachRouter delegate straight to the existing
serve/serveSSE functions — the SAME functions the pre-Attach API
surface already used — so ErrorPattern and stats.Observer wiring were
never at risk on the server side; serverTransport.Serve (see this doc's
own Revision 2 "Decision: unexported handle-based primitive" section) is a
pure wiring wrapper, not a reimplementation.
Confirmed bug: nethttp.Attach's CLIENT-side clientTransport.Call
called stats.Observer NOWHERE AT ALL, and did NOT consult a declared
rest.ErrorPattern on a non-2xx response — it reimplemented the HTTP
call from scratch (a hand-rolled http.NewRequestWithContext/client.Do/
io.ReadAll sequence) rather than delegating to the existing, already-
correct callWithVars (this doc's own Decision: unexported handle-based
primitive). This meant every call made through the "preferred" Client.
Attach workflow silently dropped ALL metrics/logging/tracing, AND lost the
declarative typed-error-decode ErrorPattern promises entirely — falling
back to a bare UnexpectedStatusError even when a route declared a
matching rest.ErrorPattern.
Fix: clientTransport.Call now resolves obs :=
stats.ObserverFromContext(ctx) (ctx-only — this reflection shim has no
per-call CallOptions-equivalent struct to carry an explicit override,
matching its own documented v1 scope) and calls obs.RecordRequest(method,
path, statusCode, duration) on EVERY exit path — status 0 before a
response is ever received (pre-flight/build/network failures), mirroring
callWithVars's own convention exactly. TraceObserver span start/end is
wired identically ("http.request" span name, matching callWithVars's
own). On a non-2xx response, clientTransport.Call now calls
handle.DecodeErrorFor(statusCode, respBody) (the client-side counterpart
of RouteHandle.ErrorResponseFor — see this doc's own error-pattern
sections) via the SAME reflection technique already used for BuildPath/
EncodeRequest/DecodeResponse, returning the typed ErrorPatternResponse
on a match, falling back to UnexpectedStatusError unchanged otherwise —
mirroring callWithVars's own step 11 exactly.
Why this was missed originally: clientTransport.Call was written as
a from-scratch reflection shim (necessarily so, since rest.Client.Call's
any-typed signature requires runtime type recovery this doc's Decision 5
already establishes) rather than delegating to callWithVars — a
reasonable-looking choice at the time, since the CORE request/response
mechanics (encode, build URL, decode) had to be reflection-driven anyway.
But this meant the Observer/ErrorPattern wiring — which does NOT need
reflection, since obs and the error-decode call are both resolved via
already-concrete values (stats.ObserverFromContext(ctx) returns a
concrete stats.Observer; handle.DecodeErrorFor is called via reflection
only because handle's own concrete type is runtime-only, not because the
call itself needs anything special) — was simply never added, since there
was no existing "add this one wiring block" pattern to notice was missing.
Lesson, consistent with this doc's own "Lessons Learned" section: a
reflection-based shim built to solve ONE structural problem (recovering a
generic type at runtime) can silently regress OTHER, unrelated concerns
(observability, declarative error handling) that have nothing to do with
the reflection problem itself, if the shim is written as a fresh
implementation instead of a thin wrapper delegating to the already-correct
non-reflection-blocked pieces.
Verification: new tests in adapters/nethttp/clienttransport_test.go
cover RecordRequest on success (with the real status code) and network
failure (status 0), plus a matched ErrorPattern returning
ErrorPatternResponse and an unmatched non-2xx status falling back to
UnexpectedStatusError unchanged. gofmt/go build/go vet/go test
all green; zero regressions in the existing TestAttach_*/TestCall_*
suites.
Addendum 2: Client.Call's declared-format gap, fixed by centralizing resolution on RouteHandle¶
A second, structurally identical gap surfaced while proving the Observer/
ErrorPattern fix above via example rework. The identical fix also
landed on the events side in the same round — see
docs/design/d-0002-pubsub-workflow-simplification.md's Decision 9 for the
events-specific detail (ChannelHandle[T]'s mirror-image methods).
Confirmed bug: clientTransport.Call's request-encode/response-decode
steps ALSO always called RouteHandle[Req,Resp]'s plain
EncodeRequest/DecodeResponse, which are hardcoded to JSON codec
encode/decode by original design (their own doc comments say so
explicitly), silently ignoring a route's declared
WithFormats/WithRequestFormats (YAML, TOML, Gob, custom binary) —
while callWithVars (the existing, already-correct escape-hatch
primitive) resolved this correctly by duplicating the resolution logic
inline at its own call site ("call-time override > declared
RequestFormats/Formats > plain EncodeRequest/DecodeResponse"). A route
declared with a non-JSON format would silently break the moment a caller
switched from the escape hatch to Client.Attach. This is not about
per-call format overrides (which stay a legitimate client-side
CallOptions concern, untouched) — it is about the route's own
declared format, the single most basic contract a handle makes about
its own wire shape.
Fix: rather than teach Call to duplicate callWithVars's resolution
logic a second time, the logic moved onto RouteHandle[Req,Resp]
itself — the single source of truth for a route's own declared
configuration. REST's format model has only two levels (no three-level
Subscribe/Publish/Formats chain like events) — override > declared, no
further fallback chain needed. Three new canonical methods
(api/rest/builder.go):
// EncodeRequestWithFormats resolves formats (call-time override) >
// declared RequestFormats > plain EncodeRequest, returning the matching
// Content-Type alongside the bytes (a pre-flight concern, needed before
// sending the request).
func (h *RouteHandle[Req, Resp]) EncodeRequestWithFormats(req Req, formats ...format.Format[Req]) (body []byte, contentType string, err error)
// ResponseFormat resolves the SAME priority for the response direction,
// returning only the resolved format's ContentType — used for the Accept
// header, a pre-flight concern separate from decode (a post-flight
// concern), hence its own method.
func (h *RouteHandle[Req, Resp]) ResponseFormat(formats ...format.Format[Resp]) string
// DecodeResponseWithFormats resolves formats > declared Formats > plain
// DecodeResponse.
func (h *RouteHandle[Req, Resp]) DecodeResponseWithFormats(body []byte, formats ...format.Format[Resp]) (resp Resp, acceptContentType string, err error)
BOTH callWithVars and Call now call these methods, deleting
callWithVars's own inline duplicated copy in the process:
adapters/nethttp/client.go's callWithVars request-encode/response-
decode steps now call handle.EncodeRequestWithFormats/
handle.DecodeResponseWithFormats, passing the existing
resolveCallFormat-derived override through unchanged (still a
client-side CallOptions concern); adapters/nethttp/clienttransport.go's
Call calls the SAME canonical methods via
reflect.Value.MethodByName(...) with zero-length variadic (no
call-time override — Client.Attach still has no per-call override
concept, unchanged from Decision 5's documented v1 scope). Net effect:
callWithVars and Call both shrink; the only logic left in
clienttransport.go's Call is the reflection dispatch itself, exactly
the "thin adapter, pure IO" principle this doc's own Addendum 1 already
established for Observer/ErrorPattern.
Tests: canonical method unit tests in api/rest/builder_test.go
(declared-format-wins, call-time-override-wins-over-declared,
empty-falls-back-to-plain-EncodeRequest/DecodeResponse); existing
escape-hatch callWithVars tests pass unmodified, confirming the
refactor preserves 100% existing behavior; a new round-trip test proving
Client.Attach's Call now honors a declared YAML format end-to-end —
TestAttach_ClientCall_HonorsDeclaredYAMLFormat in
adapters/nethttp/clienttransport_test.go (a real httptest.Server +
rest.Route declared with RequestFormats(format.YAML(...))/
Formats(format.YAML(...)), called via rest.NewClient() +
nethttp.Attach + Client.Call).
Verified: gofmt/go build/go vet/go test all green repo-wide;
just check (staticcheck + gosec) with no new suppressions; zero
regressions in existing test suites.
Addendum 3: client-side general-purpose ClientMW hook (closes the last known REST/events middleware asymmetry)¶
Folded in from docs/roadmap/rest-client-general-purpose-middleware.md,
now deleted (its content lives here). Spun out of the events-side review
(docs/design/d-0002-pubsub-workflow-simplification.md's Decision 11),
which flagged this exact asymmetry as the one remaining known gap between
REST and events middleware: Route.HandleMW (server-side) recognizes
TWO Fn shapes — the security-Fn shape and a general-purpose
func(http.Handler) http.Handler — and pub/sub's PublishMW/
SubscribeMW (adapters/mqtt5/adapter.go, mirrored in mqtt/zeromq)
also recognize two shapes each. Route.ClientMW (client-side, via
adapters/nethttp.Call/CallWithHandle) recognized only ONE shape — the
credential shape — hard-erroring on anything else. There was no way to
attach general-purpose, non-credential behavior (custom request/response
transformation, retry logic, bespoke tracing) to ClientMW at all.
Fix, mirroring the shipped pub/sub precedent exactly
(adapters/mqtt5/adapter.go's wrapPublishGeneral/
validatePublishImplementationShapes/publish): a new Fn shape,
recognized alongside the credential shape by a new
validateCallImplementationShapes[Req, Resp] (used ONLY by
callWithVars/CallWithHandle), and dispatched by a new
wrapCallGeneral[Req, Resp] (adapters/nethttp/client.go), which wraps
fn OUTERMOST-in, in attachment order — identical contract to
wrapPublishGeneral.
Wrap boundary — confirmed by mirroring publish's shipped structure:
topic derivation and security/credential resolution happen OUTSIDE
wrapPublishGeneral's wrap, which applies ONLY to an "encode and
transmit" closure. REST's direct equivalent: the wrap applies ONLY to
callWithVars's network-round-trip step (encode request body → build+
send httpReq → decode response → response header/cookie merge-fields),
NOT to path/query/header/cookie param derivation+validation, format
resolution, URL building, or credential resolution — those stay outside
the wrap and are closed over by the wrapped closure. CallOptions.Observer
is unaffected — it stays a permanent per-call field, exactly like
PublishOptions.Observer stays independent of PublishMW's own general
hook; every existing obs.RecordRequest(...) call site was relocated
verbatim into the new closure's lexical scope, not altered.
Variable-scoping verification (the one non-mechanical risk in this
refactor): callWithVars's err is a single, function-body-scoped
variable, first declared at concretePath, err := handle.BuildPath(vars)
and reused via :=/= throughout (Go's := reuses an existing
same-scope variable whenever at least one new variable also appears on
the LHS); every place that needs the variable NOT to propagate uses an
if err := ...; err != nil { return ... } block-scoped shadow that
returns immediately. Moving the network-round-trip step into a nested
closure introduces a genuinely new function scope, but the closure's
return value is combined back into the SAME outer err at the call site
(result, err := next(ctx, req), executed at the pre-existing outer
scope, not inside the closure) — so the deferred
to.EndSpan(ctx, err) (stats.TraceObserver) continues to observe the
correct final error exactly as before the refactor. Locked in by
TestCall_GeneralShape_TraceObserver_SeesFinalError.
SSE deliberately NOT touched: consumeSSE (SSE's Consume/
CallSSEAdapter) shares validateClientImplementationShapes (the
original, credential-only validator) with callWithVars today. Rather
than widen that shared function — which would let a general-purpose Fn
attached to an SSE route silently pass validation while never being
invoked (SSE's per-event dispatch shape, func(context.Context, Event)
error called repeatedly, doesn't match the single-call wrap shape) — a
SEPARATE validator (validateCallImplementationShapes) was introduced,
used only by callWithVars/CallWithHandle. consumeSSE keeps calling
the original, completely untouched validateClientImplementationShapes.
Mirrors mqtt5/mqtt/zeromq's existing precedent of separate
validateSubscribeImplementationShapes vs
validatePublishImplementationShapes rather than one universal
function. Locked in by TestConsume_GeneralShapeFn_StillRejected
(adapters/nethttp/binding_test.go) — a regression guard confirming SSE
was NOT widened by this change.
rest.Route.ClientMW/rest.SSERoute.ClientMW needed ZERO code
changes — they already accept untyped fn any with no shape
validation of their own (all shape validation and dispatch logic lives
in the consuming adapter). The entire fix is scoped to
adapters/nethttp/client.go.
Explicitly out of scope (both confirmed during this doc's own
finalization, not new information):
- adapters/nethttp/clienttransport.go's Client.Attach-backed Call
(used by rest.Client.Call) — its own, already-documented, much
narrower "v1 scope" (no path/query/header/cookie params, no security/
credential handling AT ALL, per Decision 5 above) is a separate,
larger, pre-existing gap, not conflated with this feature.
- SSE's own general-purpose hook — a structurally different, future
design problem (SSE's per-event dispatch shape doesn't match the
single-call wrap shape), analogous to why pub/sub needed separate
Subscribe/Publish general shapes rather than one universal one.
- adapters/chi — confirmed to have ZERO client-side code (a
server-side-only router library); the original roadmap doc's title
incorrectly listed it in scope and was corrected before this fold-in.
Structured errors / observer: no new types. middleware.MiddlewareShapeError
(already slog.LogValuer-compliant) is reused as-is for the new
shape-mismatch case, mirroring validatePublishImplementationShapes's
reuse of the same type. No observer changes beyond the verbatim
relocation described above.
Tests (adapters/nethttp/client_test.go unless noted): TestCall_GeneralShape_WrapsRequest
(happy path, one general-purpose Fn wraps next), TestCall_GeneralShape_MultipleFns_OuterToInner_AttachmentOrder
(two Fns compose outermost-in, in attachment order), TestCall_GeneralAndCredential_Coexist
(a credential-shaped Fn and a general-purpose Fn attached together, both
run correctly), TestCall_GeneralShape_TraceObserver_SeesFinalError
(regression guard for the variable-scoping verification above),
TestConsume_GeneralShapeFn_StillRejected (adapters/nethttp/binding_test.go,
regression guard confirming SSE stays untouched); the pre-existing
TestCall_WrongShapeMiddleware_ReturnsMiddlewareShapeError (server-side
func(http.Handler) http.Handler shape attached to ClientMW) continues
to correctly fail, confirmed unaffected by the second recognized shape.
Verified: gofmt/go build/go vet/go test all green repo-wide;
just check (staticcheck + gosec, 0 issues); just examples (all pass);
zero regressions in existing test suites.
Addendum 4: Client.Call/Client.Consume full ClientTransport feature parity, and retiring nethttp.Consumer/Consume¶
Folded in from docs/roadmap/rest-client-transport-full-parity.md, now
deleted (its content lives here). Triggered by a direct user request to
add rest.Client.Consume — a declarative, ClientTransport-based SSE
counterpart to Client.Call mirroring events' Client.Subscribe — which
surfaced that Client.Call's existing reflection shim
(clientTransport.Call) had a documented "v1 scope" excluding path/query/
header/cookie params, security/credential ClientMW, per-call format
overrides, and general-purpose ClientMW wrapping. A naive
Client.Consume mirroring that same scope would have been unusable for
SSE's common path-templated routes, so this addendum closes ALL FOUR
gaps for BOTH Client.Call and the new Client.Consume together, then
retires the now-fully-redundant nethttp.Consumer/Consume.
Part 1 — ClientTransport interface gains Consume. Mirrors BOTH
ClientTransport.Call (REST's own precedent) and events.Transport's
bundled Publish/Subscribe/ServeSubscribers shape (one interface per
boundary role, ALL operations bundled, ONE adapter type implementing all
of them):
type ClientTransport interface {
Call(ctx context.Context, route any, req any, opts ...ClientCallOptions) (any, error)
Consume(ctx context.Context, sseRoute any, req any, fn any, opts ...ClientConsumeOptions) error
}
nethttp.Attach's existing clientTransport struct implements BOTH
methods. Client.Consume is a thin dispatcher, exactly mirroring
Client.Call. opts is variadic (0 or 1 value) on both methods —
additive, backward-compatible with every existing 3-arg Call call site.
Part 2 — new EncodeVars-family methods close the param-derivation
wall. RouteHandle[Req,Resp]/SSERouteHandle[Req,Event] gained
EncodeVars/EncodeQueryVars/EncodeHeaderVars/EncodeCookieVars(req
Req) (map[string]string, error) — each a 1-line wrapper around
codex.EncodeVars(req, h.xMergeFields()...), mirroring
events.ChannelHandle[T].EncodeVars exactly. These exist as
monomorphized METHODS (not direct codex.EncodeVars calls) specifically
because a reflection-based caller with a runtime-only Req (like
clientTransport.Call/.Consume) cannot reflect a generic FREE function
— Go forbids it — but CAN call an exported method already monomorphized
for a concrete Req at compile time. This same wall previously forced
clientTransport.Call to pass a nil vars map to BuildPath, meaning
ANY path-templated route (or one with query/header/cookie params) could
never be called through Client.Call at all before this fix.
Part 3 — SSERouteHandle gained EffectiveEventFormats/
ResolveEventDecoder. Extracted from adapters/nethttp/binding.go's
previously free-function resolveSSEDecodeFormat (which now delegates to
these), mirroring events.ChannelHandle.EffectiveSubscribeFormats
exactly: EffectiveEventFormats(formats ...format.Format[Event])
[]format.Format[Event] resolves override > declared h.Formats;
ResolveEventDecoder(accept string, formats ...format.Format[Event])
func([]byte) (Event, error) picks the one decoder via the SAME
Accept-header-matching algorithm the server's own Accept-negotiation
uses. Behavior-neutral refactor — confirmed via the full existing SSE
test suite staying green unmodified.
Part 4 — security/credential ClientMW and format overrides.
clientTransport.Call/.Consume now call the EXISTING, non-generic
mergeCredentialHeaders/validateSecurityCredentials directly (no
reflection needed for the call itself, only to reach the plain-typed
Descriptor.Security/GlobalSecurity/ClientImplementations/
SecuritySchemes fields on the type-erased handle). New
ClientCallOptions{RequestFormats, ResponseFormats any}/
ClientConsumeOptions{Formats any} — plain, type-erased structs
(consistent with the codebase's existing CallOptions/ConsumeOptions
idiom, not a new functional-options pattern) — provide per-call format
overrides, resolved via reflect.Value type-comparison against the
reflected handle method's expected parameter type (the reflection
sibling of resolveCallFormat).
Part 5 — general-purpose ClientMW wrapping, via reflect.MakeFunc.
For Call, the network round-trip (encode → send → decode) is built as
a reflect.MakeFunc-constructed closure of type func(context.Context,
Req) (Resp, error), then wrapped by every attached general-purpose
ClientImplementation.Fn whose reflected type matches func(next
<that type>) <that type>, iterated OUTERMOST-in in attachment order —
mirrors wrapCallGeneral's static-generics contract exactly, just via
reflection instead of a type assertion. For Consume, the wrap applies
to the PER-EVENT dispatch (func(context.Context, Event) error) instead
— mirrors wrapSubscribeGeneral's identical per-message (not
per-connection) wrap boundary, the natural SSE analogue since Consume has
no single "Resp" the way Call does.
Part 6 — nethttp.Consumer/Consume/NewConsumer retired.
Confirmed via code that Consume[Req,Event](ctx, consumer, sseRoute, req,
fn, opts) was a 2-line delegate (sseRoute.ClientHandle() +
consumeSSE(...)) — structurally identical in role to the ALREADY-REMOVED
Call[Req,Resp]/Caller (Decision 6: demoted to unexported
call/caller, CallWithHandle remaining the sole escape hatch).
Deleted Consume and consumer.go's Consumer/NewConsumer/
WithBaseURL entirely — CallSSEAdapter (handle-based, plain
client,baseURL params) remains the SOLE public escape hatch, mirroring
CallWithHandle exactly, delegating to the unchanged consumeSSE/
consumeSSEOnce primitive.
Migration completed: examples/adapters-sse/main.go — every
Consume/Consumer call site migrated to rest.Client.Consume (via
nethttp.Attach); the one demo needing ConsumeOptions.OnError (the
codec-rejection demo, and the negative unauthenticated-request demo)
migrated to nethttp.CallSSEAdapter + ports.SourcePort instead, since
Client.Consume deliberately has no OnError/OnCredentialRejected/
MaxBackoff/ExtraHeaders hooks. adapters/nethttp/binding_test.go's 23
TestConsume_* tests migrated to call the unexported consumeSSE
primitive directly (same package, handle built via route.ClientHandle())
— a mechanical rename, zero behavior change, since Consume's body was
always just a 2-line delegate to this exact primitive. One test
(ExampleConsume) rewritten as ExampleClient_Consume, demonstrating the
new public API instead of the removed one. Swept dangling [Consume]/
[nethttp.Consume]/[Consumer] godoc bracket-links across
adapters/nethttp/{binding,stream_errors,clienttransport,serve_sse}.go
and api/rest/{builder,middleware}.go, plus prose references in
docs/features/sse-streaming.md, docs/concepts/api-contracts.md, and
this document's own earlier SSE-consumption addendum.
Structured errors / observer: no new error types. CallFormatOptError
(pre-existing) is reused for the new format-override type-mismatch case.
No observer changes — every existing RecordRequest/TraceObserver/
SecurityObserver call site was preserved exactly, only reached via a
new code path (the extended clientTransport.Call) or a new sibling
(clientTransport.Consume).
Tests: TestAttach_ClientCall_DerivesPathVars,
TestAttach_ClientCall_CredentialClientMW_Invoked,
TestAttach_ClientCall_GeneralPurposeClientMW_Wraps,
TestAttach_ClientCall_WithClientRequestResponseFormats_Overrides,
TestAttach_ClientCall_BackwardCompatible_NoOptsStillWorks,
TestAttach_ClientConsume_RoundTrip,
TestAttach_ClientConsume_DerivesPathVars,
TestAttach_ClientConsume_CredentialClientMW_Invoked,
TestAttach_ClientConsume_GeneralPurposeClientMW_Wraps,
TestAttach_ClientConsume_WithFormats_Overrides,
TestAttach_ClientConsume_NoTransportAttached_ReturnsNoClientTransportAttachedError,
TestAttach_ClientConsume_WrongRouteType_ReturnsTransportTypeMismatchError
(adapters/nethttp/clienttransport_test.go); 8 new
RouteHandle/SSERouteHandle.EncodeVars-family tests
(api/rest/builder_test.go).
Verified: gofmt/go build/go vet/go test all green repo-wide
(zero regressions across the full existing test suite, including the 23
migrated TestConsume_* tests); just check (staticcheck + gosec, 0
issues — one new G124 false-positive exclusion added to
gosec.config.json for adapters/nethttp/clienttransport.go, mirroring
the SAME pre-existing exclusion already applied to
client.go/binding.go/cookie.go for the identical outgoing-cookie
pattern); just examples (all pass, including the migrated
adapters-sse example, verified console output for both the
credential-supplied and unauthenticated-rejection demo paths).
Addendum 5: ServerTransport/ClientTransport, Server.Attach/.Serve(ctx), and Client/nethttp.Attach/.Call — the transport-agnostic Attach-then-call vocabulary¶
Folded in from docs/roadmap/transport-agnostic-serve-interface.md, now
deleted (its content lives here). This is the FOUNDATIONAL design
Addenda 1-4 above already assume exists (e.g. "Client.Attach's Observer +
ErrorPattern parity fix" references Client.Attach directly) — it was
never itself folded into this document until now, despite being core
REST design content. Spun out of a review of
docs/design/d-0002-pubsub-workflow-simplification.md's new
events.SubscriberServer/events.Client.Attach design, which surfaced a
genuine, pre-existing shape mismatch: REST's Serve(mux, builder) error
(as it existed at the time) took no ctx, WIRED every handler-bearing
route onto mux, and RETURNED IMMEDIATELY — actual request-serving
happened later via a separately-called http.Server.ListenAndServe()
entirely outside rest/nethttp's control. Pub/sub's
ServeSubscribers(ctx) error, by contrast, BLOCKS — it actually RUNS
every registered subscription until ctx is cancelled. A shared interface
literally named Serve(ctx) error could not mean the same thing in both
places without resolving this mismatch first.
Resolved via Option 1 (give REST a genuinely NEW, ADDITIVE, opt-in
BLOCKING variant, rather than forcing pub/sub's shape to match REST's
wire-only one): rest.Server gained an Attach(t ServerTransport) error
method and a blocking Serve(ctx context.Context) error method, mirroring
events.Client.Attach/.ServeSubscribers(ctx) exactly:
type ServerTransport interface {
Serve(ctx context.Context) error
}
func (b *Server) Attach(t ServerTransport) error // ServerTransportAlreadyAttachedError on a 2nd call
func (b *Server) Serve(ctx context.Context) error // NoServerTransportAttachedError if never attached; BLOCKS
nethttp.AttachMux(builder, mux, addr string) error / chi.AttachRouter(builder,
r, addr string) error each build an internal ServerTransport whose
Serve(ctx) wires every handler-bearing route (reusing the existing
wire-only dispatch internally, unchanged) onto mux/r, then constructs
its OWN &http.Server{Addr: addr, Handler: mux} and calls
ListenAndServe(), shutting down gracefully via http.Server.Shutdown on
ctx.Done().
Client side mirrors events.Client exactly — NOT an adapter-level
nethttp.Caller method, per a direct user request ("I want a rest.Client
and a rest.Server ... to mirror the exact design of the event api"):
type ClientTransport interface {
Call(ctx context.Context, route any, req any) (any, error)
}
type Client struct { /* unexported: mu sync.RWMutex; transport ClientTransport */ }
func NewClient() *Client
func (c *Client) Attach(t ClientTransport) error // ClientTransportAlreadyAttachedError on a 2nd call
func (c *Client) Call(ctx context.Context, route any, req any) (any, error) // NoClientTransportAttachedError if never attached
nethttp.Attach(client *rest.Client, httpClient *http.Client, baseURL
string) error builds an internal ClientTransport wrapping the
(then-existing) nethttp.Caller/Call[Req,Resp] — mirrors
zeromq.Attach/mqtt5.Attach wrapping their own *caller internally.
route/req are any (Go forbids a method introducing its own
[Req,Resp] type parameters) — the internal transport recovers the
concrete types via reflection against the route's already-concrete
Decode/Encode/BuildPath closures, the SAME technique
events.Client.Publish/.Subscribe uses; a type mismatch surfaces as
rest.TransportTypeMismatchError at call time, not a compile error — the
same explicit, scoped trade-off pub/sub's Decision 5 accepted.
rest.Client carries NO spec/registry (unlike rest.Server) — a pure
connection/transport holder. chi has no client side to unify (confirmed
via code — chi is server-routing-only, no Caller/Call exists in
adapters/chi at all); this design applies to nethttp only.
Resulting workflow, mirroring events.Client symbol-for-symbol:
client := rest.NewClient()
_ = nethttp.Attach(client, httpClient, baseURL)
respAny, err := client.Call(ctx, getUserRoute, req)
resp := respAny.(GetUserResp)
Both Server.Attach/.Serve(ctx) and today's existing wire-only
dispatch were KEPT — this was purely additive, zero regression at the
time it shipped. A LATER step (tracked as Decision 6 in
docs/design/d-0002-pubsub-workflow-simplification.md, executed as REST
sub-phases 17e/17f of that plan) went further: Serve/ServeSSE
(the wire-only dispatch functions) were UNEXPORTED to serve/serveSSE,
called internally by AttachMux/AttachRouter's Serve(ctx) — making
AttachMux/AttachRouter the SOLE public server-side entry point today,
not merely an additive alternative to a still-public Serve. See this
document's own "Decision: Serve is the only public server-side entry
point" above for the (now superseded, but historically accurate as an
intermediate step) state that decision describes, and its own "Superseded
by Addendum 5" pointer.
Structured errors / observer: ServerTransportAlreadyAttachedError,
NoServerTransportAttachedError, ClientTransportAlreadyAttachedError,
NoClientTransportAttachedError, TransportTypeMismatchError — all
errors.As-navigable + slog.LogValuer, mirroring events.Client's
identically-shaped error set exactly. No new observer hooks — Client.Call
resolves stats.Observer from ctx the same way nethttp.Call/CallHandle
already did.
Verified at the time: gofmt/go build/go vet/go test/just
check/just examples all green.
Addendum 6: Client.Call/Client.Consume's GlobalSecurity dual-mode dispatch¶
Folded in from docs/roadmap/rest-client-call-global-security.md, now
deleted (its content lives here).
The gap. Route.ClientHandle()/SSERoute.ClientHandle() explicitly
"source no Builder" — the handle they return always has
GlobalSecurity: nil, regardless of whether the same route was also
registered against a *Server with AddGlobalSecurity(...) declared.
resolveClientSecurity (adapters/nethttp/clienttransport.go) already
contained the correct fallback (if secReqs == nil { secReqs, _ =
elem.FieldByName("GlobalSecurity")... }), but it only fires when the
handle it inspects genuinely carries a populated GlobalSecurity field —
i.e. when the dispatcher was handed an already-registered *RouteHandle
rather than a fresh ClientHandle() built from a raw Route.
Client.Call's half was already fixed, as an unplanned side effect.
clientTransport.Call dispatches through recoverClientRouteHandleValue
(built during the earlier, unrelated Phase 5a work, to support
bare-handle callers like adapters/mcprest), which already implements a
dual-mode type-switch: a raw, unregistered Route[Req,Resp] derives
ClientHandle() fresh (GlobalSecurity stays invisible — the accepted,
documented limitation); an already-registered *RouteHandle[Req,Resp]
(obtained via Route.RegisterHandle(server) — not Route.Register
(server), which returns only an error and discards the handle) is used
as-is, with GlobalSecurity populated and visible to
resolveClientSecurity's existing fallback. This was confirmed to work
via code trace, but had zero test coverage of the scenario — every
existing AddGlobalSecurity-involving test also declared per-route
Security on the same route, so the fallback path had never actually been
exercised.
Client.Consume (SSE) had the identical, unfixed gap.
clientTransport.Consume had no dual-mode branch at all — it
type-checked only for a raw SSERoute[...] and unconditionally called
.ClientHandle() fresh, every time, even though SSERouteHandle
.GlobalSecurity exists and is populated at registration time exactly
like RouteHandle's.
Confirmed N/A for api/events and api/reqreply — neither has this
bug class:
api/eventsis architecturally immune by design:events.Client.AddGlobalSecuritylives onClientitself (events unifies builder+dispatcher into one type, unlike REST's Server/Client split).Client.Subscribe/Publishresolve handles via a sharedrecoverHandle(kind, anyAny, client *events.Client)helper (mirrored acrossadapters/mqtt5/zeromq/mqtt), which callssub.Handle (client)— passing the live*events.Clientreference on every single dispatch, never a cached/disconnected handle. There is no "stale handle built without a client reference" failure mode here at all;GlobalSecurityis always current.api/reqreplyalready had this fix as its reference implementation.reqreply.Clienthas only three methods (Attach/Call/CallAsync— no SSE-like second entry point);CallAsync's own doc comment confirms it sharesCall's dual-mode dispatch. At the adapter level,adapters/mqtt5/reqreply_transport.go'srecoverRouteHandleValueis in fact the original mechanismnethttp'srecoverClientRouteHandleValuewas modeled after — reqreply is the source, not a follower, here.
What shipped.
- Two regression tests proving
Call's already-shipped mechanism:TestCall_GlobalSecurityOnly_RouteHandle_CredentialInvoked(a route with no per-route Security, aServerwithAddGlobalSecurity, registered viaRegisterHandle, called viaclient.Call(ctx, handle, req)— the credential-providing general-purposeClientMWFn is invoked and succeeds) andTestCall_GlobalSecurityOnly_RawRoute_CredentialNotInvoked(same setup via the raw, unregisteredRoute— credential Fn is not invoked, confirming the accepted limitation is preserved). Both test servers are built directly viahttptest.NewServer, not viaserve/rest.Server.Serve— real server-side dispatch runsrest.CheckCoverageat Serve time, which requires a SERVER-sideHandleMWsatisfying any scheme inGlobalSecurity(a separate, orthogonal, server-side concern), and attaching a scheme-gatedClientMWwould itself require a matching.Use()declaration (UnknownMiddlewareImplementationError), which would populate per-route Security and defeat the point of the test. The credentialClientMWis attached general-purpose (nilmw, noSatisfies) — it only runs whensecReqsis non-empty (mergeCredentialHeadersis gated bylen(secReqs) > 0inclientTransport.Call), which is exactly theGlobalSecurityfallback being tested. - A new
recoverClientSSERouteHandleValuehelper inadapters/nethttp/clienttransport.go, mirroringrecoverClientRouteHandleValueexactly forSSERoute/SSERouteHandle, wired intoConsumein place of its previous single-branch check — the one real code change this addendum made. Nothing downstream (consumeOnce,resolveClientSecurity,mergeCredentialHeaders) needed any change — both already operate generically against the resolved handle's fields via reflection. - Two mirrored regression tests for
Consume(TestConsume_GlobalSecurityOnly_RouteHandle_CredentialInvoked/_RawRoute_CredentialNotInvoked) — confirmed to genuinely fail against the pre-fix code (verified via a temporary revert) and pass against the fix, not just compile. - Documentation:
Client.Call/Client.Consume's own godoc, and theClientTransportinterface'sCall/Consumedoc comments, now state the dual-mode acceptance contract explicitly — a future secondClientTransportimplementer knows this is part of the interface contract, not annethttp-specific accident.
Verified: gofmt/go build/go vet/go test ./adapters/nethttp/...
./api/rest/... (then full go test ./...)/just check all green.
Addendum 7: retiring the legacy client-side SECURITY-pairing mechanism¶
A review round investigating api/rest's security schemes found
Route.ClientMW's legacy Security-pairing path (a bare
middleware.Middleware carrying a Security declaration, paired via
.Use(declMw).ClientMW(&declMw, func(ctx, secReqs) (http.Header, error)))
had real, structural limitations the newer, bound mechanism
(Route.ClientBoundMW + BoundSecurityClientMiddleware, from
d-0003-codec-declared-middlewares.md's Addendum 7) does not share: it
can only naturally express HEADER-location credentials (a Cookie-location
credential requires an undocumented manual "Cookie: name=value"
header-string construction; a Query-location credential is structurally
impossible via its http.Header-only return type), and — found in the
SAME investigation — its "empty return means no credential needed"
convention made a genuine client-side codec-validation gap (an invalid
credential supplied via the newer bound axis was never checked at all)
structurally indistinguishable from an intentional opt-out.
Given this project has exactly one user/maintainer and no external compatibility obligation, the decision this round was to retire the legacy SECURITY-pairing path OUTRIGHT, rather than carry it forward alongside its replacement indefinitely:
Route.ClientMW/SSERoute.ClientMWnow REJECT a Security-carryingmwvia a newLegacySecurityClientMWRemovedError(mirroringMiddlewareMisattachedError's existing shape/precedent exactly) — supplying a client-side credential now goes exclusively throughRoute.ClientBoundMW+BoundSecurityClientMiddleware.Route.HandleMW/SSERoute.HandleMW(the SERVER-side mirror, credential VERIFICATION rather than supply) were explicitly NOT touched — the header-only limitation and the nil/empty-ambiguity gap are both CLIENT-SUPPLY-specific concerns; the server-side legacy pairing has no analogous structural problem, so it remains a fully valid, supported mechanism alongsideHandleBoundMW.ClientMW's GENERAL-PURPOSE (non-security,mw == nil) use is completely UNCHANGED —mergeCredentialHeaders/ConflictingCredentialHeaderErrorremain exactly as they were; only the Security-pairing BRANCH insideClientMWwas removed.- Scope:
api/restonly —api/events/api/reqreplyhave the identical legacy pattern (SubscribeMW/PublishMW,HandleMW/ClientMWrespectively) but were explicitly left untouched this round; retiring their own legacy mechanisms (if ever desired) is a SEPARATE, independent decision. - A genuine, pre-existing gap surfaced by this investigation, NOT
fixed: the replacement mechanism (
dispatchClientMiddlewareIn, backingClientBoundMW) has NO equivalent of the legacy mechanism's Satisfies-based filtering (an implementation paired against an unrelated scheme is correctly skipped) OR its same-key conflict detection (ConflictingCredentialHeaderError) — it runs every attachedClientMiddlewareHandlerunconditionally and silently lets the LAST one win on a shared header/cookie/query key. This is flagged as a follow-up finding, out of scope for "retire the legacy mechanism" specifically (fixing it is an ADDITIVE feature change to the bound dispatch, not a removal) — two tests whose exact scenario has no bound-mechanism equivalent (TestCall_ClientMWSatisfiesGating_UnrelatedImplNotRun,TestCall_CredentialFunc_ReturnsNilHeader_SkipsValidation) were deleted rather than misleadingly "migrated."
Migration summary: ~20 test call sites across
api/rest/middleware_test.go, adapters/nethttp/{caller,client,
clienttransport,binding}_test.go either migrated to
ClientBoundMW/BoundSecurityClientMiddleware (preserving their
original behavioral coverage: credential invoked every call, re-derived
per SSE reconnect, OnCredentialRejected fires on 401, client-side
codec-format validation rejects a malformed credential) or deleted with
an explanatory note (where the exact legacy behavior has no bound-
mechanism equivalent, per the gap above). adapters/nethttp/binding.go's
own, separate consumeSSE dispatch function (used by ports' binding
adapters, distinct from clienttransport.go's consumeOnce) needed the
SAME credentialProduced-based fix a prior round applied to
clienttransport.go — found via this migration surfacing a test failure,
not a static read.
Verified: gofmt/go build/go test ./.../just check all green;
every regression test confirmed to genuinely exercise its claimed
scenario (not just compile) by tracing raw byte content back to the
pre-change file for 2 tests whose header values a terminal-display layer
had redacted to look like "******".
Addendum 8: extending the retirement to HandleMW's server-side pairing, across all 3 api packages¶
A follow-up question — "can middleware.SecurityScheme be dropped
entirely?" — revisited Addendum 7's explicit scoping decision to leave
Route.HandleMW's server-side legacy pairing untouched. Investigation
(originally tracked in a roadmap doc, docs/roadmap/
retire-legacy-security-middleware.md — fully implemented and removed
once this addendum absorbed its full design rationale and final,
as-implemented summary, per this repo's roadmap-doc lifecycle policy)
found:
middleware.SecurityScheme's ONE remaining distinguishing feature (after Addendum 7 retired its client-supply role) is letting ONE Go value be.Use()'d identically acrossapi/rest,api/events, ANDapi/reqreplyin the same program. A full-repo search found zero actual uses of this capability — every.Use(middleware.SecurityScheme(...))call site declares and consumes its scheme within exactly ONE api package.- Despite that,
middleware.SecurityScheme/rest.FromSecurityScheme(and events/reqreply's equivalents) could NOT simply be deleted: they have a SECOND, independent, perfectly valid use — pure DECLARE-ONLY.Use()(noHandleMW/ClientMW/SubscribeMW/PublishMWpairing at all), confirmed in active use by many existing tests across all 3 api packages andadapters/mqtt5/adapters/mqtt. Deleting these bridge functions would have broken all of that legitimate, unrelated usage for zero benefit.
Decision: extend Addendum 7's retirement to the SERVER side (HandleMW
in api/rest/api/reqreply, SubscribeMW in api/events), across all
3 api packages — but KEEP middleware.SecurityScheme/FromSecurityScheme
(and the underlying middleware.Middleware/Security field) exactly as
they are, since they remain the only way to express the declare-only and
cross-pattern-value-sharing use cases.
Route.HandleMW/SSERoute.HandleMW(api/rest),Subscriber.SubscribeMW/Publisher.PublishMW(api/events),Route.HandleMW/Route.ClientMW(api/reqreply) now ALL reject a Security-carryingmwvia a new, per-package error type (LegacySecurityHandleMWRemovedErrorinapi/rest,LegacySecurityMWRemovedErrorinapi/events/api/reqreply— naming differs slightly per package's own existing convention, same shape asMiddlewareMisattachedError/LegacySecurityClientMWRemovedError).mw == nilgeneral-purpose use is completely UNCHANGED in all 3 packages.- Declaring AND implementing a security scheme now goes exclusively
through each package's already-shipped Bound mechanism:
HandleBoundMW/ClientBoundMW+BoundSecurityMiddleware/BoundSecurityClientMiddleware(api/rest/api/reqreply),SubscribeBoundMW/PublishBoundMW+BoundSecuritySubscribeMiddleware/BoundSecurityPublishMiddleware(api/events) — these fuse declare+implement into ONE call, eliminating the "paired against an undeclared scheme" failure mode entirely (it is now structurally impossible, not merely checked-for). - A Security-carrying Bound SERVER attachment and a Security-carrying
Bound CLIENT attachment for the SAME scheme name cannot share ONE
route/channel value (both default to the same auto-generated
Declaration Name) — this was already true for the general Bound
mechanism (documented on
BoundMiddleware's own doc comment) and is now exercised more widely by this round's test migrations; build two separate route/channel values (or use a differently-named scheme for whichever side merely satisfies server-side coverage) when a test needs both roles. api/eventshad ZERO production or test call sites exercising its legacySubscribeMW/PublishMWSecurity-pairing shape before this round — its retirement was closer to a pure deletion than a migration.api/rest(~30 test call sites) andapi/reqreply(4 test files, plusadapters/mqtt5/adapters/zeromq) required substantive test migration, following Addendum 7's exact discipline: migrate genuinely valuable coverage to the Bound mechanism; delete-with-an-explanatory- note any test whose EXACT legacy-only semantics (e.g. twoClientMWcalls for the same scheme getting distinct auto-generated names — now impossible, since a second same-scheme Bound attachment is rejected as a duplicate rather than renamed) has no Bound-mechanism equivalent.- Checked for a server-side analogue of Addendum 7's "no Satisfies-
gating/conflict-detection in the replacement" gap: NONE found — the
server-side grant-merging helper (
adapters/internal/httpsecurity. MergeMiddlewareHandlerGrants) already runs BOTH the legacyServerImplementation-based path (CollectGrantsReflect) and the Bound-mechanism path (handle.MiddlewareHandlers) through the SAME Satisfies-gated merge and a singleCheckScopescall — this was already correct before this round, confirmed by re-reading rather than re-derived from scratch.
Scope correction, made mid-implementation: the original plan proposed
deleting middleware.SecurityScheme/FromSecurityScheme entirely — this
was revised once the declare-only use case's real, active callers were
found (see above). middleware.Middleware's own package doc comment and
SecurityScheme's constructor doc comment were both updated to describe
the CURRENT (post-Addendum-8) state accurately, rather than leaving stale
"pair it with a ServerImplementation supplied separately" language that
no longer describes a working mechanism.
Verified: gofmt/go build ./.../go test ./.../just check/all
examples all green, per this doc's standing verification checklist.
Addendum 9: FromSecurityScheme/SharedMiddleware fully removed (supersedes Addendum 8's "scope correction")¶
Addendum 8 above kept middleware.SecurityScheme/FromSecurityScheme (the
declare-only legacy type's public constructors) because a prior round
found what it believed were real, active callers needing the declare-only/
no-enforcement use case. A LATER, independent, from-scratch investigation
(docs/design/d-0009-internalize-shared-mechanics.md) found this was no longer
true: by that point, EVERY real caller (including examples/go-edge-models,
the originally-cited motivating case) had migrated to the FUSED,
codec-backed rest.SecurityMiddleware/events.SecurityMiddleware/
reqreply.SecurityMiddleware constructors used .Use()-only with no Fn —
which already fully covers the "declare a requirement, enforce nothing in
this codebase" case. The ONLY remaining references to rest.SharedMiddleware
anywhere in the repo were STALE COMMENT PROSE (artifacts of an earlier,
unrelated mechanical rename), not real code.
Given this, rest.SharedMiddleware/events.SharedMiddleware/
reqreply.SharedMiddleware (the per-pattern public aliases) and
rest.FromSecurityScheme/events.FromSecurityScheme were REMOVED
entirely — along with the now-fully-unreachable legacy HandleMW/ClientMW/
SubscribeMW/PublishMW security-rejection path (LegacySecurityHandleMWRemovedError/
LegacySecurityClientMWRemovedError/LegacySecurityMWRemovedError and
their RouteOpt wrappers) and a provably-dead branch inside each pattern's
buildServerImplementation. The underlying internal/middleware.Middleware{Name,
Security} type itself is UNCHANGED and NOT removed — it remains essential
INTERNAL bridging/synthesis currency every pattern's own .Use() path
constructs internally from a codec-backed Middleware[In,Out]'s Security
declaration; only the PUBLIC door letting an external user construct one
directly was closed. See docs/design/d-0009-internalize-shared-mechanics.md
for the full investigation and removal record.