diff --git a/scripts/build-search.mjs b/scripts/build-search.mjs index fd857e5..5324f56 100644 --- a/scripts/build-search.mjs +++ b/scripts/build-search.mjs @@ -203,20 +203,28 @@ function writeLlms(root) { const docMap = docPagesForLlms(root); - // Order docs by the documentation tab's nav groups; skip landing pages. + // Order docs by the nav groups of every content tab (an OpenAPI tab has no + // groups of its own). Walking only the first one dropped the pages of any + // second content tab — e.g. a Guides tab — into the ungrouped "More" bucket. const tabs = cfg.navigation?.tabs ?? []; - const docTab = tabs.find((t) => !t.openapi) ?? tabs[0]; + const docTabs = tabs.filter((t) => !t.openapi); + const walkTabs = docTabs.length ? docTabs : tabs.slice(0, 1); + // Group titles are only unique within a tab, so qualify them once there's + // more than one tab in play ("Guides · Overview"). + const qualify = walkTabs.length > 1; const sections = []; const used = new Set(); - for (const g of docTab?.groups ?? []) { - const pages = []; - for (const p of g.pages ?? []) { - const d = docMap.get(p.href); - used.add(p.href); - if (d && d.layout === "landing") continue; - pages.push({ title: p.label ?? d?.title ?? p.href, url: p.href, lede: d?.lede ?? "" }); + for (const tab of walkTabs) { + for (const g of tab.groups ?? []) { + const pages = []; + for (const p of g.pages ?? []) { + const d = docMap.get(p.href); + used.add(p.href); + if (d && d.layout === "landing") continue; + pages.push({ title: p.label ?? d?.title ?? p.href, url: p.href, lede: d?.lede ?? "" }); + } + if (pages.length) sections.push({ group: qualify ? `${tab.label} · ${g.group}` : g.group, pages }); } - if (pages.length) sections.push({ group: g.group, pages }); } const leftover = [...docMap.values()].filter((d) => !used.has(d.url) && d.layout !== "landing"); const apis = apiResources(root); diff --git a/site/docs/guides/fastapi.mdx b/site/docs/guides/fastapi.mdx new file mode 100644 index 0000000..e637825 --- /dev/null +++ b/site/docs/guides/fastapi.mdx @@ -0,0 +1,229 @@ +--- +title: FastAPI +lede: FastAPI already emits OpenAPI 3.1 with native webhooks. Dump it to a file at build time and shape the reference with openapi_extra. +crumbs: + - label: Docs + href: / + - label: Guides + href: /guides + - label: FastAPI +toc: + - id: emit-the-document + label: Emit the document + - id: tags-become-resources + label: Tags become resources + - id: order-the-sidebar + label: Order the sidebar + - id: events + label: Events + - id: code-samples + label: Code samples + - id: gotchas + label: Gotchas +--- + +FastAPI builds an OpenAPI document from your type hints and Pydantic models +with no extra annotation, and since 0.99 it emits **3.1** — so native +`webhooks` work out of the box. The only Markline-specific hook you need is +`openapi_extra`, which merges arbitrary keys onto an operation. + +## Emit the document + +`app.openapi()` returns the document as a dict. Write it to `api/openapi.json` +in your docs project as a build step rather than pointing the docs at a running +server. + +```python +# scripts/emit_openapi.py +import json +from pathlib import Path + +from app.main import app + +out = Path("../docs/api/openapi.json") +out.parent.mkdir(parents=True, exist_ok=True) +out.write_text(json.dumps(app.openapi(), indent=2)) +``` + +```bash +python scripts/emit_openapi.py && (cd ../docs && markline build) +``` + +The document's `info` block comes from the app constructor, and it matters — +Markline derives the client name in generated code samples from the first word +of `info.title`, and the version pill from `info.version`: + +```python +from fastapi import FastAPI + +app = FastAPI( + title="Acme API", # → acme.accounts.create({ … }) in samples + version="1.4.2", # → the version pill + servers=[{"url": "https://api.acme.com"}], +) +``` + +## Tags become resources + +Tags are what Markline groups by. Set them per-router so a whole module lands on +one resource, and use slashes to nest: + +```python +from fastapi import APIRouter + +router = APIRouter(prefix="/orders", tags=["store/orders"]) +``` + +That renders a **Store** parent with an **Orders** child, routed at +`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags). + +Control **resource** order — and add resource descriptions — with +`openapi_tags`. Tags you omit sort after the listed ones, alphabetically: + +```python +app = FastAPI( + title="Acme API", + openapi_tags=[ + {"name": "accounts", "description": "Balances and statements."}, + {"name": "payments"}, + {"name": "store/orders"}, + ], +) +``` + +## Order the sidebar + +`openapi_extra` merges straight onto the operation object, which is exactly +where `x-nav-order` belongs: + +```python +@router.post("", openapi_extra={"x-nav-order": 1}) +async def create_account(body: AccountCreate) -> Account: + ... + + +@router.get("", openapi_extra={"x-nav-order": 2}) +async def list_accounts() -> list[Account]: + ... +``` + +This is the case reordering can't fix: Markline reads verbs in a fixed order, +so `GET /accounts` always precedes `POST /accounts` regardless of the order you +declare the handlers in. `x-nav-order` is the only way to open the resource on +**Create account**. + +Number them `10, 20, 30` and you can insert an endpoint later without touching +its neighbours. Operations you don't annotate keep document order, after the +ordered ones. + +## Events + +On 3.1 you have two options, and Markline reads both. + +**Native webhooks** — the idiomatic FastAPI route, available since 0.99: + +```python +@app.webhooks.post("account.created") +async def account_created(body: AccountCreatedEvent): + """A new account was opened.""" +``` + +FastAPI emits this under the document root's `webhooks` object. + +A root webhook only attaches to a resource if its operation carries a +matching `tags` entry — otherwise Markline parses it but has nowhere to show it. +Pass `tags=["accounts"]` to the webhook decorator. + +**`x-events`** — lighter, and it gives you the emitter cross-link. Put it on the +operation that causes the event and the endpoint gets a **Triggers** chip while +the event gets an **Emitted by** back-link: + +```python +@router.post( + "", + openapi_extra={ + "x-nav-order": 1, + "x-events": { + "account.created": { + "summary": "A new account was opened", + "payload": {"$ref": "#/components/schemas/AccountCreatedEvent"}, + "guide": "/guides/webhooks#account-created", + } + }, + }, +) +async def create_account(body: AccountCreate) -> Account: + ... +``` + +For that `$ref` to resolve, `AccountCreatedEvent` has to actually reach +`components.schemas`. If no endpoint returns or accepts it, FastAPI won't emit +it — reference the model from a response somewhere, or inline the payload +schema instead of using `$ref`. + +Full behaviour in [Events & webhooks](/openapi#events-webhooks). + +## Code samples + +Replace the generated rail on an operation when you ship an SDK whose calls +don't match Markline's inferred ones: + +```python +@router.post( + "", + openapi_extra={ + "x-codeSamples": [ + { + "lang": "python", + "label": "Python SDK", + "source": 'client.accounts.create(email="ada@example.com")', + } + ] + }, +) +async def create_account(body: AccountCreate) -> Account: + ... +``` + +One custom sample replaces the **entire** generated rail for that +operation. List every language you want shown. + +No SDK at all? Skip the annotation and set `"codeSamples": ["curl"]` in +`markline.json` — that suppresses the invented SDK snippets everywhere at once. + +## Gotchas + +**`operationId`s are ugly and unstable by default.** FastAPI derives them from +the function name, path and method — `create_account_accounts_post`. Markline +routes per-operation deep links and [MDX overlays](/openapi#mdx-overlays) off +`operationId`, so those names end up in URLs and overlay filenames. Pin them: + +```python +@router.post("", operation_id="createAccount") +``` + +Or normalise the whole app once, before emitting: + +```python +for route in app.routes: + if isinstance(route, APIRoute): + route.operation_id = route.name +``` + +**`app.openapi()` caches.** It memoises into `app.openapi_schema`, so if you +mutate routes after the first call you'll dump a stale document. In a one-shot +emit script this never bites; in a longer script, set +`app.openapi_schema = None` before re-reading. + +**`openapi_extra` merges, it doesn't validate.** A typo like `x-navorder` is +silently ignored — Markline only reads `x-nav-order`, and only when the value is +a **number**. `"x-nav-order": "1"` is dropped. + +**Only the first tag counts for grouping.** An operation with +`tags=["accounts", "beta"]` lands on **Accounts**; the second tag doesn't create +a second placement. + +**Pydantic aliases show up verbatim.** Markline renders the emitted schema, so +`alias`/`serialization_alias` names are what your readers see. That's usually +what you want — just be aware the docs follow the wire format, not your Python +attribute names. diff --git a/site/docs/guides/hono-zod-openapi.mdx b/site/docs/guides/hono-zod-openapi.mdx new file mode 100644 index 0000000..b3baaa6 --- /dev/null +++ b/site/docs/guides/hono-zod-openapi.mdx @@ -0,0 +1,226 @@ +--- +title: Hono + Zod OpenAPI +lede: Define routes and schemas once with @hono/zod-openapi, write the document to disk, and drop Markline's extensions straight into createRoute. +crumbs: + - label: Docs + href: / + - label: Guides + href: /guides + - label: Hono + Zod OpenAPI +toc: + - id: emit-the-document + label: Emit the document + - id: tags-become-resources + label: Tags become resources + - id: order-the-sidebar + label: Order the sidebar + - id: events + label: Events + - id: code-samples + label: Code samples + - id: gotchas + label: Gotchas +--- + +`@hono/zod-openapi` is the cleanest fit of the four. `createRoute()` takes an +OpenAPI **operation object** — it destructures `method`, `path`, `request` and +`responses` and spreads everything else through untouched — so Markline's +extensions go in as literal keys, no decorator or post-process step in between. + +## Emit the document + +`OpenAPIHono` exposes the document directly. Write it to `api/openapi.json` as a +build step: + +```ts +// scripts/emit-openapi.ts +import { mkdirSync, writeFileSync } from "node:fs"; +import { app } from "../src/app"; + +const doc = app.getOpenAPI31Document({ + openapi: "3.1.0", + info: { title: "Acme API", version: "1.4.2" }, // ← client name + version pill + servers: [{ url: "https://api.acme.com" }], +}); + +mkdirSync("../docs/api", { recursive: true }); +writeFileSync("../docs/api/openapi.json", JSON.stringify(doc, null, 2)); +``` + +```jsonc +// package.json +"scripts": { + "docs:spec": "tsx scripts/emit-openapi.ts", + "docs:build": "npm run docs:spec && cd ../docs && markline build" +} +``` + +`getOpenAPI31Document()` emits 3.1, which unlocks native root `webhooks`. +`getOpenAPIDocument()` gives you 3.0 if you need it — Markline reads both. +`app.doc()` / `app.doc31()` mount the same document on a route; use those for +local inspection, not for the build. + +## Tags become resources + +`tags` on the route config is what Markline groups by, and slashes nest: + +```ts +import { createRoute, z } from "@hono/zod-openapi"; + +const createOrder = createRoute({ + method: "post", + path: "/orders", + tags: ["store/orders"], + request: { body: { content: { "application/json": { schema: OrderCreate } } } }, + responses: { 201: { description: "Created", content: { "application/json": { schema: Order } } } }, +}); +``` + +That renders a **Store** parent with an **Orders** child, routed at +`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags). + +Set **resource** order in the document config — omitted tags sort after the +listed ones, alphabetically: + +```ts +app.getOpenAPI31Document({ + openapi: "3.1.0", + info: { title: "Acme API", version: "1.4.2" }, + tags: [ + { name: "accounts", description: "Balances and statements." }, + { name: "payments" }, + { name: "store/orders" }, + ], +}); +``` + +## Order the sidebar + +`x-nav-order` is just another key on the route config: + +```ts +const createAccount = createRoute({ + method: "post", + path: "/accounts", + tags: ["accounts"], + operationId: "createAccount", + summary: "Create an account", + "x-nav-order": 1, + request: { body: { content: { "application/json": { schema: AccountCreate } } } }, + responses: { 201: { description: "Created", content: { "application/json": { schema: Account } } } }, +}); + +const listAccounts = createRoute({ + method: "get", + path: "/accounts", + tags: ["accounts"], + operationId: "listAccounts", + summary: "List accounts", + "x-nav-order": 2, + responses: { 200: { description: "OK", content: { "application/json": { schema: z.array(Account) } } } }, +}); +``` + +It type-checks: `RouteConfig` extends OpenAPI's `OperationObject`, which allows +arbitrary `x-` keys. + +These two routes are the case nothing else can fix — they share a path, and +Markline reads verbs in a fixed order, so `GET /accounts` would always come +first no matter how you register them. Number your operations `10, 20, 30` and +you can insert one later without renumbering. + +## Events + +On a 3.1 document you have both options. + +**Native webhooks**, registered on the underlying registry: + +```ts +app.openAPIRegistry.registerWebhook({ + method: "post", + path: "account.created", + tags: ["accounts"], + responses: { 200: { description: "Acknowledged" } }, + request: { body: { content: { "application/json": { schema: AccountCreatedEvent } } } }, +}); +``` + +A root webhook only attaches to a resource if it carries a matching +`tags` entry. Leave `tags` off and Markline parses it but has nowhere to put +it. + +**`x-events`** is lighter and gives you the emitter cross-link — the endpoint +gets a **Triggers** chip, the event an **Emitted by** back-link: + +```ts +const createAccount = createRoute({ + method: "post", + path: "/accounts", + tags: ["accounts"], + "x-nav-order": 1, + "x-events": { + "account.created": { + summary: "A new account was opened", + payload: { $ref: "#/components/schemas/AccountCreatedEvent" }, + guide: "/guides/webhooks#account-created", + }, + }, + // … +}); +``` + +For that `$ref` to resolve, register the schema under a name: + +```ts +app.openAPIRegistry.register("AccountCreatedEvent", AccountCreatedEvent); +``` + +Or use `AccountCreatedEvent.openapi("AccountCreatedEvent")` at definition time — +either way the component has to exist, or the payload renders empty. Full +behaviour in [Events & webhooks](/openapi#events-webhooks). + +## Code samples + +```ts +const createAccount = createRoute({ + // … + "x-codeSamples": [ + { + lang: "typescript", + label: "SDK", + source: 'await acme.accounts.create({ email: "ada@example.com" });', + }, + ], +}); +``` + +One custom sample replaces the **entire** generated rail for that +operation — list every language you want shown. + +No SDK? Skip this and set `"codeSamples": ["curl"]` in `markline.json`, which +drops the invented `acme.accounts.create(…)` snippets everywhere at once. + +## Gotchas + +**Set `operationId` yourself.** Without it the generator falls back to a derived +id, and Markline routes per-operation deep links and [MDX +overlays](/openapi#mdx-overlays) off `operationId`. An explicit `operationId` keeps +those URLs and overlay filenames stable across refactors. + +**Register schemas you `$ref`.** A `$ref` to +`#/components/schemas/Foo` is only valid if `Foo` was registered — inline Zod +schemas get inlined into the operation, not hoisted. `.openapi("Foo")` or +`registry.register("Foo", schema)` hoists them, which also stops the same shape +being duplicated across every operation that uses it. + +**Only the first tag groups.** `tags: ["accounts", "beta"]` lands the operation +on **Accounts**; the second tag doesn't create a second placement. + +**`x-` keys are typed as `any`.** The spread-through is untyped, so a typo like +`"x-navorder"` compiles fine and is silently ignored. Markline reads +`x-nav-order`, and only when the value is a **number**. + +**`z.array()` at the top level of a response is fine, but name it.** An unnamed +array response renders as an anonymous inline schema with no attribute table +heading. `z.array(Account).openapi("AccountList")` gives readers something to +anchor on. diff --git a/site/docs/guides/index.mdx b/site/docs/guides/index.mdx new file mode 100644 index 0000000..2949f08 --- /dev/null +++ b/site/docs/guides/index.mdx @@ -0,0 +1,96 @@ +--- +title: Framework guides +lede: Point Markline at the OpenAPI document your backend already emits — then use four small extensions to shape the reference without touching your routes. +crumbs: + - label: Docs + href: / + - label: Guides +toc: + - id: what-each-guide-covers + label: What each guide covers + - id: the-contract + label: The contract + - id: pick-your-framework + label: Pick your framework + - id: not-listed + label: Not listed? +--- + +Markline doesn't ask you to describe your API twice. If your backend already +produces an OpenAPI 3.0 or 3.1 document — and most do — that document *is* your +reference. These guides cover the last mile: getting the document onto disk at +build time, and annotating it so the generated site reads the way you'd write it +by hand. + +## What each guide covers + +Every guide walks the same six steps, so you can skim across frameworks: + +1. **Emit the document** into `api/openapi.json` as a build step. +2. **Tags become resources** — including nested `store/orders` tags. +3. **Order the sidebar** with `x-nav-order`. +4. **Document events** with `x-events`. +5. **Replace generated code samples** with `x-codeSamples`. +6. **Gotchas** — the framework-specific traps. + +## The contract + +The whole surface is four OpenAPI extensions plus your tags. Nothing here is +Markline-specific plumbing: they're plain JSON keys on a standard document, so +they survive `$ref` bundling, spec linting, and every other tool in the chain. + +| What you want | Where it goes | Reference | +| --- | --- | --- | +| Group endpoints into resources | `tags` on the operation | [Nested tags](/openapi#nested-tags) | +| Reorder operations in a resource | `x-nav-order` on the operation | [Sidebar order](/openapi#sidebar-order) | +| Document webhooks / async events | `x-events` on a tag or operation | [Events & webhooks](/openapi#events-webhooks) | +| Hand-write the code rail | `x-codeSamples` on the operation | [Code samples](/openapi#code-samples) | + +Every one of these is optional. A plain, unannotated document already +renders a complete reference — resource pages, parameter tables, generated +cURL/Node/Python/Go samples, and the request explorer. The extensions are for +when the default reading order isn't the one you want. + +## Pick your framework + + + Decorator-driven, via `@nestjs/swagger` and `@ApiExtension`. + Native OpenAPI 3.1, shaped with `openapi_extra`. + springdoc-openapi, with `@Extension` and `@ExtensionProperty`. + Schema-first TypeScript, extensions inline in `createRoute`. + + +## Not listed? + +You don't need a guide. Markline reads a standard document, so any generator +works — Django REST with drf-spectacular, Laravel, Rails with rswag, ASP.NET +with Swashbuckle, Go with huma or swaggo, or a hand-written YAML file. + +The only thing a guide really buys you is the idiomatic way to attach an +`x-` key in that framework. If yours has no clean hook, post-process the emitted +document instead — the extensions are just JSON: + +```js +// scripts/annotate-spec.mjs — run after your generator, before `markline build` +import { readFileSync, writeFileSync } from "node:fs"; + +const spec = JSON.parse(readFileSync("api/openapi.json", "utf8")); + +const order = { + createSession: 1, + endSession: 2, +}; + +for (const item of Object.values(spec.paths)) { + for (const op of Object.values(item)) { + const n = order[op.operationId]; + if (n !== undefined) op["x-nav-order"] = n; + } +} + +writeFileSync("api/openapi.json", JSON.stringify(spec, null, 2)); +``` + +This is also the honest answer for tag-level `x-events` in frameworks +whose decorators only attach to handlers — see the note at the end of the +[NestJS guide](/guides/nestjs#gotchas). diff --git a/site/docs/guides/nestjs.mdx b/site/docs/guides/nestjs.mdx new file mode 100644 index 0000000..9cfa7dd --- /dev/null +++ b/site/docs/guides/nestjs.mdx @@ -0,0 +1,225 @@ +--- +title: NestJS +lede: Emit your OpenAPI document from @nestjs/swagger, snapshot it into your docs, and shape the reference with decorators that survive regeneration. +crumbs: + - label: Docs + href: / + - label: Guides + href: /guides + - label: NestJS +toc: + - id: emit-the-document + label: Emit the document + - id: tags-become-resources + label: Tags become resources + - id: order-the-sidebar + label: Order the sidebar + - id: events + label: Events + - id: code-samples + label: Code samples + - id: gotchas + label: Gotchas +--- + +NestJS is the framework Markline's OpenAPI extensions were designed against. +`@nestjs/swagger` builds the document from decorators you already write, and +`@ApiExtension` gives you a clean hook for everything else — so the annotations +live next to the handler and survive every regeneration. + +## Emit the document + +`SwaggerModule.createDocument()` returns a plain object. Write it to +`api/openapi.json` in your docs project and commit it, so the docs build never +depends on a running service. + +```ts +// scripts/emit-openapi.ts +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { AppModule } from '../src/app.module'; + +async function main() { + const app = await NestFactory.create(AppModule, { logger: false }); + + const config = new DocumentBuilder() + .setTitle('Acme API') // ← names the generated SDK samples + .setVersion('1.4.2') // ← drives the version pill + .addServer('https://api.acme.com') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + + mkdirSync('../docs/api', { recursive: true }); + writeFileSync('../docs/api/openapi.json', JSON.stringify(document, null, 2)); + await app.close(); +} + +main(); +``` + +```jsonc +// package.json +"scripts": { + "docs:spec": "ts-node scripts/emit-openapi.ts", + "docs:build": "npm run docs:spec && cd ../docs && markline build" +} +``` + +`setTitle` is load-bearing. Markline derives the client name in generated +code samples from the first word of `info.title` — `"Acme API"` produces +`acme.payments.create({ … })`. See [how samples are +named](/openapi#code-samples). + +## Tags become resources + +`@ApiTags` is what Markline groups by. Slash-separated tags nest in the sidebar +without changing any URLs: + +```ts +@ApiTags('store/orders') +@Controller('orders') +export class OrdersController {} +``` + +That renders a **Store** parent with an **Orders** child, still routed at +`/api-reference/store-orders`. Depth is arbitrary. See [Nested +tags](/openapi#nested-tags). + +Declare the tags at the document level to control **resource** order — anything +you omit sorts after the listed tags, alphabetically: + +```ts +const config = new DocumentBuilder() + .addTag('accounts') + .addTag('payments') + .addTag('store/orders') + .build(); +``` + +## Order the sidebar + +`@ApiTags` fixes which resource an endpoint lands on; `x-nav-order` fixes where +it sits *inside* that resource. + +```ts +import { ApiExtension, ApiOperation, ApiTags } from '@nestjs/swagger'; + +@ApiTags('accounts') +@Controller('accounts') +export class AccountsController { + @Post() + @ApiExtension('x-nav-order', 1) + @ApiOperation({ summary: 'Create an account' }) + create() { /* … */ } + + @Get() + @ApiExtension('x-nav-order', 2) + @ApiOperation({ summary: 'List accounts' }) + list() { /* … */ } +} +``` + +This is the case you **cannot** solve by reordering anything. Markline reads +verbs in a fixed order, so `GET /accounts` always precedes `POST /accounts` no +matter how the controller is written — `x-nav-order` is the only way to put +**Create an account** first. + +A tiny composed decorator keeps it readable when you're ordering a whole +controller: + +```ts +import { applyDecorators } from '@nestjs/common'; +import { ApiExtension } from '@nestjs/swagger'; + +export const NavOrder = (n: number) => applyDecorators(ApiExtension('x-nav-order', n)); + +// @NavOrder(1) +``` + +Number them `10, 20, 30` rather than `1, 2, 3` and you can slot a new +endpoint in later without touching its neighbours. + +## Events + +NestJS has no `@ApiCallbacks`, which is exactly why `x-events` exists. Annotate +the handler that causes the event — Markline aggregates it onto the resource, +adds a **Triggers** chip to the endpoint, and an **Emitted by** back-link on the +event. + +```ts +import { ApiExtension, ApiExtraModels, ApiTags, getSchemaPath } from '@nestjs/swagger'; + +@ApiTags('accounts') +@ApiExtraModels(AccountCreatedEvent) +@Controller('accounts') +export class AccountsController { + @Post() + @ApiExtension('x-events', { + 'account.created': { + summary: 'A new account was opened', + payload: { $ref: getSchemaPath(AccountCreatedEvent) }, + guide: '/guides/webhooks#account-created', + }, + }) + create() { /* … */ } +} +``` + +`@ApiExtraModels` is required — without it `getSchemaPath()` points at a schema +that was never emitted into `components.schemas`. Full details in [Events & +webhooks](/openapi#events-webhooks). + +## Code samples + +Markline generates cURL, Node, Python and Go rails from the operation itself. +If you ship a real SDK whose signatures don't match, replace the rail for that +operation: + +```ts +@Post() +@ApiExtension('x-codeSamples', [ + { + lang: 'ruby', + label: 'Ruby', + source: "Acme::Account.create(\n email: \"ada@example.com\",\n)", + }, +]) +create() { /* … */ } +``` + +`x-codeSamples` is all-or-nothing per operation — one custom sample +replaces the entire generated rail. List every language you want shown. + +If you don't ship an SDK at all, don't annotate every operation. Set +`"codeSamples": ["curl"]` in `markline.json` and the invented +`acme.accounts.create(…)` snippets disappear everywhere at once. + +## Gotchas + +**`@ApiExtension` values aren't restricted to objects.** The decorator enforces +only that the key starts with `x-`; the value is cloned through as-is, so +`@ApiExtension('x-nav-order', 1)` is valid. Repeated calls merge, so `x-events` +and `x-nav-order` coexist on one handler. + +**`@ApiExtension` on a controller class applies to every route in it.** Handy +for `x-events` that any endpoint in a resource can emit — and a trap for +`x-nav-order`, where it would give every operation the same rank. + +**Operations need stable `operationId`s.** Markline routes per-operation deep +links and MDX overlays off `operationId`. NestJS derives it from the controller +and method name (`AccountsController_create`), so renaming a method breaks +existing links. Pin the ones you care about with +`@ApiOperation({ operationId: 'createAccount' })`. + +**There's no decorator for tag objects.** An event with no single triggering +endpoint — delivered by a processor or a batch job — can't be attached via +decorators. Either hang it off the closest operation, or add it under +`tags[].x-events` in a [post-processing step](/guides#not-listed) after +`createDocument()` and before you write the file. + +**Snapshot, don't proxy.** Pointing the docs build at a live `/api-json` +endpoint couples your docs deploy to your API being up. Emit to a committed +file; the diff also gives you a review surface for accidental API changes. diff --git a/site/docs/guides/spring-boot.mdx b/site/docs/guides/spring-boot.mdx new file mode 100644 index 0000000..9404895 --- /dev/null +++ b/site/docs/guides/spring-boot.mdx @@ -0,0 +1,230 @@ +--- +title: Spring Boot +lede: Pull the document springdoc-openapi already serves at /v3/api-docs into your docs build, then shape the reference with swagger-core's @Extension annotations. +crumbs: + - label: Docs + href: / + - label: Guides + href: /guides + - label: Spring Boot +toc: + - id: emit-the-document + label: Emit the document + - id: tags-become-resources + label: Tags become resources + - id: order-the-sidebar + label: Order the sidebar + - id: events + label: Events + - id: code-samples + label: Code samples + - id: gotchas + label: Gotchas +--- + +springdoc-openapi introspects your Spring MVC or WebFlux controllers and serves +the document at `/v3/api-docs`. Everything Markline needs beyond that comes from +swagger-core's `@Extension` / `@ExtensionProperty` annotations — with one sharp +edge around value types that's worth reading before you start. + +## Emit the document + +springdoc generates at runtime, so the build step is "start the app, take a +snapshot." The official Maven plugin does exactly that during +`integration-test`: + +```xml + + org.springdoc + springdoc-openapi-maven-plugin + 1.4 + + + integration-test + generate + + + + http://localhost:8080/v3/api-docs + openapi.json + ${project.basedir}/../docs/api + + +``` + +Pair it with `spring-boot-maven-plugin`'s `start` / `stop` goals in +`pre-integration-test` / `post-integration-test`. On Gradle, the +`org.springdoc.openapi-gradle-plugin` equivalent does the same. + +If you'd rather not wire a plugin, the framework-agnostic version is two lines +and works everywhere: + +```bash +java -jar target/app.jar & +until curl -sf localhost:8080/v3/api-docs -o ../docs/api/openapi.json; do sleep 1; done +kill %1 +``` + +Set the document metadata once — `info.title` names the client in generated code +samples, `info.version` drives the version pill: + +```java +@OpenAPIDefinition( + info = @Info(title = "Acme API", version = "1.4.2"), + servers = @Server(url = "https://api.acme.com") +) +@SpringBootApplication +public class AcmeApplication { } +``` + +## Tags become resources + +`@Tag` on the controller is what Markline groups by, and slashes nest: + +```java +@Tag(name = "store/orders") +@RestController +@RequestMapping("/orders") +public class OrderController { } +``` + +That renders a **Store** parent with an **Orders** child, routed at +`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags). + +Declare tags at the document level to fix **resource** order and give each one a +description — anything you omit sorts after the listed tags, alphabetically: + +```java +@OpenAPIDefinition( + info = @Info(title = "Acme API", version = "1.4.2"), + tags = { + @Tag(name = "accounts", description = "Balances and statements."), + @Tag(name = "payments"), + @Tag(name = "store/orders") + } +) +``` + +## Order the sidebar + +Here's the sharp edge. `@ExtensionProperty.value()` is a **`String`**, and +`parseValue()` defaults to `false` — so the obvious spelling emits +`"x-nav-order": "1"`, a JSON *string*. Markline only honours `x-nav-order` when +it's a **number**, so a string is silently ignored and nothing moves. + +Set `parseValue = true`: + +```java +@Operation( + summary = "Create an account", + extensions = @Extension(properties = @ExtensionProperty( + name = "x-nav-order", value = "1", parseValue = true + )) +) +@PostMapping +public Account create(@RequestBody AccountCreate body) { … } +``` + +Leave `parseValue` off and you get `"x-nav-order": "1"`. It's valid +OpenAPI, it lints clean, and it does nothing. This is the single most common way +to wire this up wrong on the JVM. + +Note the empty `@Extension` name: with no `name`, swagger-core promotes each +property to a **top-level** extension key (prefixing `x-` if you left it off). +Give `@Extension` a name and you get a nested object instead — which is what you +want for `x-events` below, and not what you want here. + +This is also the case that can't be solved by reordering source. Markline reads +verbs in a fixed order, so `GET /accounts` always precedes `POST /accounts` +however the controller is written. Number your operations `10, 20, 30` and you +can insert one later without renumbering its neighbours. + +## Events + +Give `@Extension` a **name** and its properties become a nested object — which +is exactly the shape `x-events` wants. The payload is JSON, so it needs +`parseValue = true` too: + +```java +@Operation( + summary = "Create an account", + extensions = @Extension(name = "events", properties = @ExtensionProperty( + name = "account.created", + value = """ + { + "summary": "A new account was opened", + "payload": { "$ref": "#/components/schemas/AccountCreatedEvent" }, + "guide": "/guides/webhooks#account-created" + } + """, + parseValue = true + )) +) +@PostMapping +public Account create(@RequestBody AccountCreate body) { … } +``` + +`@Extension(name = "events", …)` produces the key `x-events` — swagger-core +prepends `x-` for you. Placed on the operation, the event inherits the +controller's `@Tag`, so `POST /accounts` gets a **Triggers** chip and the event +an **Emitted by** back-link. + +The `$ref` only resolves if `AccountCreatedEvent` reached +`components.schemas`. If no endpoint returns it, add +`@Schema(implementation = AccountCreatedEvent.class)` somewhere reachable, or +inline the payload schema instead of using `$ref`. + +Full behaviour in [Events & webhooks](/openapi#events-webhooks). + +## Code samples + +`x-codeSamples` is an **array**, which the nested-object form can't express. Use +the empty-name form with a JSON array as the value: + +```java +@Operation( + extensions = @Extension(properties = @ExtensionProperty( + name = "x-codeSamples", + value = """ + [{ "lang": "java", "label": "Java SDK", + "source": "acme.accounts().create(AccountCreate.of(\\"ada@example.com\\"));" }] + """, + parseValue = true + )) +) +``` + +One custom sample replaces the **entire** generated rail for that +operation — list every language you want shown. + +If you don't publish an SDK, don't annotate anything: set +`"codeSamples": ["curl"]` in `markline.json` and the invented +`acme.accounts.create(…)` snippets disappear everywhere at once. + +## Gotchas + +**`parseValue = true` on every non-string value.** Numbers, booleans, objects +and arrays all arrive as strings otherwise. It's the first thing to check when +an extension "doesn't work." + +**Blank values are dropped.** swagger-core skips any `@ExtensionProperty` whose +name or value is blank, so an empty string won't clear an inherited value. + +**Empty `@Extension` name vs named.** No name → each property becomes its own +top-level `x-` key. A name → one `x-` key holding a map of the properties. +Picking the wrong one is the second most common failure. + +**`operationId`s collide and get suffixed.** springdoc derives them from the +method name and appends `_1`, `_2` on collision. Markline routes per-operation +deep links and [MDX overlays](/openapi#mdx-overlays) off `operationId`, so those +suffixes leak into URLs and filenames — and shift when you add a method. Pin +them with `@Operation(operationId = "createAccount")`. + +**Only the first tag groups.** A controller with two `@Tag`s puts its operations +on the first one; the second doesn't create a second placement. + +**Text blocks and quotes.** A Java text block takes bare `"` happily, so the +JSON keys and values above need no escaping. The exception is a quote that has +to survive *into* the JSON string — write `\\"` so the runtime string holds +`\"`, which the JSON parser then reads as a quote. Write `\"` and the block +collapses it to `"`, terminating the JSON string early. diff --git a/site/docs/openapi.mdx b/site/docs/openapi.mdx index 8937543..8dc3644 100644 --- a/site/docs/openapi.mdx +++ b/site/docs/openapi.mdx @@ -8,15 +8,17 @@ toc: label: What you get - id: nested-tags label: Nested tags + - id: sidebar-order + label: Sidebar order - id: code-samples label: Code samples - - id: playground + - id: the-playground label: The playground - - id: events + - id: events-webhooks label: Events & webhooks - - id: overlays + - id: mdx-overlays label: MDX overlays - - id: ai-and-actions + - id: ai-page-actions label: AI & page actions - id: versions label: Versions @@ -105,6 +107,64 @@ admin/api/keys Admin `@ApiTags` to get clean resource boundaries. Per-resource [MDX overlays](#overlays) still key off the full slug (`store-orders.mdx`). +## Sidebar order + +Within a resource, operations render in the order Markline reads them from the +document. That's rarely the order you'd teach in — the "create a session" +call is usually declared after the read endpoints, and it's the one that should +open the page. + +Set `x-nav-order` (a number) on any operation to pin it: + +```jsonc +"paths": { + "/session": { + "post": { + "operationId": "createSession", + "summary": "Create a session", + "x-nav-order": 1 // opens the resource + } + }, + "/session/{id}": { + "delete": { + "operationId": "endSession", + "summary": "End a session", + "x-nav-order": 2 + } + } +} +``` + +- Operations carrying `x-nav-order` sort **ascending**, ahead of everything else. +- Operations without it keep document order, after the ordered ones. +- You annotate only what you care about — a spec with no `x-nav-order` anywhere + renders exactly as it did before. +- Numbers are relative, not positional. Use `10, 20, 30` and you can insert + later without renumbering. Ties fall back to document order. +- The order applies to all three surfaces that read the resource at once: the + sidebar tree, the **Endpoints** card, and the rendered section order. + +This orders operations **within** a tag. Resource order comes from the +root `tags` array — list your tags there in the order you want them, and any +tag you leave out sorts after the listed ones, alphabetically. + +### Why not just reorder the document? + +Two reasons the document's own order can't carry this: + +- **Methods on one path are fixed.** Markline reads verbs in a stable order — + `get`, `post`, `put`, `patch`, `delete`, `options`, `head` — so two operations + sharing a path item always come out GET before POST. Moving them around in the + document changes nothing. If you want **Create account** (`POST /accounts`) + above **List accounts** (`GET /accounts`), `x-nav-order` is the only lever. +- **Generated documents get regenerated.** If your spec comes out of NestJS, + FastAPI, springdoc or similar, path order follows your route or handler + declaration order. Hand-sorting the emitted JSON is wiped on the next build, + and reordering handlers to fix the docs couples source layout to reading + order. An annotation on the handler survives regeneration. + +See the [framework guides](/guides) for the per-framework annotation. + ## Code samples Every endpoint gets a dark **code rail**. By default Markline *generates* four @@ -344,9 +404,11 @@ export class CardsController { it the `$ref` points at a schema that was never emitted. - The event inherits `@ApiTags('cards')`, so it lands on the **Cards** resource and `POST /cards` gets the **Triggers** chip. -- `@ApiExtension` requires an `x-`-prefixed key and an object value — both hold. - Placed on the **controller class** it applies to every route; on a **handler**, - just that operation. +- `@ApiExtension` only enforces that the key starts with `x-`; the value is + passed through as-is, so objects, arrays and plain numbers all work. Repeated + `@ApiExtension` calls merge, so `x-events` and `x-nav-order` can sit on the + same handler. Placed on the **controller class** it applies to every route; on + a **handler**, just that operation. Reuse a shared set with a small composed decorator: @@ -365,6 +427,11 @@ delivers), attach it to the most relevant operation, or add it under a tag's `x-events` by post-processing the generated document before you write `openapi.json` — NestJS has no decorator for tag objects. +The [NestJS guide](/guides/nestjs) walks the whole setup end to end — emitting +the document, tags, ordering, events and code samples. There are matching guides +for [FastAPI](/guides/fastapi), [Spring Boot](/guides/spring-boot) and +[Hono](/guides/hono-zod-openapi). + ## MDX overlays Layer authored MDX on top of the generated reference — full components @@ -399,4 +466,4 @@ the spec's `info.version` (a semver `1.4.2` → `v1 · 1.4.2`; a date `2025-06-0 With multiple [versions](/versions) configured, each version ships its **own** `/api/openapi.json`, served at `/api-reference/` — the selector switches the spec in place and every link stays under that version's prefix. See -[Versions & i18n](/versions#api-reference) for the folder layout. +[Versions & i18n](/versions#api-reference-per-version) for the folder layout. diff --git a/site/markline.json b/site/markline.json index 06b0969..ae20656 100644 --- a/site/markline.json +++ b/site/markline.json @@ -56,6 +56,39 @@ } ] }, + { + "id": "guides", + "label": "Guides", + "href": "/guides", + "match": ["/guides"], + "groups": [ + { + "group": "Overview", + "pages": [ + { "href": "/guides", "label": "Framework guides" } + ] + }, + { + "group": "Node & TypeScript", + "pages": [ + { "href": "/guides/nestjs", "label": "NestJS" }, + { "href": "/guides/hono-zod-openapi", "label": "Hono + Zod OpenAPI" } + ] + }, + { + "group": "Python", + "pages": [ + { "href": "/guides/fastapi", "label": "FastAPI" } + ] + }, + { + "group": "JVM", + "pages": [ + { "href": "/guides/spring-boot", "label": "Spring Boot" } + ] + } + ] + }, { "id": "api-reference", "label": "API reference",