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 specGET /scalarโ the UIGET /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
| Field | Default | Meaning |
|---|---|---|
Title | "Breeze API" | API name in the UI |
Version | "1.0.0" | version string |
Description | "" | long description |
JSONPath | /openapi.json | where the spec is served |
UIPath | /scalar | where 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
| Field | Type | Meaning |
|---|---|---|
Title | string | the endpoint's summary line in the UI |
Tags | []string | groups the endpoint in the sidebar |
Description | string | longer explanation |
Input | []InputGroup | the input contract, one group per source |
Output | any | a zero-value struct or typed nil whose shape describes success |
OutputStatus | int | success status (default 200) |
OutputDescription | string | response 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:
InputType | Where the fields come from |
|---|---|
InputBody | the JSON request body |
InputQuery | URL query parameters |
InputParams | path parameters (:id) |
InputHeader | request 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:
| Path | Contents |
|---|---|
/llms.txt | the index โ what this is, one line per route |
/llms-full.txt | the 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 | Detail | Meaning |
|---|---|
routes_recorded | how many routes reached the registry |
with_title / without_title | how many have a summary |
by_method | counts per HTTP method |
api_title, api_version, has_description | what SetInfo was given |
undocumented_list | which 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.