API docs
API documentation for personal access tokens, generated from the app itself. A ticketing
feature exposes its ticket handlers through token scopes; kumiko api-docs turns the
registry, the translations and recorded requests into Markdown pages, an OpenAPI file and
an index.json that a website or an agent can read.
What it shows
Section titled “What it shows”- Scopes decide the content. Only handlers that a scope of the token feature reaches
appear.
ticketing:write:ticket:purgeis reachable from a screen but in no scope, so it is listed underapiDocs.uiOnlyWriteswith a reason; without that entry the generator reports it. - Texts come from the feature. Titles, descriptions and field labels per locale use the
keys
<handler qn>:title,:descriptionand:field:<name>; the German pages need the German translations, the menu path to the token screen comes from the nav tree. - Examples are recordings. The integration test sends real requests with a token through
recordApiExamplefrom@cosmicdrift/kumiko-testing/api-examples. UUIDs, timestamps and tokens are replaced by fixed values, so a recording only changes when the API changes. - The output is a golden.
api-docs/is committed and the golden test fails when the generator would produce something else.
Commands
Section titled “Commands”Record the examples (writes api-examples/, needs Postgres from docker compose up -d):
KUMIKO_RECORD_API_EXAMPLES=1 bun test --config=bunfig.integration.toml samples/recipes/api-docs/src/__tests__/api-docs.integration.test.tsRegenerate the golden (empties and rewrites api-docs/):
KUMIKO_UPDATE_API_DOCS_GOLDEN=1 bun test samples/recipes/api-docs/src/__tests__/api-docs.golden.test.tsBoth tests run without the flags as ordinary checks.
In an app, kumiko api-docs writes the pages to apiDocs.outDir (default api-docs) and
kumiko api-docs --check compares them with the disk, so a CI step can keep them current.
The golden test pins the framework and app version in index.json and openapi.json, so
those two files differ from a real run of the command.
kumiko.config.ts features and the apiDocs blocksrc/feature.ts ticketing feature and the token feature with its scopessrc/__tests__/api-docs.integration.test.ts mints a token, records the examples, checks scope limitssrc/__tests__/api-docs.golden.test.ts generator output equals api-docs/api-examples/ recorded requests and responsesapi-docs/ the golden outputSource code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// API docs sample: a ticketing app whose ticket handlers are reachable with a personal access token.// Every handler a token scope reaches needs a description and, per locale, a title, description// and field labels; `kumiko api-docs` turns them into the pages under api-docs/.
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";import { createEntity, createEntityExecutor, createSelectField, createTextField, defineFeature, emitEvent, setFields,} from "@cosmicdrift/kumiko-framework/engine";import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";import { z } from "zod";
export const ticketEntity = createEntity({ table: "read_api_docs_tickets", webhookEvents: true, description: "A support request with a title, a priority and an open or closed status.", fields: { title: createTextField({ personal: false, reason: "is_business_data", required: true }), status: createSelectField({ options: ["open", "closed"] as const, default: "open" }), priority: createSelectField({ options: ["low", "normal", "high"] as const, default: "normal" }), },});
const ticketAccess = { roles: ["Admin", "User"] } as const;
export const ticketingFeature = defineFeature("ticketing", (r) => { const { table: ticketTable, executor: ticketExecutor } = createEntityExecutor( "ticket", ticketEntity, );
r.crud("ticket", ticketEntity, { write: { access: ticketAccess }, read: { access: ticketAccess }, verbs: { delete: false }, descriptions: { create: "Opens a new ticket.", update: "Changes the title or the priority of a ticket.", list: "Lists tickets, newest first.", detail: "Returns one ticket.", }, });
const ticketClosed = r.defineEvent("ticket-closed", z.object({ resolution: z.string().min(1) }), { piiFields: "none", });
r.defineWebhookEvent({ name: "ticket-closed", wireName: "ticket.closed", description: "A ticket was closed and its resolution recorded.", payload: z.object({ id: z.uuid().describe("ID of the closed ticket"), resolution: z.string().describe("How the ticket was resolved"), }), version: 1, });
r.writeHandler({ name: "ticket:close", description: "Closes a ticket and records how it was resolved.", schema: z.object({ id: z.uuid(), resolution: z.string().min(1) }), access: ticketAccess, handler: async (event, ctx) => { const ticket = await ticketExecutor.detail({ id: event.payload.id }, event.user, ctx.db); if (!ticket) return failNotFound("ticket", event.payload.id); if (ticket["status"] === "closed") return failUnprocessable("ticket_already_closed"); await emitEvent(ctx, ticketClosed, { aggregateId: event.payload.id, aggregateType: "ticket", payload: { resolution: event.payload.resolution }, }); return { isSuccess: true as const, data: { id: event.payload.id } }; }, });
// Reachable from a screen but deliberately outside every token scope: see uiOnlyWrites in kumiko.config.ts. r.writeHandler({ name: "ticket:purge", description: "Removes tickets that were closed long ago.", schema: z.object({ olderThanDays: z.number().int().min(30) }), access: { roles: ["Admin"] }, handler: async () => ({ isSuccess: true as const, data: { purged: 0 } }), });
const markTicketClosed = setFields(ticketTable, { status: "closed" }); r.projection({ name: "ticket-status", source: "ticket", table: ticketTable, apply: { [ticketClosed.name]: (event, tx, ctx) => markTicketClosed(event, tx, ctx) }, });
r.screen({ id: "ticket-list", type: "entityList", entity: "ticket", columns: ["title", "status", "priority"], }); r.screen({ id: "ticket-edit", type: "entityEdit", entity: "ticket", layout: { sections: [{ title: "ticketing:section.ticket", fields: ["title", "priority"] }] }, }); r.screen({ id: "close-ticket", type: "actionForm", handler: "ticketing:write:ticket:close", fields: { resolution: createTextField({ personal: false, reason: "is_business_data" }) }, layout: { sections: [{ title: "ticketing:section.close", fields: ["resolution"] }] }, }); r.screen({ id: "purge-tickets", type: "actionForm", handler: "ticketing:write:ticket:purge", fields: {}, layout: { sections: [] }, });
r.nav({ id: "tickets", label: "ticketing:nav:tickets", screen: "ticketing:screen:ticket-list" }); r.nav({ id: "settings", label: "ticketing:nav:settings" }); r.nav({ id: "api-tokens", label: "ticketing:nav:api-tokens", parent: "ticketing:nav:settings", screen: "personal-access-tokens:screen:api-tokens", });
r.translations({ keys: { "ticketing:nav:tickets": { en: "Tickets", de: "Tickets" }, "ticketing:nav:settings": { en: "Settings", de: "Einstellungen" }, "ticketing:nav:api-tokens": { en: "API tokens", de: "API-Tokens" }, "ticketing:section.ticket": { en: "Ticket", de: "Ticket" }, "ticketing:section.close": { en: "Close ticket", de: "Ticket schließen" }, "ticketing:entity:ticket:field:title": { en: "Title", de: "Titel" }, "ticketing:entity:ticket:field:status": { en: "Status", de: "Status" }, "ticketing:entity:ticket:field:priority": { en: "Priority", de: "Priorität" }, "ticketing:write:ticket:create:title": { en: "Create ticket", de: "Ticket anlegen" }, "ticketing:write:ticket:create:description": { en: "Opens a new ticket.", de: "Legt ein neues Ticket an.", }, "ticketing:write:ticket:update:title": { en: "Update ticket", de: "Ticket ändern" }, "ticketing:write:ticket:update:description": { en: "Changes the title or the priority of a ticket.", de: "Ändert Titel oder Priorität eines Tickets.", }, "ticketing:query:ticket:list:title": { en: "List tickets", de: "Tickets auflisten" }, "ticketing:query:ticket:list:description": { en: "Lists tickets, newest first.", de: "Listet Tickets, neueste zuerst.", }, "ticketing:query:ticket:detail:title": { en: "Show ticket", de: "Ticket anzeigen" }, "ticketing:query:ticket:detail:description": { en: "Returns one ticket.", de: "Liefert ein Ticket.", }, "ticketing:write:ticket:close:title": { en: "Close ticket", de: "Ticket schließen" }, "ticketing:write:ticket:close:description": { en: "Closes a ticket and records how it was resolved.", de: "Schließt ein Ticket und hält fest, wie es gelöst wurde.", }, "ticketing:write:ticket:close:field:id": { en: "Ticket", de: "Ticket" }, "ticketing:write:ticket:close:field:resolution": { en: "Resolution", de: "Lösung" }, "ticketing:write:ticket:purge:title": { en: "Purge tickets", de: "Tickets bereinigen" }, "ticketing:write:ticket:purge:description": { en: "Removes tickets that were closed long ago.", de: "Entfernt Tickets, die lange geschlossen sind.", }, "ticketing:write:ticket:purge:field:olderThanDays": { en: "Closed longer than (days)", de: "Länger geschlossen als (Tage)", }, "ticketing:entity:ticket": { en: "ticket", de: "Ticket" }, "ticketing:entity:ticket:field:id": { en: "Ticket ID", de: "Ticket-ID" }, "ticketing:webhook:ticket-closed:description": { en: "A ticket was closed and its resolution recorded.", de: "Ein Ticket wurde geschlossen und seine Lösung festgehalten.", }, "ticketing:webhook:ticket-closed:field:id": { en: "ID of the closed ticket", de: "ID des geschlossenen Tickets", }, "ticketing:webhook:ticket-closed:field:resolution": { en: "How the ticket was resolved", de: "Wie das Ticket gelöst wurde", }, "ticketing:entity:__action-form__:field:resolution": { en: "Resolution", de: "Lösung" }, }, });});
export const PAT_RATE_LIMIT = { maxRequests: 60, windowMs: 60_000 } as const;
export function patFeature() { return createPersonalAccessTokensFeature({ scopes: { tickets: { label: "Tickets", events: ["ticket.created", "ticketing:ticket-closed"], read: ["ticketing:query:ticket:*"], write: [ "ticketing:write:ticket:create", "ticketing:write:ticket:update", "ticketing:write:ticket:close", ], }, }, rateLimit: PAT_RATE_LIMIT, });}📄 On GitHub: samples/recipes/api-docs/src/feature.ts