Typed Endpoints
When one Go service calls another, the request and the response are described twice: once by the handler that serves them, and once by the code that builds the URL, sets the headers and decodes the answer. The two drift. A query parameter is renamed on one side, a field moves from the body to a header, and the first thing that notices is production.
muzak.NewEndpoint declares an operation once, as a value: its method, its path template,
the route options that describe it, and its In and Out types. The service implements it
with Router.Implement, its callers call it with Endpoint.Call, and both sides are typed
by the same declaration.
// Package userapi is imported by the users service and by everything that
// calls it. It holds the shapes and the endpoints, and nothing else.
package userapi
type GetUserIn struct {
ID string `path:"id"`
Expand []string `query:"expand"`
Tenant string `header:"X-Tenant"`
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
var GetUser = muzak.NewEndpoint[GetUserIn, User](http.MethodGet, "/users/{id}",
muzak.Summary("Fetch a user"),
muzak.WithTags("users"))
Implementing an endpoint
Router.Implement registers the handler exactly as Router.Handle with the endpoint's
method and path would: the same route, the same binding, the same document. The endpoint's
own route options apply first and the ones given to Implement after them, so a guard, a
rate limit or a timeout that belongs to this deployment is added where the endpoint is
implemented, and the summary and tags that belong to the operation travel with it.
r.Implement(userapi.GetUser, handlers.GetUser,
muzak.WithDependencies(core.RequireServiceToken),
muzak.Timeout(2*time.Second))
The path is relative to wherever the endpoint is implemented. A router included under
WithPrefix("/v1") serves it at /v1/users/{id}, and its callers put /v1 at the end of
their BaseURL.
What the compiler catches
The handler's types are the endpoint's. Every one of these is a compile error rather than a route that disagrees with its callers:
// The handler takes another input type.
r.Implement(userapi.GetUser, func(ctx *muzak.Context, in OtherIn) (userapi.User, error) { ... })
// in call to r.Implement, type func(ctx *muzak.Context, in OtherIn) (userapi.User, error)
// does not match inferred type muzak.Handler[userapi.GetUserIn, userapi.User]
// The handler returns another output type.
r.Implement(userapi.GetUser, func(ctx *muzak.Context, in userapi.GetUserIn) (Profile, error) { ... })
// in call to r.Implement, type func(...) (Profile, error) does not match inferred type
// muzak.Handler[userapi.GetUserIn, userapi.User]
// A caller passes the wrong input.
userapi.GetUser.Call(ctx, users, OtherIn{ID: "42"})
// cannot use OtherIn{...} as userapi.GetUserIn value in argument to userapi.GetUser.Call
// A caller expects the wrong output.
var p Profile
p, err = userapi.GetUser.Call(ctx, users, userapi.GetUserIn{ID: "42"})
// cannot use 1st function result (value of struct type userapi.User) as Profile value
// An endpoint converted to another pair of types.
other := muzak.Endpoint[OtherIn, userapi.User](userapi.GetUser)
// cannot convert userapi.GetUser (variable of struct type muzak.Endpoint[userapi.GetUserIn,
// userapi.User]) to type muzak.Endpoint[OtherIn, userapi.User]
The last one is closed on purpose. Every Endpoint[In, Out] would otherwise share one
underlying struct type, and Go allows a conversion between two such types, which would hand
an endpoint compiled for one input type to a call passing another.
Mistakes in the declaration
An endpoint is usually a package-level variable, and a panic while packages initialise stops
the program before it can say which declaration was wrong. So NewEndpoint checks the
method and the path when it is called, by the rules the router holds a route to, and records
what it finds. A mistake is a build error of the application that implements the endpoint,
joined with every other, and the error every call returns.
The input type is checked when the endpoint is implemented, as any route's is, and again on
the first call, for what writing it as a request needs. A type the server binds but a call
cannot write, such as one with UnmarshalText and no MarshalText, fails every call with
an error saying what to change. A panic while that plan is compiled, such as a Validate
method that panics on the zero input, is the panic of every call alike, never of the first
call only.
Calling an endpoint
Endpoint.Call sends the input to the endpoint at the client's BaseURL and returns what
the handler answered, decoded into Out.
user, err := userapi.GetUser.Call(ctx.Context(), core.Users, userapi.GetUserIn{ID: "42"})
The client is an ordinary muzak.NewClient, with everything that comes with one: address
checks at the moment of connecting, bounded waits and bodies, retries only for what is safe
to send twice, a circuit breaker if you turn one on, and the request id carried along. A
service calling its own network sets AllowPrivateNetworks, or names its subnets in
AllowedNetworks.
ClientOptions.BaseURL is the root of the service, such as http://users.internal:8080 or
https://api.example.com/v1. It lives on the client because a client is built per service:
it holds that service's connection pool and circuit breaker. It must be an absolute http or
https URL with a host and nothing after its path; a query, a fragment or user information
panics in NewClient, because each would be sent with every call. Client.Do and the JSON
helpers take absolute URLs and never read it.
How the input is written
A call writes its input as the exact inverse of binding. The plan it follows is compiled, on the first call, from the binder's own plan for the same type, so the two cannot disagree about which field comes from where.
| Location | How a value is written |
|---|---|
path | Escaped segment by segment; a trailing {name...} parameter may hold / |
query | Once, or once per entry for a list, with every byte escaped |
header | As it is; a list is one line of entries separated by , |
cookie | As it is, quoted when it holds a space or a comma |
| JSON body | The body members alone, never a located field or a Dep |
form, file | multipart/form-data, a value or a []byte file per part |
Numbers are written in the decimal grammar the binder reads, the shortest that reads back as
the same value. A time.Duration is written as 1500ms, and a time.Time, a uuid.UUID
or any type that reads itself with UnmarshalText writes itself with MarshalText.
An input that decodes its body itself, with an UnmarshalJSON of its own, is still written
from its body members alone. Its located fields are left out of the body, so an
Authorization or a session cookie it binds goes in its header or cookie and never into a
body that may be logged and kept.
An input that binds a header net/http writes itself, or that frames the request, cannot be
called: Host, Content-Length, Transfer-Encoding, Connection, Keep-Alive,
Proxy-Connection, TE, Trailer, Upgrade, Accept-Encoding, and Cookie, which is
what the cookie tag is for. Content-Type may be bound by an input that has no body. A
User-Agent or an Accept the input binds and leaves out arrives absent, rather than as
the one net/http or the call would otherwise write.
Headers ClientOptions.Propagate adds, the request id and the trace context with the
default, are added after the input is written. A field bound to one of them that the input
leaves out, a nil pointer or an empty list, receives the propagated value. A header the
input did set and Propagate changes refuses the call with ErrCallRefused, since the
server would not read the input's value: a Propagate of your own should leave a header
that is already set alone, as muzak.DefaultPropagate does. A non-pointer field is always
set, even when it is empty.
A form is always sent as multipart, which every method can carry, and a []byte file is a
part named after its field. A muzak.File field is what a handler receives and holds no
content a caller could set, so an input with one cannot be called: declare the field
[]byte, or [][]byte for several files.
Values a request cannot carry
Some values have no request that carries them unchanged. They are refused before anything
is sent, with an error that wraps muzak.ErrCallRefused and names the field and the
reason, never the value, which may be a credential.
| Value | Why it is refused |
|---|---|
| An empty path parameter | The router never matches an empty segment |
. or .. as a path segment | Clients and proxies resolve them as steps through the path |
/ in a path parameter that is not a trailing {name...} | Muzak keeps %2F inside its segment, but some proxies decode it and route elsewhere, and others refuse it |
An empty segment in a {name...} value | Proxies merge // and drop a trailing / |
| A header with a control character | A line break would end the header and write another |
| A header with a space or tab at either end | Every HTTP parser drops them |
| A header list entry that is empty, or holds a comma or an unbalanced quote | The server would read it as the edge of another entry |
An empty User-Agent | net/http leaves an empty one out, so it would arrive absent |
A cookie with a control character, ", ;, \ or a byte outside ASCII | The cookie parser drops it, and net/http would drop it with only a log line |
| A list of more than one value in a path parameter or a cookie | Each carries exactly one |
| A number that is not finite | The binder reads only decimal numbers |
| A body string that is not UTF-8 | The JSON decoder refuses it |
A body member with a default that its json tag's omitzero or omitempty leaves out | The server would read the default in its place |
A header given to CallHeader that the input binds or a call writes itself, or a value with a line break or a space at either end | Two values would compete, or the value would arrive altered |
_, err := api.GetFile.Call(ctx, client, api.FileIn{Name: in.Name})
if errors.Is(err, muzak.ErrCallRefused) {
// The caller's input was at fault, not the upstream.
return Out{}, muzak.BadRequest("that is not a file name")
}
One value is not refused, because the client cannot see it: a path parameter that spells a
static route beside it. With /users/me and /users/{id} registered, a call with ID: "me"
reaches /users/me, as a browser would.
Absent values
A nil pointer and an empty list are not sent, because a request has no way to spell them, so the server reads them as absent: nil, or the default the field's tag declares. A zero value that is not a pointer is sent, so a default never replaces a zero that was written on purpose.
type ListIn struct {
Limit int `query:"limit" default:"50"` // Limit: 0 arrives as 0
Cursor *string `query:"cursor"` // Cursor: nil arrives as nil
}
A body member is the one place a zero can be left out: a json tag with omitzero or
omitempty drops it from the body, and the server would then read the member's default.
So a body member that has a default and that its tag would leave out is refused with
ErrCallRefused rather than replaced by the default. Send a value the tag keeps, or a nil
pointer, or an empty list, when the default is what you mean.
type CreateIn struct {
Name string `json:"name"`
Limit int `json:"limit,omitzero" default:"10"` // Limit: 0 is refused
}
A pointer is read for what it points at, so a non-nil pointer to a nil pointer, or to an empty list, arrives as nil too. JSON has no nil slice or map either: a body member holding one arrives empty rather than nil, and so does an output's.
What comes back
Out | What Call returns |
|---|---|
| A JSON type | The body, decoded with encoding/json/v2; members Out lacks are ignored, and a 204 is the zero value |
muzak.Bytes | The body, its Content-Type, and the file name and Download of its Content-Disposition |
muzak.Stream | The body unread, as an io.ReadCloser you must close, with the same header fields and Length when the server declared one |
muzak.Redirect | The redirect itself, To and Status, which is not followed |
muzak.HTML | The document |
muzak.Empty | Nothing |
muzak.FileResponse | Cannot be called: declare the endpoint with muzak.Stream |
A redirect is returned rather than followed because following it would answer with another
endpoint's output. Every body is bounded by ClientOptions.MaxResponseBytes. A success that
is not labelled as JSON, where Out is a JSON type, is an error rather than a guess, which
is how an HTML page from a proxy in the way announces itself.
The file name a server offers in Content-Disposition is that server's, and is cleaned as a
name Muzak sends is: a / or a \ becomes _, and control characters, line breaks, double
quotes and the Unicode controls that make a name display other than it is are removed. A
name of ../../etc/passwd arrives as .._.._etc_passwd, one name and never a path.
A Stream a handler receives from a call can be returned as its own, which passes the body on
without holding it; Muzak closes it once it is sent.
func Download(ctx *muzak.Context, in DownloadIn) (muzak.Stream, error) {
return storageapi.Export.Call(ctx.Context(), core.Storage, storageapi.ExportIn{ID: in.ID})
}
Errors
A status outside 2xx is a *muzak.RemoteError. Besides the status, the header and the start
of the body, it carries what the service's error envelope says, read from the default shape
or from problem details:
| Field | From the default envelope | From problem details |
|---|---|---|
Code | error.code | code |
Message | error.message | detail, or title |
Details | error.details | errors |
RequestID | request_id | request_id |
user, err := userapi.GetUser.Call(ctx.Context(), core.Users, in)
var remote *muzak.RemoteError
if errors.As(err, &remote) && remote.Code == muzak.CodeNotFound {
return Out{}, muzak.NotFound("no such customer")
}
The body is another server's, so nothing about it is trusted. It is read only under a JSON
media type, kept to 64 KiB, decoded whole or not at all, and at most a hundred details are
kept. RemoteError.Error() names the method, the origin and the status, and the code when
it is a plain identifier of at most 64 bytes (letters, digits and _ - . :), so a hostile
body cannot write a line of its own into a log:
muzak: GET https://users.internal:8080 answered with status 404 (not_found)
Branch on StatusCode or Code, never on that message. Message and the issues in
Details are the remote server's text: do not hand them to your own clients as they are.
Client.DoJSON, GetJSON and PostJSON fill the same fields.
A failure to reach the service is returned as Client.Do returns it: an
*muzak.AddressRefusedError, a timeout, an open circuit, or the context's error.
Call options
muzak.CallHeader adds a header the input does not bind, such as the Authorization a
guard reads, or the Idempotency-Key that makes a POST safe to retry.
item, err := api.CreateItem.Call(ctx.Context(), client, in,
muzak.CallHeader("Authorization", "Bearer "+token),
muzak.CallHeader("Idempotency-Key", key))
A header the input binds is set through its field, and naming it here is refused, as are
Host, Content-Length, Content-Type and the other headers a call writes itself. A
Cookie given here is sent after the input's own cookies, so a cookie of the same name
cannot replace one the input binds. Headers every call needs, such as a tenant taken from the
context, belong in ClientOptions.Propagate.
muzak.ValidateFirst checks the input before it is sent, as the server checks it once it is
bound: required parameters, form fields and files, and the rules of an input that implements
Validatable. A failure is the *muzak.ValidationError the server would have answered 422
with, and nothing is sent.
_, err := api.CreateItem.Call(ctx.Context(), client, in, muzak.ValidateFirst())
It is off unless asked for, because the server is the authority on what it accepts. A
client built from an older version of the shared package holds that version's rules and
would refuse what the server now accepts, and a rule that reads a Dep reads its zero value
on the client. Turn it on where a round trip is worth saving and both sides ship together.
What a call guarantees
The promise is tested as a property. For inputs covering every location, every scalar kind,
pointers, lists, embedded structs, types with their own text form, dependencies and
wildcards, with values full of %, /, ?, #, spaces, Unicode and attempts at line
breaks, a call against a real server either delivers to the handler an input equal to the
one sent, or is refused with ErrCallRefused and never reaches the handler. Nothing in a
value can add a header, a cookie or a query parameter of its own. The property is fuzzed with
arbitrary bytes in every location.
The same encoder writes the arguments of an MCP tool call as the request its route reads, so a route that can be served as a tool is held to the same rules; see MCP Tools.
Testing a call
A call is an ordinary HTTP request, so a test serves the application with httptest and
points a client at it. The test server listens on loopback, which a client refuses unless it
is told otherwise, so the client sets AllowPrivateNetworks:
func TestGetUser(t *testing.T) {
server := httptest.NewServer(buildApp())
defer server.Close()
users := muzak.NewClient(muzak.ClientOptions{
AllowPrivateNetworks: true,
BaseURL: server.URL,
})
defer users.Close()
user, err := userapi.GetUser.Call(t.Context(), users, userapi.GetUserIn{ID: "42"})
if err != nil {
t.Fatal(err)
}
if user.ID != "42" {
t.Fatalf("got %q", user.ID)
}
}
For the server side alone, testclient is quicker; see Testing.
Limits
- A path parameter that spells a static route beside it reaches that route, as described above, since the client cannot see the server's other routes.
- A non-pointer header or cookie field is sent even when it is empty, so the server reads an empty value rather than an absent one. Use a pointer for a value that may be absent.
- A
time.Timearrives with the same instant and offset, in a fixed zone, not in the namedLocationit was sent from. ClientOptions.BaseURLis not checked for dot or empty segments in its path. It is operator configuration, and is sent as it is written.- A
307or308is followed with the body, to another origin too, asClient.Dofollows one for any request; credentials are dropped once a redirect leaves the origin, the body is not. WithAllowPrivateNetworksthat origin may be another internal service, so name the networks a client may reach inAllowedNetworkswhere that matters.
Where to go next
HTTP Client covers the client a call is made with,
Request Data the tags an input is bound by,
Files, Streams and Redirects the outputs that
are not JSON, and Error Handling the envelope a
RemoteError reads. TypeScript generates the same API's
types for a frontend from the OpenAPI document.