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

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 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 by category, one per endpoint, with a Tags index beneath for the labels that cut across categories, and a filter over both.
  • 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. 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

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 typeSchema
time.Time{"type": "string", "format": "date-time"}
uuid.UUID{"type": "string", "format": "uuid"}
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 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
Router.SSE200 carrying text/event-stream, whose schema describes one event's data
Router.WS101 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.