You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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.

LocationHow a value is written
pathEscaped segment by segment; a trailing {name...} parameter may hold /
queryOnce, or once per entry for a list, with every byte escaped
headerAs it is; a list is one line of entries separated by ,
cookieAs it is, quoted when it holds a space or a comma
JSON bodyThe body members alone, never a located field or a Dep
form, filemultipart/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.

ValueWhy it is refused
An empty path parameterThe router never matches an empty segment
. or .. as a path segmentClients 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...} valueProxies merge // and drop a trailing /
A header with a control characterA line break would end the header and write another
A header with a space or tab at either endEvery HTTP parser drops them
A header list entry that is empty, or holds a comma or an unbalanced quoteThe server would read it as the edge of another entry
An empty User-Agentnet/http leaves an empty one out, so it would arrive absent
A cookie with a control character, ", ;, \ or a byte outside ASCIIThe 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 cookieEach carries exactly one
A number that is not finiteThe binder reads only decimal numbers
A body string that is not UTF-8The JSON decoder refuses it
A body member with a default that its json tag's omitzero or omitempty leaves outThe 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 endTwo 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

OutWhat Call returns
A JSON typeThe body, decoded with encoding/json/v2; members Out lacks are ignored, and a 204 is the zero value
muzak.BytesThe body, its Content-Type, and the file name and Download of its Content-Disposition
muzak.StreamThe body unread, as an io.ReadCloser you must close, with the same header fields and Length when the server declared one
muzak.RedirectThe redirect itself, To and Status, which is not followed
muzak.HTMLThe document
muzak.EmptyNothing
muzak.FileResponseCannot 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:

FieldFrom the default envelopeFrom problem details
Codeerror.codecode
Messageerror.messagedetail, or title
Detailserror.detailserrors
RequestIDrequest_idrequest_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.Time arrives with the same instant and offset, in a fixed zone, not in the named Location it was sent from.
  • ClientOptions.BaseURL is not checked for dot or empty segments in its path. It is operator configuration, and is sent as it is written.
  • A 307 or 308 is followed with the body, to another origin too, as Client.Do follows one for any request; credentials are dropped once a redirect leaves the origin, the body is not. With AllowPrivateNetworks that origin may be another internal service, so name the networks a client may reach in AllowedNetworks where 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.