From fa1b977c77773c3936ed7a8049e6207ad3393aa6 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 27 Oct 2026 11:15:07 +0000 Subject: [PATCH 1/9] feat(schemas): render the REST OpenAPI document as agent-readable markdown Add renderOpenApiMarkdown, a pure renderer that turns the generated OpenAPI document into one Markdown file: every operation with its parameters, body, responses and a minimal curl, shared shapes written once and linked, shared headers documented once. Mark X-LoreKit-Dry-Run with x-server-default: false and reword its description: the spec's default: true is only the console pre-fill, and a request without the header executes for real. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/schemas/package.json | 1 + packages/schemas/src/openapi/markdown.spec.ts | 176 +++++ packages/schemas/src/openapi/markdown.ts | 649 ++++++++++++++++++ packages/schemas/src/openapi/spec.ts | 21 +- .../functions/_shared/schemas/openapi/spec.ts | 21 +- 5 files changed, 858 insertions(+), 10 deletions(-) create mode 100644 packages/schemas/src/openapi/markdown.spec.ts create mode 100644 packages/schemas/src/openapi/markdown.ts diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 3ea29187c..88ec6416b 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -21,6 +21,7 @@ "./usage": "./src/domain/usage.ts", "./relevant": "./src/shared/relevant.ts", "./openapi/spec": "./src/openapi/spec.ts", + "./openapi/markdown": "./src/openapi/markdown.ts", "./tool-catalog": "./src/shared/tool-catalog.ts", "./llms/render": "./src/llms/render.ts" }, diff --git a/packages/schemas/src/openapi/markdown.spec.ts b/packages/schemas/src/openapi/markdown.spec.ts new file mode 100644 index 000000000..755172fcc --- /dev/null +++ b/packages/schemas/src/openapi/markdown.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { generateSpec } from './spec.ts'; +import { headingAnchor, renderOpenApiMarkdown, type OpenApiDocument } from './markdown.ts'; + +const LINKS = { + htmlUrl: 'https://lorekit.test/api-docs', + specUrl: 'https://lorekit.test/api-docs/spec', + llmsTxtUrl: 'https://lorekit.test/llms.txt', +}; + +const BASE_URL = 'https://example.test/functions/v1'; + +function section(markdown: string, heading: string): string { + const start = markdown.indexOf(`\n### ${heading}\n`); + if (start === -1) throw new Error(`no section ${heading}`); + const next = markdown.indexOf('\n### ', start + 1); + return markdown.slice(start, next === -1 ? undefined : next); +} + +// Runs the REAL document, so a route registered in spec.ts without being +// renderable here fails the build rather than vanishing from /api-docs.md. +describe('renderOpenApiMarkdown — the generated REST spec', () => { + const spec = generateSpec(BASE_URL) as unknown as OpenApiDocument; + const markdown = renderOpenApiMarkdown(spec, LINKS); + + it('documents every operation in the spec under its own heading', () => { + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of Object.keys(item)) { + expect(markdown, `${method.toUpperCase()} ${path}`).toContain(`\n### ${method.toUpperCase()} ${path}\n`); + } + } + }); + + it('has no dangling in-page links', () => { + const anchors = new Set( + [...markdown.matchAll(/^#{1,6} (.+)$/gm)].map((m) => headingAnchor(m[1] as string)), + ); + const targets = [...markdown.matchAll(/\]\(#([^)]+)\)/g)].map((m) => m[1] as string); + expect(targets.length).toBeGreaterThan(0); + for (const target of targets) expect(anchors, `#${target}`).toContain(target); + }); + + it('points at the base URL and the sibling formats', () => { + expect(markdown).toContain(`\`${BASE_URL}\``); + for (const url of Object.values(LINKS)) expect(markdown).toContain(url); + }); + + // The trap this guards: the spec's `default: true` is the console's pre-fill. + // The server executes a write that omits the header, so an agent must never + // read "default true" here. + it('states the dry-run header defaults to a REAL write', () => { + const row = markdown.split('\n').find((line) => line.startsWith('| `X-LoreKit-Dry-Run`')); + expect(row).toBeDefined(); + expect(row).toContain('Default `false`'); + expect(row).not.toContain('Default `true`'); + expect(markdown).toContain('Writes are real by default'); + }); + + it('writes shared headers once instead of in every operation table', () => { + const rows = markdown.split('\n').filter((line) => line.startsWith('| `X-LoreKit-Client`')); + expect(rows).toHaveLength(1); + expect(section(markdown, 'POST /memories')).toContain('`X-LoreKit-Dry-Run`'); + expect(section(markdown, 'GET /memories')).not.toContain('`X-LoreKit-Dry-Run`'); + }); + + it('links repeated shapes to one Schemas entry rather than inlining them', () => { + expect(markdown.match(/^### Error$/gm)).toHaveLength(1); + expect(markdown.match(/^### Memory$/gm)).toHaveLength(1); + expect(section(markdown, 'GET /memories')).toContain('[MemoryPage](#memorypage)'); + // Smaller than even the minified JSON, despite adding prose and examples. + expect(markdown.length).toBeLessThan(JSON.stringify(spec).length); + }); + + it('sends a bearer token in every example except the public routes', () => { + expect(section(markdown, 'POST /memories')).toContain('-H "Authorization: Bearer $LOREKIT_TOKEN"'); + expect(section(markdown, 'GET /blog/likes')).not.toContain('Authorization'); + expect(section(markdown, 'GET /blog/likes')).toContain('None — public endpoint.'); + }); + + it('builds a minimal body from the required fields', () => { + expect(section(markdown, 'POST /memories')).toContain(`-d '{"scope":"","key":"","value":""}'`); + }); + + it('is deterministic', () => { + expect(renderOpenApiMarkdown(spec, LINKS)).toBe(markdown); + }); +}); + +describe('renderOpenApiMarkdown — rendering rules', () => { + const doc: OpenApiDocument = { + info: { title: 'Tiny API', version: '2.0.0', description: 'A tiny API.\n\nMore text.' }, + servers: [{ url: 'https://tiny.test' }], + components: { + schemas: { + Thing: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + paths: { + '/things/{id}': { + put: { + summary: 'Replace a thing', + tags: ['Things'], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + { name: 'mode', in: 'query', required: true, schema: { type: 'string', enum: ['fast', 'safe'] } }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['note', 'meta'], + properties: { + note: { type: 'string', example: "it's here" }, + count: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] }, + meta: { + type: 'object', + required: ['owner'], + properties: { owner: { type: 'string', maxLength: 64 } }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Replaced', + content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } }, + }, + '204': { description: 'Nothing to do' }, + }, + }, + }, + }, + }; + const markdown = renderOpenApiMarkdown(doc, LINKS); + + it('labels a nullable union and keeps the bound of its non-null branch', () => { + expect(markdown).toContain('| `count` | no | integer or null | ≥ 0. |'); + }); + + it('flattens a nested object into dotted rows', () => { + expect(markdown).toContain('| `meta.owner` | yes | string | Length ≤ 64. |'); + }); + + it('links an inline copy of a named component', () => { + expect(markdown).toContain('`200` — Replaced → [Thing](#thing)'); + expect(markdown).toContain('### Thing'); + }); + + it('describes a bodiless response', () => { + expect(markdown).toContain('`204` — Nothing to do. No body.'); + }); + + it('puts required query parameters in the example URL and shell-quotes the body', () => { + expect(markdown).toContain('curl -X PUT "https://tiny.test/things/{id}?mode="'); + expect(markdown).toContain(`-d '{"note":"it'\\''s here","meta":{"owner":""}}'`); + }); + + it('uses the first line of the API description as the summary', () => { + expect(markdown).toContain('> A tiny API. This is the Markdown edition'); + expect(markdown).not.toContain('More text.'); + }); +}); + +describe('headingAnchor', () => { + it.each([ + ['GET /memories/{id}', 'get-memoriesid'], + ['POST /memories/purge-expired', 'post-memoriespurge-expired'], + ['Common headers', 'common-headers'], + ['MemoryPage', 'memorypage'], + ])('%s → #%s', (heading, anchor) => { + expect(headingAnchor(heading)).toBe(anchor); + }); +}); diff --git a/packages/schemas/src/openapi/markdown.ts b/packages/schemas/src/openapi/markdown.ts new file mode 100644 index 000000000..4f68d8353 --- /dev/null +++ b/packages/schemas/src/openapi/markdown.ts @@ -0,0 +1,649 @@ +/** + * Render the REST API's OpenAPI document as ONE Markdown file for AI agents. + * + * `/api-docs` is a Scalar page: an empty `
` that a CDN script + * draws in the browser. An agent that fetches it — `curl`, a web-fetch tool, + * an MCP client reading docs into context — gets a title and nothing else, and + * the raw OpenAPI JSON beside it is ~170 KB minified because zod-to-openapi + * inlines every schema (the error body alone is repeated over a hundred times). This + * renderer is the readable middle: every operation with its parameters, body, + * responses and a copy-paste `curl`, in one pass, with shared shapes written + * once and linked. + * + * It derives everything from the same document Scalar renders, so the two + * cannot disagree. The only editorial text is the short "Before you call" + * preamble, which states facts the document holds but buries — and it reads + * those facts (the auth scheme, the shared headers) out of the document too. + * + * Pure and dependency-free: the web app owns the I/O (`/api-docs.md`), and this + * file is unit tested against the real `generateSpec()` output. + */ + +/** The subset of JSON Schema (OpenAPI 3.1 dialect) the generated document uses. */ +export interface JsonSchema { + readonly $ref?: string; + readonly type?: string | readonly string[]; + readonly enum?: readonly unknown[]; + readonly const?: unknown; + readonly format?: string; + readonly default?: unknown; + readonly example?: unknown; + readonly description?: string; + readonly items?: JsonSchema; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean | JsonSchema; + readonly anyOf?: readonly JsonSchema[]; + readonly oneOf?: readonly JsonSchema[]; + readonly allOf?: readonly JsonSchema[]; + readonly minimum?: number; + readonly maximum?: number; + readonly exclusiveMinimum?: number; + readonly exclusiveMaximum?: number; + readonly minLength?: number; + readonly maxLength?: number; + readonly minItems?: number; + readonly maxItems?: number; + readonly pattern?: string; +} + +export interface OpenApiParameter { + readonly name: string; + readonly in: 'path' | 'query' | 'header' | 'cookie' | string; + readonly required?: boolean; + readonly description?: string; + readonly schema?: JsonSchema; + /** + * What the SERVER does when the parameter is absent, when that differs from + * `schema.default`. `X-LoreKit-Dry-Run` needs it: its `default: true` is what + * the interactive console pre-fills, while a request without the header + * executes for real. An agent told "default true" would skip the header and + * make a real write it believed was a rehearsal. + */ + readonly 'x-server-default'?: unknown; +} + +interface MediaType { + readonly schema?: JsonSchema; +} + +export interface OpenApiOperation { + readonly summary?: string; + readonly description?: string; + readonly tags?: readonly string[]; + readonly security?: ReadonlyArray>>; + readonly parameters?: readonly OpenApiParameter[]; + readonly requestBody?: { readonly content?: Readonly> }; + readonly responses?: Readonly< + Record> }> + >; +} + +export interface OpenApiDocument { + readonly info: { readonly title: string; readonly version?: string; readonly description?: string }; + readonly servers?: ReadonlyArray<{ readonly url: string }>; + readonly paths: Readonly>>>; + readonly components?: { + readonly schemas?: Readonly>; + readonly securitySchemes?: Readonly>; + }; +} + +/** Absolute URLs of the sibling formats, so the file can point at them. */ +export interface MarkdownLinks { + /** The interactive (browser) reference, e.g. `https://lorekit.io/api-docs`. */ + readonly htmlUrl: string; + /** The OpenAPI JSON, e.g. `https://lorekit.io/api-docs/spec`. */ + readonly specUrl: string; + /** The product-wide agent guide, e.g. `https://lorekit.io/llms.txt`. */ + readonly llmsTxtUrl: string; +} + +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const; +const JSON_MEDIA = 'application/json'; + +/** Deepest nesting a field table flattens into dotted rows before it says `object`. */ +const MAX_FLATTEN_DEPTH = 4; + +// ── Small text helpers ────────────────────────────────────────────────────── + +/** One table cell: no line breaks, and a literal `|` cannot end the cell. */ +function cell(text: string): string { + return text.replace(/\s*\n+\s*/g, ' ').replace(/\|/g, '\\|').trim(); +} + +function code(value: unknown): string { + return `\`${typeof value === 'string' ? value : JSON.stringify(value)}\``; +} + +/** + * GitHub-style heading anchor: lowercase, punctuation dropped, spaces to `-`. + * Matches what GitHub and most Markdown renderers generate, so the index links + * resolve wherever the file is rendered. + */ +export function headingAnchor(heading: string): string { + return heading + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .trim() + .replace(/\s/g, '-'); +} + +/** Key-order-independent JSON, so two inlined copies of one shape compare equal. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function operationHeading(method: string, path: string): string { + return `${method.toUpperCase()} ${path}`; +} + +// ── Schema description ────────────────────────────────────────────────────── + +/** + * Per-render state: which named component each inlined shape is, and which of + * them the output has linked to (only those get a "Schemas" entry). + */ +class SchemaContext { + private readonly nameByShape = new Map(); + readonly linked = new Set(); + + constructor(readonly components: Readonly>) { + for (const [name, schema] of Object.entries(components)) { + const shape = canonicalJson(schema); + // First registration wins, so a later alias never renames a shape. + if (!this.nameByShape.has(shape)) this.nameByShape.set(shape, name); + } + } + + /** The component a schema IS — by `$ref`, or by being an identical inline copy. */ + componentName(schema: JsonSchema): string | undefined { + if (schema.$ref) return schema.$ref.split('/').pop(); + // Only objects are worth naming: a bare `string` matching some + // single-field component would be a confusing link, not a helpful one. + if (!schema.properties) return undefined; + return this.nameByShape.get(canonicalJson(schema)); + } + + link(name: string): string { + this.linked.add(name); + return `[${name}](#${headingAnchor(name)})`; + } + + resolve(schema: JsonSchema): JsonSchema { + if (!schema.$ref) return schema; + const name = schema.$ref.split('/').pop() ?? ''; + return this.components[name] ?? {}; + } +} + +function isNullSchema(schema: JsonSchema): boolean { + return schema.type === 'null'; +} + +/** The variants of a union, nulls removed, plus whether `null` was one of them. */ +function unionVariants(schema: JsonSchema): { variants: readonly JsonSchema[]; nullable: boolean } | null { + const union = schema.anyOf ?? schema.oneOf; + if (!union) return null; + return { variants: union.filter((v) => !isNullSchema(v)), nullable: union.some(isNullSchema) }; +} + +/** A short type label: `string`, `integer or null`, `array of string`, a component link. */ +function typeLabel(schema: JsonSchema, ctx: SchemaContext): string { + const component = ctx.componentName(schema); + if (component) return ctx.link(component); + + const union = unionVariants(schema); + if (union) { + const label = union.variants.map((v) => typeLabel(v, ctx)).join(' or '); + return union.nullable ? `${label} or null` : label; + } + + if (schema.allOf?.length === 1) return typeLabel(schema.allOf[0] as JsonSchema, ctx); + + const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []; + const nonNull = types.filter((t) => t !== 'null'); + const nullable = nonNull.length < types.length; + let base = nonNull.join(' or ') || (schema.properties ? 'object' : schema.enum ? 'string' : 'any'); + + if (base === 'array' && schema.items) base = `array of ${typeLabel(schema.items, ctx)}`; + if (base === 'object' && !schema.properties && typeof schema.additionalProperties === 'object') { + const valueType = typeLabel(schema.additionalProperties, ctx); + base = valueType === 'any' ? 'object' : `object (map of ${valueType})`; + } + return nullable ? `${base} or null` : base; +} + +/** `Range 1–100.`, `≥ 0.`, `Length ≤ 512.` — `label` is omitted for an open-ended number range. */ +function rangeNote(min: number | undefined, max: number | undefined, label: string): string | null { + const prefix = label ? `${label} ` : ''; + if (min !== undefined && max !== undefined) return `${label || 'Range'} ${min}–${max}.`; + if (min !== undefined) return `${prefix}≥ ${min}.`; + if (max !== undefined) return `${prefix}≤ ${max}.`; + return null; +} + +/** Constraint sentences for one schema: allowed values, bounds, format, default. */ +function constraintNotes(schema: JsonSchema, serverDefault: { readonly value: unknown } | null): string[] { + const notes: string[] = []; + const union = unionVariants(schema); + const scalar = union && union.variants.length === 1 ? (union.variants[0] as JsonSchema) : schema; + + if (scalar.enum) notes.push(`One of ${scalar.enum.map(code).join(', ')}.`); + if (scalar.const !== undefined) notes.push(`Always ${code(scalar.const)}.`); + if (scalar.format) notes.push(`Format: ${scalar.format}.`); + + const numberRange = rangeNote(scalar.minimum, scalar.maximum, ''); + if (numberRange) notes.push(numberRange); + if (scalar.exclusiveMinimum !== undefined) notes.push(`> ${scalar.exclusiveMinimum}.`); + if (scalar.exclusiveMaximum !== undefined) notes.push(`< ${scalar.exclusiveMaximum}.`); + + // `minLength: 1` alone is zod's `.min(1)` — "must not be empty" says it plainly. + const nonEmptyOnly = scalar.minLength === 1 && scalar.maxLength === undefined; + const lengthRange = nonEmptyOnly ? 'Non-empty.' : rangeNote(scalar.minLength, scalar.maxLength, 'Length'); + if (lengthRange) notes.push(lengthRange); + if (scalar.pattern) notes.push(`Pattern: ${code(scalar.pattern)}.`); + + if (scalar.minItems !== undefined || scalar.maxItems !== undefined) { + const items = rangeNote(scalar.minItems, scalar.maxItems, 'Items'); + if (items) notes.push(items); + } + if (scalar.items) { + const itemNotes = constraintNotes(scalar.items, null).filter((n) => !n.startsWith('Default')); + if (itemNotes.length) notes.push(`Each item: ${itemNotes.join(' ')}`); + } + + if (serverDefault) { + notes.push(`Default ${code(serverDefault.value)}.`); + } else if (schema.default !== undefined && !(Array.isArray(schema.default) && schema.default.length === 0)) { + notes.push(`Default ${code(schema.default)}.`); + } + return notes; +} + +function notesCell(description: string | undefined, notes: readonly string[]): string { + const prose = description?.trim() ?? ''; + // A description that already says "One of `a`, `b`" needs no second copy. + const fresh = notes.filter((note) => !prose.includes(note.replace(/\.$/, ''))); + return cell([prose, ...fresh].filter(Boolean).join(' ')) || '—'; +} + +interface FieldRow { + readonly name: string; + readonly required: boolean; + readonly type: string; + readonly notes: string; +} + +/** + * Flatten an object schema into table rows. A nested object becomes dotted + * rows (`org.slug`) and an array of objects `entries[].key`, unless the nested + * shape is a named component — then it is a link, written out once under + * "Schemas" instead of on every response that carries it. + */ +function fieldRows(schema: JsonSchema, ctx: SchemaContext, prefix = '', depth = 0): FieldRow[] { + const resolved = ctx.resolve(schema); + const required = new Set(resolved.required ?? []); + const rows: FieldRow[] = []; + + for (const [name, raw] of Object.entries(resolved.properties ?? {})) { + const field = ctx.resolve(raw); + const path = `${prefix}${name}`; + rows.push({ + name: path, + required: required.has(name), + type: typeLabel(raw, ctx), + notes: notesCell(field.description, constraintNotes(field, null)), + }); + + if (depth + 1 >= MAX_FLATTEN_DEPTH || ctx.componentName(raw)) continue; + const nested = nestedObject(field); + if (nested && !ctx.componentName(nested.schema)) { + rows.push(...fieldRows(nested.schema, ctx, `${path}${nested.suffix}.`, depth + 1)); + } + } + return rows; +} + +/** The object a field contains, directly or as the element of an array. */ +function nestedObject(field: JsonSchema): { schema: JsonSchema; suffix: string } | null { + const union = unionVariants(field); + const single = union && union.variants.length === 1 ? (union.variants[0] as JsonSchema) : field; + if (single.properties) return { schema: single, suffix: '' }; + if (single.items?.properties) return { schema: single.items, suffix: '[]' }; + return null; +} + +function fieldTable(rows: readonly FieldRow[], nameHeader: string): string { + if (rows.length === 0) return '_No fields._'; + return [ + `| ${nameHeader} | Required | Type | Notes |`, + '|---|---|---|---|', + ...rows.map((r) => `| \`${r.name}\` | ${r.required ? 'yes' : 'no'} | ${cell(r.type)} | ${r.notes} |`), + ].join('\n'); +} + +// ── Examples ──────────────────────────────────────────────────────────────── + +/** + * A minimal example value: required fields only, real enum/default values + * where the schema has them, `` placeholders where it does not. Minimal + * on purpose — an agent copying it sends what the call needs, not a guess at + * every optional filter. + */ +function exampleValue(schema: JsonSchema, ctx: SchemaContext, name: string, depth = 0): unknown { + const s = ctx.resolve(schema); + if (s.example !== undefined) return s.example; + if (s.const !== undefined) return s.const; + if (s.enum?.length) return s.enum[0]; + if (s.default !== undefined && !(Array.isArray(s.default) && s.default.length === 0)) return s.default; + + const union = unionVariants(s); + if (union?.variants[0]) return exampleValue(union.variants[0], ctx, name, depth); + + const type = Array.isArray(s.type) ? s.type.find((t) => t !== 'null') : s.type; + if (s.properties || type === 'object') { + if (depth >= MAX_FLATTEN_DEPTH) return {}; + const out: Record = {}; + for (const key of s.required ?? []) { + const property = s.properties?.[key]; + if (property) out[key] = exampleValue(property, ctx, key, depth + 1); + } + return out; + } + switch (type) { + case 'array': + return s.items ? [exampleValue(s.items, ctx, name, depth + 1)] : []; + case 'integer': + case 'number': + return s.minimum ?? (s.exclusiveMinimum !== undefined ? s.exclusiveMinimum + 1 : 1); + case 'boolean': + return true; + default: + return `<${name}>`; + } +} + +/** Single-quote a string for a POSIX shell. */ +function shellQuote(text: string): string { + return `'${text.replace(/'/g, `'\\''`)}'`; +} + +function curlExample(method: string, path: string, op: OpenApiOperation, baseUrl: string, ctx: SchemaContext): string { + const query = (op.parameters ?? []) + .filter((p) => p.in === 'query' && p.required) + .map((p) => `${encodeURIComponent(p.name)}=<${p.name}>`); + const url = `${baseUrl}${path}${query.length ? `?${query.join('&')}` : ''}`; + + const lines = [`curl${method === 'get' ? '' : ` -X ${method.toUpperCase()}`} "${url}"`]; + if (!isPublic(op)) lines.push(` -H "Authorization: Bearer $LOREKIT_TOKEN"`); + + const body = op.requestBody?.content?.[JSON_MEDIA]?.schema; + if (body) { + lines.push(` -H "Content-Type: application/json"`); + lines.push(` -d ${shellQuote(JSON.stringify(exampleValue(body, ctx, 'body')))}`); + } + return ['```bash', lines.join(' \\\n'), '```'].join('\n'); +} + +// ── Operations ────────────────────────────────────────────────────────────── + +/** An explicit empty `security` array is how OpenAPI marks an open endpoint. */ +function isPublic(op: OpenApiOperation): boolean { + return Array.isArray(op.security) && op.security.length === 0; +} + +interface OperationRef { + readonly method: string; + readonly path: string; + readonly op: OpenApiOperation; +} + +function listOperations(doc: OpenApiDocument): OperationRef[] { + const out: OperationRef[] = []; + for (const [path, item] of Object.entries(doc.paths)) { + for (const method of HTTP_METHODS) { + const op = item[method]; + if (op) out.push({ method, path, op }); + } + } + return out; +} + +/** Tags in order of first appearance, so the file follows the spec's own grouping. */ +function tagOrder(ops: readonly OperationRef[]): string[] { + const tags: string[] = []; + for (const { op } of ops) { + const tag = op.tags?.[0] ?? 'Other'; + if (!tags.includes(tag)) tags.push(tag); + } + return tags; +} + +/** + * Header parameters attached to more than one operation with an identical + * definition — `X-LoreKit-Client`, `X-LoreKit-Dry-Run`. Written once under + * "Common headers" rather than repeated in every operation's table. + */ +function commonHeaders(ops: readonly OperationRef[]): Map { + const byShape = new Map(); + for (const ref of ops) { + for (const param of ref.op.parameters ?? []) { + if (param.in !== 'header') continue; + const shape = canonicalJson(param); + const entry = byShape.get(shape) ?? { param, ops: [] }; + entry.ops.push(ref); + byShape.set(shape, entry); + } + } + const common = new Map(); + for (const entry of byShape.values()) { + if (entry.ops.length > 1) common.set(entry.param.name, entry); + } + return common; +} + +function serverDefaultOf(param: OpenApiParameter): { readonly value: unknown } | null { + return 'x-server-default' in param ? { value: param['x-server-default'] } : null; +} + +function parameterTable(params: readonly OpenApiParameter[], ctx: SchemaContext): string { + return [ + '| Name | In | Required | Type | Notes |', + '|---|---|---|---|---|', + ...params.map((p) => { + const schema = p.schema ?? {}; + const notes = notesCell(p.description ?? schema.description, constraintNotes(schema, serverDefaultOf(p))); + return `| \`${p.name}\` | ${p.in} | ${p.required ? 'yes' : 'no'} | ${cell(typeLabel(schema, ctx))} | ${notes} |`; + }), + ].join('\n'); +} + +/** Indent a block so it renders inside the preceding list item. */ +function indent(block: string): string { + return block.replace(/^/gm, ' '); +} + +function responseLines(op: OpenApiOperation, ctx: SchemaContext): string[] { + const groups = new Map(); + + for (const [status, response] of Object.entries(op.responses ?? {}).sort(([a], [b]) => a.localeCompare(b))) { + const schema = response.content?.[JSON_MEDIA]?.schema; + const description = response.description?.trim() ?? ''; + const component = schema ? ctx.componentName(schema) : undefined; + let summary: string; + let table: string | null = null; + + if (!schema) { + summary = `${description}. No body.`.replace(/^\. /, ''); + } else if (component) { + summary = `${description} → ${ctx.link(component)}`.trim(); + } else { + summary = `${description} → ${typeLabel(schema, ctx)}`.trim(); + if (ctx.resolve(schema).properties) table = fieldTable(fieldRows(schema, ctx), 'Field'); + } + + // Statuses that render identically (most often 400/401/403 → Error) share a line. + const key = `${summary}\u0000${table ?? ''}`; + const group = groups.get(key) ?? { statuses: [], summary, table }; + group.statuses.push(status); + groups.set(key, group); + } + + const lines: string[] = []; + for (const { statuses, summary, table } of groups.values()) { + lines.push(`- ${statuses.map((s) => `\`${s}\``).join(', ')} — ${summary}`); + if (table) lines.push('', indent(table), ''); + } + return lines; +} + +function renderOperation( + { method, path, op }: OperationRef, + ctx: SchemaContext, + baseUrl: string, + shared: ReadonlySet, +): string { + const out: string[] = [`### ${operationHeading(method, path)}`, '']; + if (op.summary) out.push(`**${op.summary.replace(/\.?$/, '.')}**`, ''); + if (op.description) out.push(op.description.trim(), ''); + + const sharedHere = (op.parameters ?? []).filter((p) => p.in === 'header' && shared.has(p.name)).map((p) => p.name); + const auth = isPublic(op) ? 'None — public endpoint.' : 'Bearer token (`Authorization: Bearer `).'; + out.push(`- **Auth:** ${auth}`); + if (sharedHere.length) { + out.push(`- **Common headers:** ${sharedHere.map((h) => `\`${h}\``).join(', ')} — see [Common headers](#common-headers).`); + } + out.push(''); + + const params = (op.parameters ?? []).filter((p) => !(p.in === 'header' && shared.has(p.name))); + if (params.length) out.push('**Parameters**', '', parameterTable(params, ctx), ''); + + const body = op.requestBody?.content?.[JSON_MEDIA]?.schema; + if (body) { + const resolved = ctx.resolve(body); + out.push('**Request body** (`application/json`)', ''); + out.push(resolved.properties ? fieldTable(fieldRows(body, ctx), 'Field') : `\`${typeLabel(body, ctx)}\``, ''); + } + + out.push('**Responses**', '', ...responseLines(op, ctx), ''); + out.push('**Example**', '', curlExample(method, path, op, baseUrl, ctx), ''); + return out.join('\n'); +} + +// ── Document ──────────────────────────────────────────────────────────────── + +function renderCommonHeaders( + common: ReturnType, + total: number, + ctx: SchemaContext, +): string { + const rows = [...common.values()].map(({ param, ops }) => { + const methods = [...new Set(ops.map((o) => o.method.toUpperCase()))]; + const sentOn = ops.length === total ? 'every operation' : `${ops.length} operations (${methods.join(', ')})`; + const notes = notesCell(param.description, constraintNotes(param.schema ?? {}, serverDefaultOf(param))); + return `| \`${param.name}\` | ${sentOn} | ${cell(typeLabel(param.schema ?? {}, ctx))} | ${notes} |`; + }); + return ['| Header | Accepted on | Type | Notes |', '|---|---|---|---|', ...rows].join('\n'); +} + +function renderIndex(ops: readonly OperationRef[], tags: readonly string[]): string { + const out: string[] = []; + for (const tag of tags) { + out.push(`### ${tag}`, '', '| Method | Path | Summary |', '|---|---|---|'); + for (const ref of ops.filter((o) => (o.op.tags?.[0] ?? 'Other') === tag)) { + const anchor = headingAnchor(operationHeading(ref.method, ref.path)); + out.push(`| ${ref.method.toUpperCase()} | [\`${ref.path}\`](#${anchor}) | ${cell(ref.op.summary ?? '')} |`); + } + out.push(''); + } + return out.join('\n'); +} + +function renderSchemas(ctx: SchemaContext): string { + const out: string[] = []; + const written = new Set(); + // Rendering one schema can link another (MemoryPage → Memory), so keep going + // until a pass adds nothing new. + for (let pending = [...ctx.linked]; pending.length; pending = [...ctx.linked].filter((n) => !written.has(n))) { + for (const name of pending) { + written.add(name); + const schema = ctx.components[name]; + if (!schema) continue; + out.push(`### ${name}`, ''); + if (schema.description) out.push(schema.description.trim(), ''); + out.push(fieldTable(fieldRows(schema, ctx), 'Field'), ''); + } + } + return out.join('\n'); +} + +/** Render the whole document. Deterministic: the same spec gives the same bytes. */ +export function renderOpenApiMarkdown(doc: OpenApiDocument, links: MarkdownLinks): string { + const ctx = new SchemaContext(doc.components?.schemas ?? {}); + const baseUrl = doc.servers?.[0]?.url ?? ''; + const ops = listOperations(doc); + const tags = tagOrder(ops); + const common = commonHeaders(ops); + const shared = new Set(common.keys()); + + const authDescription = Object.values(doc.components?.securitySchemes ?? {})[0]?.description; + const intro = (doc.info.description ?? '').split('\n')[0]?.trim(); + + // Operations first: rendering them is what decides which schemas get linked. + const sections = tags.map((tag) => { + const body = ops + .filter((o) => (o.op.tags?.[0] ?? 'Other') === tag) + .map((ref) => renderOperation(ref, ctx, baseUrl, shared)); + return [`## ${tag}`, '', ...body].join('\n'); + }); + const errorLink = ctx.components['Error'] ? ctx.link('Error') : 'a JSON error object'; + const commonTable = renderCommonHeaders(common, ops.length, ctx); + const schemas = renderSchemas(ctx); + + return [ + `# ${doc.info.title}`, + '', + `> ${intro ? `${intro} ` : ''}This is the Markdown edition of the REST reference, written for AI agents and scripts. ` + + 'It is generated from the same OpenAPI document as the interactive reference, so the two always agree.', + '', + `- **Base URL:** \`${baseUrl}\` — every path below is relative to it.`, + `- **OpenAPI 3.1 JSON:** ${links.specUrl}`, + `- **Interactive reference (browser):** ${links.htmlUrl}`, + `- **MCP tools, scopes, filtering and limits:** ${links.llmsTxtUrl}`, + `- **Version:** ${doc.info.version ?? 'unversioned'} · ${ops.length} operations`, + '', + '## Before you call', + '', + `1. **Authenticate.** Send \`Authorization: Bearer \` on every request except the ones marked public. ${authDescription ?? ''}`.trim(), + '2. **Writes are real by default.** A `POST`, `PATCH` or `DELETE` without `X-LoreKit-Dry-Run: true` changes data. ' + + 'Send that header to have the request validated and authorized without changing anything (see [Common headers](#common-headers)).', + `3. **Errors.** A non-2xx response carries ${errorLink}. \`429\` means rate-limited; wait for the \`Retry-After\` header before retrying.`, + '4. **JSON in, JSON out.** Send request bodies with `Content-Type: application/json`.', + '', + '## Common headers', + '', + commonTable, + '', + '## Endpoints', + '', + renderIndex(ops, tags), + ...sections, + '## Schemas', + '', + 'Named shapes the endpoints above link to, written out once.', + '', + schemas, + ] + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + .concat('\n'); +} diff --git a/packages/schemas/src/openapi/spec.ts b/packages/schemas/src/openapi/spec.ts index 4fb8f1766..4e752c851 100644 --- a/packages/schemas/src/openapi/spec.ts +++ b/packages/schemas/src/openapi/spec.ts @@ -698,9 +698,12 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 'Generate a token from [Settings → API keys](/settings/api-keys) and paste it into the', '**Authorize** box above — one token works on every endpoint, orgs included.', '', - '**Safe by default** — destructive calls run in *dry-run* mode: the `X-LoreKit-Dry-Run`', - 'header defaults to `true`, so create / update / delete requests are validated and', - 'authorized but make **no changes**. Clear that header on an operation to execute for real.', + '**Safe by default here** — this console pre-fills `X-LoreKit-Dry-Run: true`, so create /', + 'update / delete requests sent from it are validated and authorized but make **no changes**.', + 'Clear that header on an operation to execute for real. A request sent WITHOUT the header', + '(curl, an SDK, an agent) executes for real.', + '', + '**For agents and scripts** — the same reference as Markdown: [/api-docs.md](/api-docs.md).', ].join('\n'); const gen = new OpenApiGeneratorV31(registry.definitions); @@ -727,14 +730,22 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co // docs are safe by default; the caller clears it to execute for real. The // backend contract lives in `_shared/limits/dry-run.ts` (isDryRunHeader). const MUTATING_METHODS = new Set(['post', 'patch', 'delete', 'put']); + // + // `schema.default: true` is what the console pre-fills, NOT what the server + // does: `isDryRunHeader` treats an absent header as `false` and executes. The + // description says so in words, and `x-server-default` says so in data, so a + // reader that skips the prose (the Markdown renderer, an agent reading the + // JSON) does not mistake a real write for a rehearsal. const dryRunParam = { name: 'X-LoreKit-Dry-Run', in: 'header', required: false, description: - 'Safe-explore mode. When `true` (the default here), the request is validated and ' + - 'authorized but makes NO changes. Set it to `false` to execute for real.', + 'Safe-explore mode. `true` validates and authorizes the request but makes NO changes. ' + + 'Absent or `false` executes it for real — the server default is `false`; only this ' + + 'reference\'s try-it console pre-fills `true`.', schema: { type: 'boolean', default: true }, + 'x-server-default': false, }; // Attach the client-attribution header to EVERY operation (not just mutating // ones — it exists mainly to label reads). Optional and fail-safe: an diff --git a/supabase/functions/_shared/schemas/openapi/spec.ts b/supabase/functions/_shared/schemas/openapi/spec.ts index d3d7032d0..26a7f8acc 100644 --- a/supabase/functions/_shared/schemas/openapi/spec.ts +++ b/supabase/functions/_shared/schemas/openapi/spec.ts @@ -703,9 +703,12 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 'Generate a token from [Settings → API keys](/settings/api-keys) and paste it into the', '**Authorize** box above — one token works on every endpoint, orgs included.', '', - '**Safe by default** — destructive calls run in *dry-run* mode: the `X-LoreKit-Dry-Run`', - 'header defaults to `true`, so create / update / delete requests are validated and', - 'authorized but make **no changes**. Clear that header on an operation to execute for real.', + '**Safe by default here** — this console pre-fills `X-LoreKit-Dry-Run: true`, so create /', + 'update / delete requests sent from it are validated and authorized but make **no changes**.', + 'Clear that header on an operation to execute for real. A request sent WITHOUT the header', + '(curl, an SDK, an agent) executes for real.', + '', + '**For agents and scripts** — the same reference as Markdown: [/api-docs.md](/api-docs.md).', ].join('\n'); const gen = new OpenApiGeneratorV31(registry.definitions); @@ -732,14 +735,22 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co // docs are safe by default; the caller clears it to execute for real. The // backend contract lives in `_shared/limits/dry-run.ts` (isDryRunHeader). const MUTATING_METHODS = new Set(['post', 'patch', 'delete', 'put']); + // + // `schema.default: true` is what the console pre-fills, NOT what the server + // does: `isDryRunHeader` treats an absent header as `false` and executes. The + // description says so in words, and `x-server-default` says so in data, so a + // reader that skips the prose (the Markdown renderer, an agent reading the + // JSON) does not mistake a real write for a rehearsal. const dryRunParam = { name: 'X-LoreKit-Dry-Run', in: 'header', required: false, description: - 'Safe-explore mode. When `true` (the default here), the request is validated and ' + - 'authorized but makes NO changes. Set it to `false` to execute for real.', + 'Safe-explore mode. `true` validates and authorizes the request but makes NO changes. ' + + 'Absent or `false` executes it for real — the server default is `false`; only this ' + + 'reference\'s try-it console pre-fills `true`.', schema: { type: 'boolean', default: true }, + 'x-server-default': false, }; // Attach the client-attribution header to EVERY operation (not just mutating // ones — it exists mainly to label reads). Optional and fail-safe: an From ab40be05a6adeed14661402142e3361c189f0a88 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 27 Oct 2026 11:16:28 +0000 Subject: [PATCH 2/9] feat(web): serve the REST reference as markdown for agents Add /api-docs.md, the OpenAPI document rendered as Markdown, and make /api-docs negotiate: a request with Accept: text/markdown gets the Markdown instead of Scalar's empty JavaScript shell. The HTML page now carries alternate links, a plain-text noscript note and a Link header (alternate + RFC 8631 service-desc) pointing at both machine formats. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/web/src/app/api-docs.md/route.ts | 16 +++ packages/web/src/app/api-docs/route.ts | 36 +++++- packages/web/src/app/api-docs/spec/route.ts | 15 +-- .../web/src/lib/api-docs/agent-docs.spec.ts | 74 +++++++++++++ packages/web/src/lib/api-docs/agent-docs.ts | 104 ++++++++++++++++++ packages/web/src/lib/api-docs/rest-api.ts | 46 ++++++++ 6 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/app/api-docs.md/route.ts create mode 100644 packages/web/src/lib/api-docs/agent-docs.spec.ts create mode 100644 packages/web/src/lib/api-docs/agent-docs.ts create mode 100644 packages/web/src/lib/api-docs/rest-api.ts diff --git a/packages/web/src/app/api-docs.md/route.ts b/packages/web/src/app/api-docs.md/route.ts new file mode 100644 index 000000000..3d35a5f5b --- /dev/null +++ b/packages/web/src/app/api-docs.md/route.ts @@ -0,0 +1,16 @@ +import { apiDocsMarkdownResponse } from '@/lib/api-docs/rest-api'; + +/** + * The REST API reference as one Markdown file, for AI agents and scripts. + * + * `/api-docs` (the Scalar page) is drawn by JavaScript, so a fetch of it holds + * no content. This is the same OpenAPI document rendered server-side by + * `@lorekit/schemas/openapi/markdown` — every operation, parameter, body, + * response and a `curl` example — so the two can never disagree. The `.md` + * suffix follows the llms.txt convention of a Markdown twin at the page's URL; + * `/api-docs` also returns this body to a request that asks for + * `Accept: text/markdown`. See `src/lib/api-docs/agent-docs.ts`. + */ +export function GET(request: Request) { + return apiDocsMarkdownResponse(request.url); +} diff --git a/packages/web/src/app/api-docs/route.ts b/packages/web/src/app/api-docs/route.ts index f5d9e12b1..ddf892c39 100644 --- a/packages/web/src/app/api-docs/route.ts +++ b/packages/web/src/app/api-docs/route.ts @@ -1,5 +1,14 @@ import { ApiReference } from '@scalar/nextjs-api-reference'; +import { + API_DOCS_MARKDOWN_PATH, + API_DOCS_SPEC_PATH, + agentDocsLinkHeader, + prefersMarkdown, + withAgentHints, +} from '@/lib/api-docs/agent-docs'; +import { apiDocsMarkdownResponse } from '@/lib/api-docs/rest-api'; + import { LOREKIT_SCALAR_CSS } from './theme'; /** @@ -15,9 +24,14 @@ import { LOREKIT_SCALAR_CSS } from './theme'; * just consumes it through the same-origin `/api-docs/spec` proxy (see the * sibling route) so the fetch works on localhost, preview, and production * regardless of the Edge Function's CORS allow-origin. + * + * Scalar draws the page with JavaScript, so the HTML an agent fetches is empty. + * A request asking for `Accept: text/markdown` therefore gets the Markdown + * edition (`/api-docs.md`) instead, and the HTML carries signposts to it — see + * `src/lib/api-docs/agent-docs.ts`. */ const config = { - url: '/api-docs/spec', + url: API_DOCS_SPEC_PATH, pageTitle: 'LoreKit REST API', // Route "Send"/try-it-out through our same-origin forwarder so live testing // isn't blocked by the Edge Function's CORS allow-list (see ./proxy/route.ts). @@ -35,8 +49,24 @@ const config = { persistAuth: true, metaData: { title: 'LoreKit REST API', - description: 'REST API reference for LoreKit — shared, persistent agent memory.', + description: `REST API reference for LoreKit — shared, persistent agent memory. Markdown for agents: ${API_DOCS_MARKDOWN_PATH}`, }, }; -export const GET = ApiReference(config); +const renderScalarPage = ApiReference(config); + +// The body depends on `Accept`, so every cache between here and the caller +// must key on it — otherwise a cached Markdown body could be served to a browser. +const NEGOTIATED = { Vary: 'Accept', Link: agentDocsLinkHeader() }; + +export async function GET(request: Request) { + if (prefersMarkdown(request.headers.get('accept'))) { + return apiDocsMarkdownResponse(request.url, NEGOTIATED); + } + + const page = renderScalarPage(); + const html = withAgentHints(await page.text(), new URL(request.url).origin); + const headers = new Headers(page.headers); + for (const [name, value] of Object.entries(NEGOTIATED)) headers.set(name, value); + return new Response(html, { status: page.status, headers }); +} diff --git a/packages/web/src/app/api-docs/spec/route.ts b/packages/web/src/app/api-docs/spec/route.ts index fcaa36423..244391b45 100644 --- a/packages/web/src/app/api-docs/spec/route.ts +++ b/packages/web/src/app/api-docs/spec/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; -import { generateSpec } from '@lorekit/schemas/openapi/spec'; + +import { API_DOCS_CACHE_CONTROL, restApiSpec } from '@/lib/api-docs/rest-api'; /** * Serves the OpenAPI spec for the /api-docs Scalar page. @@ -11,15 +12,11 @@ import { generateSpec } from '@lorekit/schemas/openapi/spec'; * * `servers[0].url` is pinned to this environment's Edge Function base so Scalar's * "Send" targets the right API; those requests still go through the same-origin - * `/api-docs/proxy` forwarder (see ../proxy/route.ts) to avoid CORS. + * `/api-docs/proxy` forwarder (see ../proxy/route.ts) to avoid CORS. The same + * document, rendered as Markdown for agents, is served at `/api-docs.md`. */ -const SUPABASE_URL = - process.env['NEXT_PUBLIC_SUPABASE_URL'] ?? 'https://pqokxlhvnosogizsjztg.supabase.co'; -const BASE_URL = `${SUPABASE_URL}/functions/v1`; - export function GET() { - const spec = generateSpec(BASE_URL); - return NextResponse.json(spec, { - headers: { 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=60' }, + return NextResponse.json(restApiSpec(), { + headers: { 'Cache-Control': API_DOCS_CACHE_CONTROL }, }); } diff --git a/packages/web/src/lib/api-docs/agent-docs.spec.ts b/packages/web/src/lib/api-docs/agent-docs.spec.ts new file mode 100644 index 000000000..b607c4ec8 --- /dev/null +++ b/packages/web/src/lib/api-docs/agent-docs.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { agentDocsLinkHeader, prefersMarkdown, withAgentHints } from './agent-docs'; + +describe('prefersMarkdown', () => { + it.each([ + // Agents that ask for Markdown get it. + ['text/markdown', true], + ['text/x-markdown', true], + ['text/markdown, text/html, */*', true], + ['text/html;q=0.9, text/markdown', true], + ['text/markdown;q=0.8, text/plain', true], + // Browsers and generic clients keep the page. + [null, false], + ['', false], + ['*/*', false], + ['text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', false], + ['text/html, text/markdown;q=0.5', false], + ['text/markdown;q=0', false], + ['application/json', false], + ])('%j → %s', (accept, expected) => { + expect(prefersMarkdown(accept)).toBe(expected); + }); +}); + +describe('agentDocsLinkHeader', () => { + it('names the Markdown twin and the OpenAPI description', () => { + const header = agentDocsLinkHeader(); + expect(header).toContain('; rel="alternate"; type="text/markdown"'); + expect(header).toContain('; rel="service-desc"'); + }); +}); + +describe('withAgentHints', () => { + const shell = [ + '', + '', + ' ', + ' LoreKit REST API', + ' ', + ' ', + '
', + ' ', + '', + ].join('\n'); + const html = withAgentHints(shell, 'https://lorekit.test'); + + it('adds alternate links to the head', () => { + const head = html.slice(0, html.indexOf('')); + expect(head).toContain(' { + const noscript = /