API Contracts¶
See also:
api/rest·api/events·api/mcp· Declaring APIs and Ports (constructor/escape-hatch comparison across all six boundaries)
Layer 2 builds on codecs to declare typed API contracts as values. The same declaration drives runtime behaviour (decode/encode/validate) and spec generation (OpenAPI, AsyncAPI, MCP schema) — no duplication.
Workflow: declare → register → handle¶
NewRoute / NewChannel / NewTool
│
└─ .Register(builder) ──→ Handle ──→ adapter (nethttp / mqtt / mcpgo)
└──→ builder.Spec() ──→ OpenAPI / AsyncAPI / MCP JSON
Design principle: one struct, one call¶
For any API-contract boundary with a request/response shape (or a duplex
role pair — publisher/subscriber, requestor/replier, client/server), a
caller on either side should be able to do the entire encode-or-decode
direction with one struct value in (or out), one call — no manual
map-building, no manual header/cookie/query/topic stitching, in the common
case. The struct itself can be built however the caller likes: a plain
literal, or their own New... constructor function — ordinary Go, no
framework sugar required for that part.
REST (api/rest + adapters/nethttp/chi) delivers this today, both
directions, both roles:
// Client: ONE struct in, ONE struct out.
handle := getUserActivity.ClientHandle()
transport := nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: client, BaseURL: baseURL})
req := GetUserActivityReq{ID: userID, Filter: "logins"} // literal, or a New... factory
resp, err := rest.CallWithTransport(ctx, transport, handle, req, rest.ClientCallOptions{})
// resp is fully decoded AND merged — body + response header/cookie fields
// (e.g. resp.RequestID) are all populated. Nothing else to do.
// Server: ONE struct in, ONE struct out.
route := getUserActivity.WithHandler(func(ctx context.Context, req GetUserActivityReq) (User, error) {
u := lookup(req.ID) // req arrives fully merged: path+query+header+cookie+body
u.RequestID = traceID() // just set the field — no w.Header().Set() call
return u, nil // adapter auto-encodes body AND response merge fields
})
route.Register(builder)
builder.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: addr}))
go func() { _ = builder.Serve(ctx) }()
This is made possible by declare-once constructors
(rest.NewPathParam[T]/NewRequiredQueryParam[T]/etc. for the request,
NewRequiredResponseHeaderParam[Resp]/etc. for the response) that register
BOTH the spec Param (still driving OpenAPI generation) AND a merge field in
one call — see Concept: Codec — Reusing Field declarations
for the underlying mechanism, and the REST API feature
for the full reference. Plain, validate-only Param structs remain available
as the escape hatch for params a handler never reads/writes directly — a
route can freely mix both styles.
The promise also holds for NESTED structs (Req/Resp composed from
sub-structs like Meta/Payload instead of flat fields) and for non-JSON
body formats (Gob, binary, or any custom format.Format[T]) — merge-field
get/set are plain closures, not reflection, so nested access needs no
framework change; and body decode/encode is orthogonal to var-merge, so any
format composes. See
REST API — Nested structs & binary body formats
and examples/rest-nested-binary for the full runnable version.
Shipped for api/events (pub/sub) and api/reqreply (req/reply) too:
events.NewTopicParam[T]/ChannelHandle.DecodeMerged/events.PublishHandle
(+ mqtt5.NewPublishTransport) and reqreply.NewTopicParam[T]/RouteHandle.DecodeMerged
(auto-merged by each adapter's NewClientTransport/NewServerTransport dispatch)
close the same loop for MQTT pub/sub and request/reply. Req/reply routes can
also declare dedicated, RUNTIME-WIRED error-reply channels via
reqreply.ErrorPattern on NewRoute(...) — one declaration drives both the
AsyncAPI reply-error channel/operation AND the actual mqtt5/zeromq server-transport
reply behavior (matched errors get a typed codec-backed payload instead of a
plain-text string). reqreply.ErrorReplyMeta remains available for
spec-only declarations with no runtime dispatch — see
Feature: Event Channels & MQTT
and the AsyncAPI guide.
Shipped for the ports.Pattern BINDING LAYER too:
DrainCallAdapter/PublishAdapter/CallAdapter across
nethttp/mqtt5/zeromq/mqtt delegate to CallHandle/PublishHandle
and derive vars PER-ITEM whenever their Vars option is left nil —
streaming/port-based callers get the same one-struct convenience as
calling the transport function directly. Set Vars to a non-nil map
(even an empty one) to keep a single static vars map for every item in the
stream instead — the escape hatch, unchanged from before this was added.
adapters/zeromq's own pub/sub Subscribe/Publish and adapters/mqtt
(v3) events also received the same merge-field wiring adapters/mqtt5
had first. A shared, module-internal internal/templatematch package now
backs the topic/path-matching core for mqtt/mqtt5/zeromq/ports.File.
SSE and WebSocket now ship the same convenience for long-lived connections
via connection-level merge constructors (rest.NewRequiredSSEEventParam,
ports.NewRequiredSocketInParam, ports.NewRequiredSocketOutParam) with
adapter-side auto-merge on each event/frame.
Where this convenience is shipped¶
Every boundary below has (1) a declare-once constructor that registers
BOTH a spec Param/key/path-var AND a merge field in one call, and (2) a
single-call convenience wrapper on the encode side, with automatic merge
wiring on the decode side. This is the reference matrix — every NEW
adapter/port should match this shape (see the add-a-new-adapter skill's
Step 5b).
| Boundary | Declare-once constructor | Single-call convenience | Reference |
|---|---|---|---|
REST (api/rest + adapters/nethttp/chi) |
rest.NewPathParam[T]/NewRequiredQueryParam[T]/etc. + NewRequiredResponseHeaderParam[Resp]/etc. |
rest.Client.Call/rest.CallWithTransport (client) + each adapter's internal serve dispatch, wired via Server.Attach(NewServerTransport(...)) (server) |
Feature: REST API |
REST SSE (api/rest + adapters/nethttp/chi) |
rest.NewRequiredSSEEventParam[T]/NewOptionalSSEEventParam[T] |
send(event) on SSEHandler/RegisterSSE auto-merges path/query/header/cookie vars into each event |
Feature: SSE & Streaming |
Events pub/sub (api/events + adapters/mqtt/mqtt5/zeromq) |
events.NewTopicParam[T] |
events.PublishHandle + each adapter's NewPublishTransport (publish) + events.SubscribeHandle + NewSubscribeTransport auto-merge (subscribe) |
Feature: Event Channels & MQTT |
Req/reply (api/reqreply + adapters/mqtt5/zeromq) |
reqreply.NewTopicParam[T] (Req-side only) |
Client.Attach(NewClientTransport(...)) auto-merge (client) + Server.Attach(NewServerTransport(...)) auto-merge (server) |
MQTT 5 Guide — Request/Reply |
WebSocket (ports.DuplexPort + adapters/websocket) |
ports.NewRequiredSocketInParam[T]/NewOptionalSocketInParam[T] + ports.NewRequiredSocketOutParam[T]/NewOptionalSocketOutParam[T] on SocketPattern |
DuplexSocketAdapter/IngestSocketAdapter/BroadcastSocketAdapter auto-merge connection vars into inbound/outbound payload structs |
Feature: WebSocket |
ports.Pattern binding layer (nethttp/mqtt5/zeromq/mqtt) |
n/a — delegates to the underlying transport's constructors above | DrainCallAdapter/PublishAdapter/CallAdapter derive vars per-item when Vars is left nil |
Feature: Ports |
MCP Resources (api/mcp + adapters/mcpgo) |
URI {varName} template — ResourceHandle[V,T] built directly on codex.Template[V] (a real typed vars container, not merge-capable into T — see below) |
ResourceHandle.ExtractURIVars + mcpgo.RegisterResourceWithVars/ResourceHandlerWithVars (additive; RegisterResource/ResourceHandlerFunc unchanged) |
Feature: MCP Server |
File I/O (ports.File + adapters/file) |
ports.NewFilePathParam[T] |
ports.WriteHandle (write) + File.ReadMerged auto-merge (read, wired into ReadEachAdapter/ReadAdapter; DrainWriteFileAdapter's varsFor may be nil) |
Feature: Ports |
Cache (ports.Cache + adapters/redis) |
ports.NewCacheKeyParam[T] |
redis.SetHandle (write) + redis.GetMerged auto-merge (read, wired into GetAdapter; SetAdapter/DrainSetAdapter's keyFn may be nil) |
Feature: Redis Cache Adapter |
Not merge-capable, by explicit design decision — not a gap:
MCP Resources' URI vars are a real typed V (via codex.Template[V]) but
never merge into the resource's output type T — T is
application-produced, not wire-decoded, so "merge after the handler runs"
is a narrower win than elsewhere; MCP Prompts' args are validated
(ValidateArgs) but handed to the app as a raw map[string]string, not a
merged struct.
REST routes (api/rest)¶
var createUser = rest.NewRoute[CreateUserReq, User](
"POST", "/users",
createUserReqCodec, userCodec,
rest.RouteMeta{OperationID: "createUser", Summary: "Create a user"},
rest.PathParam{Name: "id"}.WithCodec(uuidCodec),
)
handle, _ := createUser.RegisterHandle(builder)
// handle.Decode(body) → typed CreateUserReq, validated
// handle.Encode(user) → JSON bytes
// handle.BuildPath(vars) → concrete path, validates params
// builder.OpenAPISpec() → full OpenAPI 3.1 document
For the HTTP client side, rest.Client.Call (bound via Client.Attach(nethttp.NewClientTransport(...)))
takes the rest.Route value directly — no builder, no separate handle
needed:
client := rest.NewClient()
client.Attach(nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: http.DefaultClient, BaseURL: serverURL}))
userAny, err := client.Call(ctx, createUser, req)
user := userAny.(User)
Why SSE lives in api/rest, not api/events¶
Server-Sent Events (rest.NewSSERoute) isn't "RESTful" by the strict
CRUD/cacheable-resource definition — but its WIRE PROTOCOL is plain HTTP:
a normal GET request whose response never closes, streaming
text/event-stream chunks instead of one JSON body. Because the
transport is ordinary HTTP, an SSE route reuses api/rest's ENTIRE
existing toolchain — path/query/header params, security schemes,
OpenAPI generation, and (on the client side) rest.Client.Consume/
nethttp.CallSSEAdapter — the STRICT client-side counterparts to
rest.Client.Call/rest.CallWithTransport (see
docs/design/d-0001-rest-middleware-workflow-simplification.md's Addendum 4), for a stream of many
events instead of one response — with zero new machinery, instead of needing an
AsyncAPI-shaped channel/message model built from scratch. This
also matches the wider OpenAPI-ecosystem convention: most tooling
(Swagger, FastAPI, NestJS, etc.) documents an SSE endpoint as a plain
GET operation with a streamed content type, not as an AsyncAPI channel.
AsyncAPI's channel/message model exists for genuinely broker/pub-sub-style
protocols (MQTT, Kafka, AMQP, WebSocket) where the interaction ISN'T a
single request/response pair. A DUAL description (OpenAPI as the primary
spec, plus an optional AsyncAPI channel describing the same event payload)
is technically possible in this codebase's renderer today —
render/asyncapi's Server.Protocol field already accepts a bare string
like "https" (the same precedent docs/roadmap/webhook-adapter.md uses
for its own HTTP-based case) — but this is NOT wired up for SSE: the
HTTP-native OpenAPI description is already complete and sufficient, so no
second spec surface is needed.
See rest.NewSSERoute's godoc
and Feature: SSE & Streaming for the full
API.
Event channels (api/events)¶
var readingsChannel = events.NewChannel[SensorReading](
"sensors/{sensorID}/readings",
sensorReadingCodec,
events.Publish{OperationID: "publishSensorReading"},
events.TopicParam{Name: "sensorID"}.WithCodec(uuidCodec),
)
handle, _ := readingsChannel.
WithSubscribe(events.Subscribe{OperationID: "receiveSensorReading"}).
Handle(builder)
// handle.Decode(payload) → typed SensorReading
// handle.BuildTopic(vars) → concrete topic, validates params
// builder.AsyncAPISpec() → full AsyncAPI 3.0 document
Reusing a Topic/Path/FilePathTemplate¶
The plain-string form above ("sensors/{sensorID}/readings" passed
directly to NewChannel) is the default and stays that way — nothing
below changes it. If the SAME topic template and TopicParam end up
declared for two or more channels of DIFFERENT payload types (e.g. one
topic family carrying several event types), extract the shape once as a
Topic value and reuse it:
var sensorReadingsTopic = events.NewTopic("sensors/{sensorID}/readings",
events.TopicParam{Name: "sensorID"}.WithCodec(uuidCodec),
)
var temperatureChannel = events.NewChannelFromTopic(sensorReadingsTopic, temperatureCodec,
events.Subscribe{OperationID: "receiveTemperature"},
)
var humidityChannel = events.NewChannelFromTopic(sensorReadingsTopic, humidityCodec,
events.Subscribe{OperationID: "receiveHumidity"},
)
// Standalone use — no payload codec involved at all:
topic, err := sensorReadingsTopic.BuildTopic(map[string]string{
"sensorID": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
})
temperatureChannel/humidityChannel are byte-for-byte identical to
channels declared via NewChannel with the same template and
TopicParam passed inline — the {sensorID} variable's name and codec
now have exactly one source of truth instead of being copy-pasted per
channel. rest.Path/reqreply.Topic/mcp Template+NewResourceFromTemplate/
ports.FilePathTemplate/ports.DirPathTemplate provide the identical
convenience for api/rest routes, api/reqreply routes, api/mcp
resources, and ports.File/ports.Dir declarations respectively. See
Guide: Declarative wire-format vocabulary
for the full recipe, and Declaring APIs and Ports
for the full comparison across all six boundaries.
MCP tools (api/mcp)¶
searchTool := mcp.NewTool[SearchReq, SearchResp](
"search", searchReqCodec, searchRespCodec,
mcp.ToolMeta{Description: "Search the knowledge base"},
)
handle := searchTool.Register(builder)
// handle.Decode(args) → typed SearchReq
// handle.Encode(result) → JSON bytes for MCP protocol
// builder.MCPSpec() → MCP tool manifest JSON
See also¶
- Feature: REST API & HTTP Adapters — full REST builder reference
- Feature: HTTP Client —
Call,ClientHandle, typed client - Feature: Event Channels & MQTT — full events builder reference
- Feature: MCP Server — Tools, Resources, Prompts
- Feature: API Builders (overview) — builder pattern, template-transparent validation, spec generation