๐ŸŒฌ๏ธ Breeze docs

OpenAPI / Scalar

An OpenAPI 3 spec generated from route declarations, the Scalar UI to browse it, and an llms.txt for models.

import (
	middleware "github.com/nelthaarion/breeze/v2/middlewares"
	"github.com/nelthaarion/breeze/v2/scalar"
)

router.Use(middleware.ScalarMiddleware(router, middleware.ScalarOptions{
	Title:   "My API",
	Version: "2.0.0",
}))

router.Handle(breeze.POST, "/users", createUser,
	middleware.DocPOST("/users", scalar.RouteDoc{
		Title: "Create user",
		Tags:  []string{"users"},
		Input: []scalar.InputGroup{
			{Type: scalar.InputBody, Fields: CreateUserRequest{}, Required: true},
		},
		Output:       UserResponse{},
		OutputStatus: 201,
	}),
)
  • GET /openapi.json โ€” the spec
  • GET /scalar โ€” the UI
  • GET /llms.txt, GET /llms-full.txt โ€” the model-readable index

Declared, not sniffed

Every other approach to this problem infers the contract: parse comments, sniff live traffic, or reflect over the handler signature. Breeze asks the route to declare it, as a scalar.RouteDoc passed to Doc at registration.

The other three are all wrong at the moment it matters. A comment drifts from the code and nothing checks it. Traffic sniffing documents what clients happen to send, so a field no caller uses yet does not exist, and a malformed request becomes part of the spec. Reflection over func(*Context) error sees nothing at all โ€” the request and response types are inside the function body. A declaration is checked by the compiler: Fields: CreateUserRequest{} stops compiling the day that struct is renamed.

Config

FieldDefaultMeaning
Title"Breeze API"API name in the UI
Version"1.0.0"version string
Description""long description
JSONPath/openapi.jsonwhere the spec is served
UIPath/scalarwhere the UI is served; "" disables it and serves JSON only

Both endpoints are registered with HandleBlocking. They regenerate the whole document on every request โ€” milliseconds of work for a route hit by hand a few times a day, the opposite of what belongs on an event loop.

SwaggerOptions and SwaggerMiddleware are deprecated aliases โ€” they serve Scalar, not Swagger UI. The names predate the move and exist only so existing code compiles.

RouteDoc

FieldTypeMeaning
Titlestringthe endpoint's summary line in the UI
Tags[]stringgroups the endpoint in the sidebar
Descriptionstringlonger explanation
Input[]InputGroupthe input contract, one group per source
Outputanya zero-value struct or typed nil whose shape describes success
OutputStatusintsuccess status (default 200)
OutputDescriptionstringresponse description (default "OK")

Output takes a value rather than a type so a typed nil works โ€” (*UserResponse)(nil) documents the shape without allocating one.

InputGroup

Input: []scalar.InputGroup{
	{Type: scalar.InputBody, Fields: CreateUserRequest{}, Required: true},
	{Type: scalar.InputQuery, Fields: struct {
		Page int `json:"page"`
	}{}},
	{Type: scalar.InputParams, Fields: struct {
		ID string `json:"id"`
	}{}},
}

Four sources, matching the four the binding package reads:

InputTypeWhere the fields come from
InputBodythe JSON request body
InputQueryURL query parameters
InputParamspath parameters (:id)
InputHeaderrequest headers

Required is meaningful for InputBody and marks the whole body required.

The Doc helpers

Doc(method, path, doc) registers documentation and returns a pass-through middleware. Registration happens when Doc is called โ€” at startup, as the route is declared โ€” not per request; the returned handler only calls ctx.Next(). DocGET, DocPOST, DocPUT, DocPATCH and DocDELETE drop the method argument when it's known statically. Tag("Users", doc) prepends a tag for inline construction.

Doc is a no-op when doc collection is not enabled, so it's safe to leave in production code โ€” a build that never calls ScalarMiddleware pays a slice append per route at startup and nothing at request time.

Schema inference

Output and InputGroup.Fields are reflected, not declared field by field. json tags name the properties, omitempty marks a field optional, json:"-" excludes it. Recursion is capped at three levels โ€” past that the schema renders as a type name rather than expanding, since a self-referential model would otherwise generate an infinite document, and three levels is enough for a reader to recognise the shape.

llms.txt

llms.txt is a convention for handing a language model the same orientation a new contributor would get. Two files:

PathContents
/llms.txtthe index โ€” what this is, one line per route
/llms-full.txtthe full reference โ€” payload shapes and model definitions

The split is the point: the common question is "which endpoint do I want", and answering it should cost a few hundred tokens, not tens of thousands. A model that needs the payload shape reads the full file.

One source of facts

A route's method, path, summary and payload shape are already recorded once by RegisterRoute. Rendering llms.txt from a second walk of the router would mean two collections of the same facts that disagree the first time a route is documented in one place and not the other โ€” exactly the failure llms.txt is meant to prevent. So Routes() reads the same slice Generate() reads, through the same lock, producing the same paths through the same normaliser: a service whose OpenAPI document is right cannot have an llms.txt that is wrong. Routes are sorted by path then method.

Two ways in, one renderer

LLMSFromRegistry() builds an LLMSDoc from the live registry โ€” a running service describing itself. The CLI, working on a project on disk rather than a running process, fills an LLMSDoc by parsing the project's generated registry file instead. One renderer, one output format, two ways of learning the same facts.

Freshness stamps

A generated file checked into a repository is a snapshot, and the useful question about a snapshot is "is it still true" โ€” answerable only by rebuilding and comparing:

<!-- breeze-llms-stamp: sha256=โ€ฆ -->
digest, ok := scalar.ReadStamp(fileContents)  // what it was built from
fresh := digest == scalar.BodyDigest(rebuilt) // is it still true

BodyDigest ignores the stamp line and trailing whitespace on every line โ€” hashing a document that contains its own hash isn't reproducible, and editors and version control disagree about final newlines; a check that failed over one would be noise that trains a reader to ignore it.

Diagnostics

The docs probe (not scalar โ€” the key matches the breeze add docs feature name) reports what the spec actually contains:

curl localhost:3000/dashboard/api/diagnostics?subsystem=docs
DetailMeaning
routes_recordedhow many routes reached the registry
with_title / without_titlehow many have a summary
by_methodcounts per HTTP method
api_title, api_version, has_descriptionwhat SetInfo was given
undocumented_listwhich routes have no title (capped)

Two states worth knowing about, since neither errors anywhere else: collection off reports off with a note โ€” Doc wrappers are no-ops while collection is off, so the document and UI are both empty and nothing complains; this probe is the only place that says so. Collection on, zero routes reports degraded, not ok โ€” the wiring is half done (ScalarMiddleware installed, no route carries a Doc wrapper) and the symptom is an empty document that looks like a working one. The undocumented list is capped: a project with three hundred undocumented routes needs to know that fact, not receive three hundred strings through a diagnostics endpoint.

Generated documentation for the Breeze framework ยท built with SvelteKit, fully static.