OpenAPI
Every Muzak application describes itself. The OpenAPI 3.1 document is served at
/openapi.json and a documentation page at /docs, both derived from the registrations
themselves: path templates, tags, categories, titles, summaries, the schemas of the In and
Out types, the declared statuses, the validation constraints, the security schemes you
declare and the entries added by WithResponseDoc and WithResponseModel.
All of that reflection happens once, while the application is being built. Nothing on the request path inspects a type.
Describing the API
AppOptions embeds OpenAPIOptions, so its fields are set inline.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Version: "1.0.0",
Description: "The example service, rebuilt on Muzak.",
TermsOfService: "https://example.com/terms",
Contact: &muzak.Contact{
Name: "API team",
Email: "api@example.com",
URL: "https://example.com/support",
},
License: &muzak.License{
Name: "Apache 2.0",
Identifier: "Apache-2.0",
},
Servers: []muzak.Server{
{URL: "https://api.example.com", Description: "production"},
{URL: "http://localhost:8080", Description: "development"},
},
Addr: ":8080",
})
Title defaults to Muzak API and Version to 0.1.0. Description is rendered as
CommonMark by documentation tools. In OpenAPI 3.1 a licence carries either an Identifier
or a URL, not both. With no Servers at all, tools treat the document's own origin as
the server.
Where it is served
| Option | Default | Effect |
|---|---|---|
DocsUI | nil | The page to serve. With none, the document is published and no page is |
DocsPath | /docs | Where that page is served |
OpenAPIPath | /openapi.json | Where the document is served |
DisableDocs | off | Serves neither, for a deployment that must not describe itself |
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
DocsUI: ui.Files(),
DocsPath: "/reference",
OpenAPIPath: "/reference/openapi.json",
})
Both paths are absolute and matched exactly. Where they ended up is the last thing the server says as it starts, as a URL you can open:
INFO [Server] Listening on [::]:8080 scheme=http
INFO [Docs] Documentation at http://localhost:8080/reference openapi=http://localhost:8080/reference/openapi.json
A socket bound to every interface is reported as localhost, because [::] is where the
process listens rather than somewhere a browser can go. With no DocsUI, the line names
the document alone and says how to add a page; with DisableDocs set, nothing is
announced and neither path is registered.
Three ways of configuring a path that could not work are build errors rather than a page nobody can reach:
muzak: AppOptions.DocsPath is "reference", which is not an absolute path and so would answer no request; write it as "/reference"
muzak: AppOptions.DocsPath and AppOptions.OpenAPIPath are both "/docs", but the page and the document it reads need an address each
muzak: AppOptions.DocsPath is "/docs", which a route of this application already answers; move one of the two, or set AppOptions.DisableDocs to serve no documentation at all
That last one matters: the documentation is matched before the router, so without the
check a route of your own at /docs would be shadowed silently. DocsPath is only
checked when a page is actually served there, so an application that configures no
DocsUI may use it for a route of its own, and one that sets DisableDocs may use both
paths.
The document, the page and its assets answer GET and HEAD. Any other method is answered
405 with Allow: GET, HEAD, rendered by the error renderer as a route's 405 is, so in the
application's error envelope, or as a problem under ProblemDetails. On guarded
documentation the rate limit and the guards run first, so a client the guards refuse learns
nothing more from a POST than from a GET.
Guarding the documentation
The guards and providers given to muzak.New run in front of the document, the page and
its assets too, so an application-wide token covers the description of the API as well as
the API. A refusal is rendered as it would be for a route, and a guarded response is sent
Cache-Control: private, no-cache. Guards and providers declared on a router or a route
do not apply: the documentation belongs to the application, not to a router.
A WithSecurity given to muzak.New covers the documentation in the same way when it names a
verifying scheme (see Declaring how a route authenticates):
a request for the document or the page without a valid credential is refused before anything
is served.
The document and the page also count against the application's rate limit, in the same
budget the routes spend. Without that, /openapi.json behind a token would be an endpoint
anyone could guess the token against as fast as they could send. To exempt them, pass
muzak.SkipRateLimit() to muzak.New, or serve no documentation with DisableDocs.
To keep the documentation public while the API is private, declare the guard where the
routers are included rather than on New:
app := muzak.New(muzak.AppOptions{Title: "Awesome API", DocsUI: ui.Files()})
app.Include(routers.Items(), muzak.WithDependencies(core.GetTokenHeader(settings)))
The documentation dashboard
Generating the document is the framework's job. Rendering it is not, and a service that wants no page should not carry one, so the dashboard is a module of its own:
go get muzak.dev/openapi
import (
"muzak.dev/framework"
"muzak.dev/openapi/ui"
)
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
DocsUI: ui.Files(),
})
Go downloads and links a module only when something imports it, so leaving DocsUI unset
costs a binary nothing at all rather than embedding a page it will never serve. Adding it
costs about two megabytes, and they are in the binary rather than fetched from anywhere:
nothing is downloaded at run time, in either direction.
The page fetches no script, stylesheet, font or image from a third party, and the only requests it makes are for this application's own document and, from the console, this application's own routes. That is what lets it run under a content security policy that hashes its own inline script and allows no network access beyond this origin. An air-gapped deployment gets working documentation with no further setup.
What it gives a reader:
- Operations grouped in a sidebar tree by category (
x-category, else the first tag), each row labelled by the route's title (x-title, else its path), with a Tags index beneath for the labels that cut across categories, and a filter over all of it. See Grouping operations. - Each schema as an outline that expands, carrying the constraints the application actually enforces, beside a generated example.
- A console on every operation: fill in the parameters, the JSON body or the multipart form, send the request from the page, and read the status, timing, size, headers and body. An event stream is read as it arrives and stopped when you have seen enough.
- Request snippets in nine languages, and copy as curl for the requests the page cannot make itself, such as one to another origin.
- Authorize, which adds a bearer token, an API key header or basic credentials to what the console sends, for the schemes the document declares (see Declaring how a route authenticates). They are held in the tab, never stored, and forgotten when the page is closed.
- A light, dark or system theme.
The page, its assets and the document are rendered, hashed and compressed once while the
application is built, so a request for any of them is a header write and a copy of bytes
that never change. Each carries an ETag, so a reader who reloads transfers nothing, and
a client that accepts gzip gets a fraction of the bytes.
Serving a dashboard of your own
DocsUI takes any fs.FS, so the dashboard above is one implementation of a small
contract rather than the only choice:
- an
index.htmlat the root of the file system; - every absolute URL in it written under
/__muzak_docs__/, which is rewritten toDocsPathwhen the application is built; - the OpenAPI document fetched from
/__muzak_spec__, rewritten toOpenAPIPath.
No other file may carry an absolute URL, because only the page is rewritten. Assets are
served beneath DocsPath, so the whole tree moves with it.
Grouping operations
There are two ways to group operations, and they do different jobs. A tag says what an operation is about, and several add up. A category files a router, or a single route, under one heading. With a hundred routers a list of tags is unreadable in a sidebar, and a tag is the wrong tool for it, because an operation that carries three of them appears three times.
Tags
A router's tags are inherited by every route beneath it:
r := muzak.NewRouter(muzak.WithTags("items"))
app.Include(routers.Admin(),
muzak.WithPrefix("/admin"),
muzak.WithTags("admin"))
A route can name a tag of its own, which adds to what it inherited rather than replacing it, so the operation is documented under every group it names:
r.Post("/", handlers.AdminAction,
muzak.Status(http.StatusCreated),
muzak.WithTags("audit"),
muzak.Summary("Admin action"))
"tags": ["admin", "audit"]
Duplicates are removed while first-seen order is preserved.
Describing a tag
Naming a tag is enough to group by it, but a group with no explanation and no place in the
running order is a heading and nothing more. OpenAPIOptions.Tags gives it both:
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Tags: []muzak.Tag{
{Name: "items", Description: "Everything the catalogue holds."},
{Name: "admin", Description: "Operations that need a staff token."},
{Name: "audit", Description: "Anything that leaves a trace."},
},
})
Described tags lead, in the order they are declared. A tag only a route names follows, in the order the routes named it. A tag described here that no route carries is left out rather than rendering as an empty group, so deleting the last route in a group also removes the group.
Categories and titles
muzak.WithCategory is a shared option, so it works at both levels: every route beneath a
router inherits its category, and a single route may set its own. muzak.Title is a route
option beside Summary, giving a route a human name for the sidebar to list in place of
its path.
billing := muzak.NewRouter(muzak.WithCategory("Billing"))
billing.Get("/users/{id}", handlers.Profile, muzak.Title("Fetch User Profile"))
billing.Get("/invoices", handlers.Invoices, muzak.Title("List Invoices"))
billing.Get("/reports", handlers.Reports, muzak.WithCategory("Reports"))
Unlike tags, a category is a single value and never a list. Many routers may share one, and a router that is included into another, or a route itself, replaces the category it would have inherited rather than adding to it. A route no category reaches has none and is listed as it always was. Tags are untouched: an operation may carry both, and tags behave exactly as above.
Both reach the document as vendor extensions on the operation, written only where one was set, and the document carries one list at its root:
"/users/{id}": {
"get": {
"operationId": "get_users_by_id",
"x-category": "Billing",
"x-title": "Fetch User Profile"
}
},
"x-categories": ["Billing", "Reports"]
x-categories names every category some operation in the document carries, each once, in
the order they were first registered. A route counts where it was declared and a router
included into another counts where the Include call was made, so a hundred routers list in
the order the application mounted them and not in the sorted order of their paths. A
category met only on a hidden route is left out. The category and the title are also
exposed on the registered route as Route.Category and Route.Title, and generic,
WebSocket and event stream routes carry them like any other.
The documentation dashboard files each operation under its x-category, falling back to
its first tag and then to default, orders the groups by x-categories, and labels a row
with x-title, falling back to the path. A document with none of the three is grouped and
sorted by tag as before, and an application that sets neither option publishes the same
document it always did.
Both values are checked when the application is built, and the error names the route, or the router by its mount prefix. After surrounding whitespace is trimmed the value must not be empty, must be valid UTF-8 of at most 64 characters for a category and 120 for a title, and must hold no control character, line break or bidirectional control, because it is shown as one line. Two spellings of a category that differ in case are two categories, and titles need not be unique.
Documenting one operation
r.Post("/items/", handlers.CreateItem,
muzak.Summary("Create an item"),
muzak.Description("Creates an item under the identifier the caller chooses."),
muzak.Status(http.StatusCreated),
muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists"))
Summary is the one-line description shown beside the operation. Description is the
long-form one, carried verbatim and rendered as CommonMark.
Operation identifiers
Client generators use the operation identifier to name the method they emit. Without one,
Muzak derives it from the method and path: GET /items/{item_id} becomes
get_items_by_item_id.
r.Get("/items/{item_id}", handlers.ReadItem, muzak.OperationID("readItem"))
Identifiers must be unique across the application. A collision is reported when the application is built.
The framework's own generators use it too. TypeScript
names the types it writes for an operation after it, and an
MCP endpoint names the tool it offers for a route after it, so
OperationID also chooses the name an MCP client calls the route by.
With versioning enabled, a version is folded into the identifier so that two versions of
one operation stay distinct: get_v1_cats under URI versioning, get_cats_1 under the
header-based schemes, and listCats_1 where an explicit OperationID("listCats") was
given. See Versioning.
Hiding and deprecating
r.Get("/healthz", handlers.Health, muzak.Hidden(), muzak.SkipRateLimit())
Hidden leaves a route fully routable but out of the document and the documentation page,
which is what a health check or an internal endpoint wants. Deprecated marks an
operation as no longer recommended and changes nothing at runtime. Both are shared
options, so a whole router can carry either.
A platform's liveness and readiness probes need no route of your own: AppOptions.Health
serves /livez and /readyz ahead of routing, outside the document, and takes readiness
down when a shutdown begins. See Health Checks.
Declaring how a route authenticates
A guard is a function. Muzak can run it and cannot read it, so the document could never say whether a route wants a bearer token, an API key or a session cookie, and every guarded operation looked public. Say so once, and the document carries it: the dashboard then has something to offer under Authorize, and a client generator knows which operations need credentials.
Declare the schemes by name in AppOptions.SecuritySchemes, then say which one a router or
a route sits behind with WithSecurity:
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
SecuritySchemes: map[string]muzak.SecurityScheme{
"bearer": muzak.BearerAuth("JWT"),
"key": muzak.APIKeyHeader("X-API-Key"),
"oauth": muzak.OAuth2(muzak.OAuthFlows{
AuthorizationCode: &muzak.OAuthFlow{
AuthorizationURL: "https://auth.example.com/authorize",
TokenURL: "https://auth.example.com/token",
Scopes: map[string]string{"items:read": "Read items"},
},
}),
},
})
admin := muzak.NewRouter(
muzak.WithSecurity(muzak.Require("bearer")),
muzak.WithDependencies(requireStaff), // the guard that actually refuses
)
admin.Get("/report", handlers.Report)
admin.Get("/status", handlers.Status, muzak.Public())
items := muzak.NewRouter()
items.Get("/", handlers.ListItems,
muzak.WithSecurity(muzak.Require("oauth", "items:read"), muzak.Require("key")))
The constructors cover the common cases:
| Constructor | Describes |
|---|---|
BearerAuth(format) | A bearer token in Authorization. The format, such as "JWT", is a hint and may be empty |
BasicAuth() | HTTP Basic |
HTTPAuth(scheme) | Any other registered HTTP scheme, such as "digest" |
APIKeyHeader(name), APIKeyQuery(name), APIKeyCookie(name) | An API key in a header, a query parameter or a cookie. A key in a URL is written to logs and browser history, so prefer a header |
OAuth2(OAuthFlows{...}) | OAuth 2.0, with the flows and scopes it offers |
OpenIDConnect(url) | OpenID Connect, discovered at the URL |
A scheme the constructors do not reach can be written as a muzak.SecurityScheme literal.
Two more constructors describe a scheme in the same way and also verify it: JWTBearer,
documented as BearerAuth("JWT") is, and APIKeyVerifier, documented as the APIKey
constructor for its location is. Authentication covers
them.
Require(scheme, scopes...) is one requirement. Several requirements given to
WithSecurity are alternatives: a client satisfies any one of them. A requirement that
needs several schemes together is written as a literal,
muzak.SecurityRequirement{"key": nil, "session": nil}. Public() marks a route, or a
router, that needs no credentials, which is how one route beneath a guarded router says it is
an exception. It is emitted as an empty security list, so the document states it and does
not leave it to be assumed.
A declaration on a route replaces the one its router made rather than adding to it,
because alternatives and combinations cannot be told apart once merged. The document carries
components.securitySchemes and a security list on each operation that declared one; an
application that declares none emits neither, exactly as before.
Whether a scheme is enforced depends on how it was built. A descriptive scheme, built by any constructor in the table above or written as a literal, only describes. It is never consulted while a request is served, so naming one protects nothing. The route still needs the guard that refuses, and the two are only as truthful as whoever wrote both. Nothing is inferred from a guard and nothing is checked against it.
A verifying scheme, built by JWTBearer or APIKeyVerifier, is enforced as the document
states it. A route whose WithSecurity names one refuses a request that satisfies none of its
requirements before its guards, its providers or its handler run, and before a CaptureBody
route reads its body: 401 when no valid credential was presented (with a WWW-Authenticate
challenge for a bearer token), 403 when one was but lacks a required scope, and 503 while
a credential cannot be judged, such as before a key set has been fetched. Alternatives,
schemes needed together and scopes are checked as written, and an empty requirement among the
alternatives admits a request with no credentials, though one that presents a credential is
still judged by it. The HEAD a GET route answers is covered with the route, and so are a
Mount, Static or Frontend beneath the router and, for a declaration on New, the
documentation. Public() exempts a route from a verifying scheme its router named, while the
guards it inherits still run. Since a declaration replaces the one it inherited, a Public()
or a descriptive WithSecurity on a route or an inner router also lifts a verifying
requirement declared above it.
What is checked when the application is built is that the declaration can be described and, for a verifying scheme, enforced. Each of these is a build error:
- a scheme name outside
[A-Za-z0-9._-]; - a type OpenAPI does not define;
- an
httpscheme with noScheme, or aBearerFormaton one that is notbearer; - an
apiKeywith no validInorName; - an
oauth2scheme with no flow, or a flow without the URL its kind needs; - an
openIdConnectURL, or a flow URL, that is nothttporhttps; - a route naming a scheme that was not declared, or a scope an OAuth 2.0 scheme does not offer;
- a
WithSecurity()that names nothing. UsePublic()for that; - alternatives that mix verifying and descriptive schemes, since a request satisfying the described one could not be told from one satisfying nothing;
- a
muzak.Dep[*muzak.Claims]ormuzak.Dep[*muzak.APIKeyPrincipal]on a route whose security does not fill it on every way in.
What the schemas are built from
Parameters
A field's location tag becomes the parameter's in, its doc tag becomes the
description, its default tag becomes the default, and its requiredness follows the rules
in Request Data.
type UserListQuery struct {
Limit int `query:"limit" default:"20" doc:"Maximum number of users to return"`
Cursor string `query:"cursor" doc:"Opaque cursor from a previous page"`
}
{
"name": "limit",
"in": "query",
"description": "Maximum number of users to return",
"schema": { "type": "integer", "format": "int64", "default": 20, "minimum": 1, "maximum": 100 }
}
The default is written as the JSON type of the field, 20 and not "20", for query,
header, form and body members alike. It is not written on a member of a nested model or on a
response type, because nothing applies it there. A parameter that has a default is not
required, even if a Required() rule names it: the default is written before any rule runs,
so leaving the parameter out is not a failure.
A query parameter and a body member with the same name are two fields. The rules are keyed by
where a field is read from as well as by its name, so a body member's Required or MaxLen
never lands on the parameter of the same name, or the other way round.
The minimum and maximum above were never written twice. They come from
v.Number(&in.Limit).Between(1, 100) in the model's Validate method, which is why the
document cannot drift from what the code enforces. The full mapping is in
Validation.
That guarantee is the reason numeric rules changed in 0.2.7. A bound that excludes zero
is now applied to zero, because until then minimum: 1 was published while the server
accepted 0. That was the one case where this document and the code did drift, and the
reader who trusted the document was the one who got it wrong.
Request bodies
A named struct becomes a component and is referenced by name, so a type used by several operations is described once.
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ItemCreateIn" }
}
}
}
What the document lists under required is what the server refuses, and the decoder itself
accepts a body that leaves any member out. So a member of a JSON request body is required
exactly when a Required() rule is declared for it and it has no default. A member that
no rule speaks for is optional however the Go type looks, whether it is a pointer or not.
Telling a client, or a gateway that validates requests against the document, that a request
the server accepts is malformed was the one place the document said more than the code did.
type ItemCreateIn struct {
Name string `json:"name"`
Note string `json:"note"`
Nick *string `json:"nick"`
}
func (in *ItemCreateIn) Validate(v *muzak.Validation) {
v.String(&in.Name).Required()
v.String(&in.Nick).Required()
}
"ItemCreateIn": {
"type": "object",
"required": ["name", "nick"],
"properties": {
"name": { "type": "string" },
"note": { "type": "string" },
"nick": { "type": "string" }
}
}
Required() on a pointer refuses null as well as absence, so nick is required and is no
longer described as nullable. A pointer nothing requires still says it may be null. A
struct embedded as a pointer contributes its members at the top level, as optional members,
which is what the decoder accepts, and not as one nullable member named for the type.
That holds at every depth. An object nested in a request body requires only what the rules of a
model nested with Nested, or a rule on a nested member's path, enforce, and nothing
otherwise.
The decoder also refuses a member it does not know, at every depth, so every object a request is
read into says additionalProperties: false, unless the route uses AllowUnknownFields. An
object that collects unknown members in an embedded map says what it takes instead, and a type
that decodes itself is left open. A response is never closed, so that a member added to it later
breaks no client.
The response side keeps its shape, since a member that is not a pointer and not omitempty is
always written, and a response's component cannot say additionalProperties: false. A type used
both as a request and as a response therefore always gets two schemas: the component keeps its
shape for the response, and the request refers to a copy named for it with Input after (Item
and ItemInput). A nested type a response also uses gets its own copy (OwnerInput), which the
request's copy of its parent refers to. A type only requests use keeps its name. Form values keep
the binder's own rule, to which a rule can only add.
A rule on a member of a struct the body holds by value narrows that member for this use of the
struct alone. It is written beside the $ref, which JSON Schema 2020-12 applies together with
it, so the shared component, and every other route and response that uses it, is left as it
was. Here Money is also a response, so the request refers to MoneyInput:
func (in *RefundIn) Validate(v *muzak.Validation) {
v.Number(&in.Price.Amount).Positive()
}
"price": {
"$ref": "#/components/schemas/MoneyInput",
"properties": { "amount": { "exclusiveMinimum": 0 } }
}
The rules of every model a Nested call reaches from a zero value are written onto the
schema of its type. A model nested once per element of a collection is not among them.
A client generator will now type an unruled request member as optional. Declare Required()
on the members that must be sent, and the document follows.
Two types with the same name in different packages are told apart by qualifying the later one
with as much of its import path as tells it apart, so Item in v1/models, v2/models and
v3/models is Item, models.Item and v3.models.Item. Types the path cannot tell apart, as
three declared inside three functions of one package are, are numbered instead. The names come
out the same on every run.
Well-known types
| Go type | Schema |
|---|---|
time.Time | {"type": "string", "format": "date-time"} |
uuid.UUID | {"type": "string", "format": "uuid"} |
time.Duration | a string with a pattern that agrees with time.ParseDuration ("1500ms"), and no format |
[]byte in a JSON body | a base64 string (format: byte, contentEncoding: base64) |
[N]byte in a JSON body | a base64 string of exactly four characters for every three bytes, rounded up, which is the only length json/v2 reads back |
| a slice of a named byte type | an array, as json/v2 writes it |
[]byte in a query, header, cookie or form value | an array of integers from 0 to 255 |
a number tagged json:",string" | a string, with a pattern matching the number json/v2 reads from inside it |
a struct field tagged json:",embed" | its members, at the top level of the object holding it |
a map[string]T or jsontext.Value tagged json:",embed" | the object's additionalProperties, since it collects every member the struct does not name |
a generic type such as Page[app.Item] | a component keyed Page_app.Item, which a $ref can resolve |
a type implementing both encoding.TextMarshaler and encoding.TextUnmarshaler | a string |
| a map | an object with additionalProperties |
| an interface | an empty schema, which accepts any JSON value |
A time.Duration is written as a string such as "1500ms" in a body, both ways. It carries
no format: duration, because that is ISO 8601 (PT1.5S) in JSON Schema and the binder and
the codec read 1500ms, so a client that checks formats would have refused the value the
document told it to send. A []byte parameter is an array of integers because the binder
reads each value of a parameter as one element: ?data=65 is []byte{65}, and
?data=aGk= is a 422.
Members are the ones json/v2 resolves. omitempty makes a response member optional only when
its value can encode as empty: json/v2 leaves out only what encodes as null, "", {} or
[], so a number, a bool, a time and an array of at least one element are always written, and
are listed as required whatever their tag says. A member that a shallower field of the same
name shadows is not described, nor one that two fields at the same depth both claim, since
json/v2 neither writes nor reads it. json/v2 refuses a format: tag option, which on a request
body is a build error.
A type that converts to and from text is a string everywhere it appears: in a parameter,
where the binder parses it with UnmarshalText, and in a body, where the encoder writes it
with MarshalText. Describing it by walking its fields would document the Go struct rather
than the value on the wire.
Responses
| Route kind | What is documented |
|---|---|
| Ordinary | The route's declared status, carrying the Out schema as application/json |
Out is muzak.HTML | The same status, carrying text/html |
Out is muzak.Empty | The same status, with no content |
Out is muzak.Bytes, muzak.Stream or muzak.FileResponse | The same status, carrying a string of format: binary under each media type Produces names, or under application/octet-stream when it names none |
Out is muzak.Redirect | The redirect status, with no content and a Location header |
Router.SSE | 200 carrying text/event-stream, whose schema describes one event's data, and the 503 a draining or full server answers with |
Router.WS | 101 Switching Protocols, because the conversation continues off the document, and what a handshake is refused with: 400 for a malformed one, 403 for a foreign origin (left out for a route that skips the check) and 503 for a draining or full server, beside 426 and 422 |
Files, Streams and Redirects covers the
response types that are not JSON, and how muzak.Produces names the media types a binary
response is sent as.
Alongside that, every route that binds anything documents 422 with the error envelope,
every declared outcome is added, and a default response carrying the error envelope is
added unless one was declared. A route with a muzak.Timeout also documents the 503 with
Retry-After that its deadline is answered with, unless it declared that status itself. A
verifying security scheme adds no response of its own, so declare its 401 and 403 with
WithResponseDoc where a client should read them. With AppOptions.ProblemDetails set,
every one of those error responses is described as an RFC 9457 problem,
application/problem+json, instead; see Problem Details.
A declared outcome is either WithResponseDoc(code, description), described as the error
envelope because that is what a returned error produces, or
WithResponseModel[T](code, description), described as T exactly as an Out type would
be. The last declaration of a status code wins, so a route replaces what it inherited from
its router, and an empty description falls back to the code's standard reason phrase. A status
a WebSocket or event stream route declared itself keeps its own description. See
Response Models.
Multipart bodies
A route binding form or file fields is described as multipart/form-data. A route
that binds form values and no files at all is also described as
application/x-www-form-urlencoded, because that is what a plain HTML form posts.
Versioned routes
Under VersioningURI each version is a distinct path, so /v1/cats and /v2/cats are two
entries and every version is described.
Under VersioningHeader, VersioningMediaType and VersioningCustom several routes share
one path and method, and an OpenAPI path item has a single slot per method. The last
registration wins, so only one version of that operation reaches the document. Routing is
unaffected; only the description is. That is the practical argument for URI versioning
whenever the generated document matters, and
Versioning covers the alternative of generating
one document per version.
Under VersioningURI, adding a version adds paths, which no client notices, and retiring
one removes them, which breaks every client still calling it.
API Compatibility reports both, and names the version
still served when one is removed.
Reading the document from Go
document, err := app.Document()
if err != nil {
return err
}
for path, item := range document.Paths {
if item.Get != nil {
fmt.Println("GET", path, item.Get.OperationID)
}
}
Document builds the application if necessary. It returns nil when documentation is
disabled with DisableDocs, and an error when the application does not build.
A document that cannot be encoded as JSON is one way not to build. The document is encoded
once, while the application is built, and text that is not valid UTF-8 in a doc tag, a
summary, a description or an example makes that fail with a build error, rather than leaving
/openapi.json silently unserved as it used to:
muzak: the OpenAPI document cannot be encoded as JSON, so it could not be served: ...
Committing the document
Document.Marshal renders indented JSON with map keys in sorted order, so the output is
byte-for-byte reproducible. That is what lets a generated document be committed and
diffed, and a diff in a pull request is how an accidental breaking change gets noticed.
// Command openapi writes the generated OpenAPI document to standard output.
package main
import (
"log"
"os"
"awesome-api/routers"
"muzak.dev/framework"
)
func main() {
app := muzak.New(muzak.AppOptions{Title: "Awesome API", Version: "1.0.0"})
app.Include(routers.Users())
app.Include(routers.Items())
document, err := app.Document()
if err != nil {
log.Fatal(err)
}
out, err := document.Marshal()
if err != nil {
log.Fatal(err)
}
if _, err := os.Stdout.Write(out); err != nil {
log.Fatal(err)
}
}
A composition shared between cmd/main.go and this command is worth factoring into a
function of its own, so the document is generated from exactly the application that runs.
go run ./cmd/openapi > openapi.json
git diff --exit-code openapi.json
A diff says that the document changed, not whether the change breaks a client.
API Compatibility compares the committed document
with the one the application generates and fails a test on a change that would, through
testclient.AssertCompatible. The muzak command
reads a document from a file, standard input or a running application: muzak routes lists
its operations, muzak diff compares two documents and exits non-zero for a CI step to gate
on, and muzak ts writes TypeScript from one.
The same document is what a client generator reads:
curl -s http://localhost:8080/openapi.json > openapi.json
For a frontend written in TypeScript, TypeScript generates the declarations, and optionally a small fetch client, from it.
Where to go next
Validation covers the constraints that reach the schemas, and Testing covers asserting on the document in a test.