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, summaries, the schemas of the In and Out types, the
declared statuses, the validation constraints 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 |
|---|---|---|
DocsPath | /docs | Where the documentation 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",
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 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. An application that sets
DisableDocs is free to use both paths for itself.
The documentation page
The page is embedded in the binary. It 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 names its own script and stylesheet by hash 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 by tag, with the description each group was given, and a filter over every operation.
- 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.
- 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. They are held in the tab, never stored, and forgotten when the page is closed.
- Deep links to an operation or a group, and a light, dark or system theme.
Both documents are rendered, hashed and compressed once while the application is built, so
a request for either 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
about a quarter of the bytes.
Grouping operations
Tags decide the groups the reference is presented in. 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 group
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.
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.
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.
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 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.
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" }
}
}
}
A member is required unless it is a pointer, carries omitzero or omitempty, carries a
default, or is tagged required:"false". A pointer member is widened so that null is
permitted.
Two types with the same name in different packages are told apart by qualifying the second
with its package, so schemas.ItemOut and core.ItemOut do not collide.
Well-known types
| Go type | Schema |
|---|---|
time.Time | {"type": "string", "format": "date-time"} |
uuid.UUID | {"type": "string", "format": "uuid"} |
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 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 |
Router.SSE | 200 carrying text/event-stream, whose schema describes one event's data |
Router.WS | 101 Switching Protocols, because the conversation continues off the document |
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 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. 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.
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.
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
The same document is what a client generator reads:
curl -s http://localhost:8080/openapi.json > openapi.json
Where to go next
Validation covers the constraints that reach the schemas, and Testing covers asserting on the document in a test.