bendo openapi turns an app's route manifest into an OpenAPI 3.1
document. src/api-docs.bend serves that document and a
Scalar API reference page. Neither is part
of main.bend, so apps without docs compile nothing extra.
Decision 0013 explains the split.
Add two routes and import the docs module:
import ../../src/main.bend as B
import ../../src/serve-schemas.bend as BS
import ../../src/api-docs.bend as Docs
import ./openapi-doc.bend as OpenApiDoc
def spec(c: B.Ctx) -> IO(B.Res):
IO.pure(B.Res, Docs.openapi(c, OpenApiDoc.json()))
def reference(c: B.Ctx) -> IO(B.Res):
IO.pure(B.Res, Docs.scalar(c, Docs.options("/openapi.json")))
# In the route list:
# B.get(Tag, "/openapi.json", Spec{}),
# B.get(Tag, "/docs", Reference{}),Then generate the document and the module, and rebuild:
printf 'def json() -> String:\n "{}"\n' > openapi-doc.bend # first run only
bendo openapi main.bend --bend openapi-doc.bend
bendo buildThe manifest does not depend on the document, so one run is enough. Run
bendo openapi again after any route or schema change; the embedded document
does not update itself. The complete app is
tests/fixtures/openapi/app.bend.
To skip embedding, serve the generated openapi.json from disk with
B.serve_static and point Docs.options at its URL.
bendo openapi [entry] [--out <file>] [--bend <file>] [--config <file>] [--manifest <file>]
entrydefaults tomain.bend. The app is built with the same cache asbendo build, then run with--bendo-routes.--outis the JSON output,openapi.jsonby default, indented two spaces.--bendalso writes a Bend module whosejson()returns the document as compact JSON. Non-ASCII characters are written as\uXXXXescapes.--configreads options from a JSON file. Without it,openapi.config.jsonin the current directory is used when it exists.--manifestreads a saved manifest instead of building, for example the output of./server --bendo-routes > routes.json. It excludesentry.
Files are replaced atomically. The command prints one warning per route it cannot document to stderr and exits 0; config errors exit 1.
Every member is optional.
{
"info": { "title": "Todo API", "version": "1.2.0", "description": "...", "license": { "name": "MIT", "identifier": "MIT" } },
"servers": [{ "url": "https://api.example.com", "description": "Production" }],
"tags": [{ "name": "todos", "description": "Todo items." }],
"autoTags": true,
"allMethods": ["get", "post", "put", "patch", "delete"],
"exclude": ["/openapi.json", "/docs"],
"operations": {
"GET /todos/:id{int}": { "summary": "Get a todo", "operationId": "getTodo", "tags": ["todos"], "deprecated": false }
}
}| Member | Default | Meaning |
|---|---|---|
info |
from package.json |
title, version and description fall back to the current directory's package.json name, version and description, then to Bendo API and 0.0.0. summary, termsOfService, contact and license pass through. |
servers |
none | Without servers, OpenAPI resolves paths against the document's own origin, which suits a document served by the app. |
tags |
none | Tag descriptions and their order. Tags that operations use but this list lacks are appended by first use. |
autoTags |
true |
Tag each operation with its path's first static segment: /todos/:id gets todos. / and paths that start with a capture get none. |
allMethods |
get, post, put, patch, delete | The operations a B.all route documents. Lowercase OpenAPI method names. |
exclude |
none | Manifest paths to leave out, compared exactly. |
operations |
none | Per-route summary, description, operationId, tags and deprecated, keyed by the manifest's METHOD path. An ALL route's key is ALL /path. A key that matches no route gives a warning. |
Unknown members and wrongly typed values are errors.
Rows keep manifest order. Paths appear in order of first use.
Bendo patterns (syntax) become OpenAPI templates:
| Pattern | Path | Parameter |
|---|---|---|
/todos/:id |
/todos/{id} |
id, a nonempty string |
/todos/:id{int} |
/todos/{id} |
pattern ^-?[0-9]+$ |
:u{uuid}, :s{slug}, :c{[a-z]+}, :y{[0-9]{4}} |
{u} and so on |
format: uuid with its pattern; ^[a-z0-9-]+$; ^[a-z]+$; ^[0-9]{4}$ |
/v1/:version?/:platform? |
/v1, /v1/{version}, /v1/{version}/{platform} |
one path per number of present captures |
/:prefix{.+}/contents |
/{prefix}/contents |
described as possibly containing / |
/files/*path |
/files/{path} |
the rest of the path, possibly empty |
* or /* |
/{wildcard} |
captured as * in the route |
/a/*/b |
/a/{wildcard}/b |
not captured by the route |
/assets* |
/assets{suffix} |
not captured by the route |
OpenAPI path parameters are always required, which is why an optional chain
becomes several paths. Wildcards that the route does not capture still get a
parameter, so a client can fill in the path. Generated names (wildcard,
suffix) take a number when the pattern already uses the name.
A parameter's schema comes from the route's params slot when the slot is an
object that declares the name. Otherwise it comes from the constraint, as in
the table. The params schema describes the value after
coercion, so an id declared as an
integer is documented as type: integer, which is how OpenAPI describes a
numeric path segment.
OpenAPI treats /todos/{id} and /todos/{todo_id} as the same path. When two
routes give such templates, the first spelling is kept and the later route's
parameters are renamed to match it. Each operation's x-bendo-route holds its
original METHOD path.
GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH and TRACE map to
the operation of the same name. B.on produces one manifest row per method,
so each becomes its own operation. B.all documents the allMethods list,
with x-bendo-all: true on each. OpenAPI 3.1 has no field for other methods,
such as PURGE, QUERY or a lowercase extension token, so those routes are
left out with a warning. Implicit HEAD for GET routes is not documented.
When a method on a path is already documented, a later route that gives the
same method and path is left out with a warning. The router tries routes in
registration order, so the earlier route answers at least those requests. A
later route can still serve requests the earlier one rejects, for example
/users/:id{int} before /users/:name; OpenAPI cannot describe both.
| Slot | OpenAPI |
|---|---|
params |
path parameter schemas, as above |
query |
one in: query parameter per property, required from the schema's required list |
headers |
one in: header parameter per property; Accept, Content-Type and Authorization are left out with a warning, because OpenAPI ignores them |
body |
requestBody, required, application/json |
response |
a 200 response with application/json; without it, a default response with no content |
A query or headers schema that is not an object with properties gives no
parameters and a warning. Each embedded schema is the slot's JSON Schema
without its $schema member, because OpenAPI 3.1's default dialect already
is 2020-12. The decoder policy
still applies: defaults are annotations, strip objects accept undeclared keys,
and anyOf unions decode to their first match. JSON numbers pass through
JavaScript, so a bound such as 1E+2 is written 100 and digits beyond a
double's precision are lost.
operationId defaults to the method and path words in camel case:
getTodosById, getRoot for /. A number is appended on a clash. An
operations override applies only when the route documents exactly one
operation.
src/api-docs.bend takes any Context.Ctx<Vars> through the _with forms.
| Function | Result |
|---|---|
Docs.openapi(c, text) |
text with application/json; charset=utf-8 |
Docs.scalar(c, o) |
the HTML page, text/html; charset=utf-8, with o's Content-Security-Policy |
Docs.options(spec_url) |
the CDN script, its SRI hash, the CDN policy and the title API Reference |
Docs.with_title(o, t) |
the page title, HTML-escaped |
Docs.self_hosted(o, url, integrity) |
load Scalar from url; see below |
Docs.with_csp(o, policy) |
replace the page's policy; "" sends none |
Docs.page(o), Docs.policy(o) |
the HTML and the policy as strings |
The page loads Scalar API Reference 1.75.1 from jsDelivr, pinned to the exact file with an SRI hash:
https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.75.1/dist/browser/standalone.js
sha384-G+2Q2i9l3PPfmYkk0b4BAIUrs7xb/DNaTL1wCYYab4c9dIKlCyiqzC0Qx3eq9Wf6
The browser refuses the script if jsDelivr serves different bytes. Scalar
reads the document URL from <script id="api-reference" data-url="...">, so
the page has no inline script. Without JavaScript it shows a link to the
document.
Docs.scalar sends this policy with the CDN options:
default-src 'none'; script-src https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.scalar.com;
font-src 'self' https://fonts.scalar.com data:; img-src 'self' data: https:;
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'
Scalar injects inline styles and loads its fonts from fonts.scalar.com.
connect-src 'self' covers fetching the document and "Test Request" calls to
the same origin. If the document lists servers on other origins, add them to
connect-src with Docs.with_csp, or test requests to them fail.
Secure headers (BND-40) send no
Content-Security-Policy by default, so the page's own policy reaches the
browser unchanged. If you add one with B.secure_set(config, "Content-Security-Policy", ...),
the middleware replaces the page's policy on every response, /docs
included, and a policy such as default-src 'self' blocks the CDN script.
Choose one of:
- Register the strict secure-headers middleware with
B.use_pathon your API paths only, and plainB.secure_headers()on the docs routes. - Use
B.secure_unset(config, "Content-Security-Policy")and set your policy per route withB.header. - Make your global policy allow what the page needs, using
Docs.cdn_csp()as the reference.
For an offline or air-gapped deployment, download the pinned file, check its hash and serve it yourself:
mkdir -p public/scalar
curl -fsSL -o public/scalar/standalone.js \
https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.75.1/dist/browser/standalone.js
echo "sha384-$(openssl dgst -sha384 -binary public/scalar/standalone.js | base64 -w0)"def reference(c: B.Ctx) -> IO(B.Res):
IO.pure(B.Res, Docs.scalar(c, Docs.self_hosted(Docs.options("/openapi.json"), "/scalar/standalone.js", "sha384-G+2Q...")))
# and B.serve_static on GET /scalar/* from public/self_hosted switches to a policy that allows scripts and fonts from the
app's own origin only, and tells Scalar not to load its default fonts. The
file is about 4.6 MB; give it a long Cache-Control with
B.static_cache_control.
scalar_version, scalar_cdn and scalar_integrity in src/api-docs.bend
change together. Download the new dist/browser/standalone.js, compute its
SHA-384 as above, update the three defs and the expected values in
tests/openapi.test.ts, then open /docs in a browser and check the console
for policy violations.
tests/openapi.test.ts compiles tests/fixtures/openapi/app.bend, which has
every pattern form and schema kind. It checks:
- the defaults golden (
tests/fixtures/openapi/golden/) and the fixture's committedopenapi.jsonandopenapi-doc.bendagainst a fresh generation from the compiled manifest;BENDO_UPDATE_GOLDEN=1rewrites them; - both documents against the OpenAPI 3.1 schema with
@seriousme/openapi-schema-validator, and every embedded schema with Ajv's 2020-12 validator in strict mode; - the running server's
/openapi.json, byte for byte, and/docswith its headers and variants; - the
bendo openapicommand, its config errors and its arguments.
bun test --timeout 200000 tests/openapi.test.tsThe Scalar page itself was checked in headless Chrome at 1440 and 390 pixels wide: all 27 operations rendered, and the console showed no policy violations.