Skip to content

API Contracts

See also: api/rest · api/events · api/mcp

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()
req := GetUserActivityReq{ID: userID, Filter: "logins"} // literal, or a New... factory
resp, err := nethttp.CallHandle(ctx, client, baseURL, handle, req, nethttp.CallOptions{})
// 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.
nethttp.Register(mux, handle, 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
}, nethttp.Options{})

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/mqtt5.PublishHandle and reqreply.NewTopicParam[T]/RouteHandle.DecodeMerged/mqtt5.CallHandle 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 Serve 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. nethttp.CallHandle (client) + Handler auto-merge (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] mqtt5.PublishHandle/zeromq.PublishHandle/mqtt.PublishHandle (publish) + Subscribe/SubscribeHandler auto-merge (subscribe) Feature: Event Channels & MQTT
Req/reply (api/reqreply + adapters/mqtt5/zeromq) reqreply.NewTopicParam[T] (Req-side only) mqtt5.CallHandle/zeromq.CallHandle (client) + mqtt5.Serve 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 (validate-only ResourceParam, not merge-capable — 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 use validate-only ResourceParam (no getter/setter merge into the resource's output type — 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.Register(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, use ClientHandle() — no builder needed:

handle := createUser.ClientHandle()
user, err := nethttp.Call(ctx, http.DefaultClient, serverURL, handle, req, nil, opts)

Event channels (api/events)

var readingsChannel = events.NewChannel[SensorReading](
    "sensors/{sensorID}/readings",
    sensorReadingCodec,
    events.Subscribe{OperationID: "receiveSensorReading"},
    events.Publish{OperationID: "publishSensorReading"},
    events.TopicParam{Name: "sensorID"}.WithCodec(uuidCodec),
)

handle, _ := readingsChannel.Register(builder)
// handle.Decode(payload)      → typed SensorReading
// handle.BuildTopic(vars)     → concrete topic, validates params
// builder.AsyncAPISpec()      → full AsyncAPI 3.0 document

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