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

OptionDefaultEffect
DocsUInilThe page to serve. With none, the document is published and no page is
DocsPath/docsWhere that page is served
OpenAPIPath/openapi.jsonWhere the document is served
DisableDocsoffServes 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.html at the root of the file system;
  • every absolute URL in it written under /__muzak_docs__/, which is rewritten to DocsPath when the application is built;
  • the OpenAPI document fetched from /__muzak_spec__, rewritten to OpenAPIPath.

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:

ConstructorDescribes
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 http scheme with no Scheme, or a BearerFormat on one that is not bearer;
  • an apiKey with no valid In or Name;
  • an oauth2 scheme with no flow, or a flow without the URL its kind needs;
  • an openIdConnect URL, or a flow URL, that is not http or https;
  • 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. Use Public() 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] or muzak.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 typeSchema
time.Time{"type": "string", "format": "date-time"}
uuid.UUID{"type": "string", "format": "uuid"}
time.Durationa string with a pattern that agrees with time.ParseDuration ("1500ms"), and no format
[]byte in a JSON bodya base64 string (format: byte, contentEncoding: base64)
[N]byte in a JSON bodya 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 typean array, as json/v2 writes it
[]byte in a query, header, cookie or form valuean 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.TextUnmarshalera string
a mapan object with additionalProperties
an interfacean 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 kindWhat is documented
OrdinaryThe route's declared status, carrying the Out schema as application/json
Out is muzak.HTMLThe same status, carrying text/html
Out is muzak.EmptyThe same status, with no content
Out is muzak.Bytes, muzak.Stream or muzak.FileResponseThe 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.RedirectThe redirect status, with no content and a Location header
Router.SSE200 carrying text/event-stream, whose schema describes one event's data, and the 503 a draining or full server answers with
Router.WS101 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.

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