Skip to content

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.

  • Scopes decide the content. Only handlers that a scope of the token feature reaches appear. ticketing:write:ticket:purge is reachable from a screen but in no scope, so it is listed under apiDocs.uiOnlyWrites with 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, :description and :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 recordApiExample from @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.

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.ts

Regenerate 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.ts

Both 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 block
src/feature.ts ticketing feature and the token feature with its scopes
src/__tests__/api-docs.integration.test.ts mints a token, records the examples, checks scope limits
src/__tests__/api-docs.golden.test.ts generator output equals api-docs/
api-examples/ recorded requests and responses
api-docs/ the golden output

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