dorfteich/apps/api/src/public-api/openapi.ts
Claude Fable 5 89ffbc0e4d #147: Eigene Identität in der API klar dokumentiert
GET /api/public/v1/me existiert bereits — OpenAPI-Summary nennt jetzt
ausdrücklich die User-ID, api-guide (en+de) ebenso. MCP war bereits
paritätisch (list_ponds + Token-Identität); kein neuer Endpoint nötig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:49:53 +02:00

437 lines
15 KiB
TypeScript

/**
* The OpenAPI 3.1 description of the public REST API (issue #104),
* maintained by hand next to the controller — the api has no swagger
* tooling, and this surface is small and versioned. A test walks the
* controller's routes and asserts each one appears here, so the document
* cannot silently drift from the implementation.
*/
const bearerSecurity = [{ pat: [] }] as const;
const pondParam = {
name: 'pondSlug',
in: 'path',
required: true,
schema: { type: 'string' },
description: 'Pond slug (the pond must have opted into the API).',
} as const;
const pageParam = {
name: 'pageSlug',
in: 'path',
required: true,
schema: { type: 'string' },
} as const;
const labelParam = {
name: 'labelId',
in: 'path',
required: true,
schema: { type: 'string' },
} as const;
const commentParam = {
name: 'commentId',
in: 'path',
required: true,
schema: { type: 'string' },
} as const;
function jsonResponse(description: string, schema: object): object {
return { description, content: { 'application/json': { schema } } };
}
function jsonBody(schema: object): object {
return { required: true, content: { 'application/json': { schema } } };
}
const ref = (name: string): object => ({ $ref: `#/components/schemas/${name}` });
export function buildOpenApiDocument(): object {
return {
openapi: '3.1.0',
info: {
title: 'Dorfteich public API',
version: '1',
description:
'Token-authenticated access to a Dorfteich instance. A personal access token ' +
'(created under Settings) acts as its user: the normal permission model applies, ' +
'narrowed by the token scope (read/write) and an optional pond restriction. ' +
'Only ponds that opted into the API are visible; everything else answers 404. ' +
'Errors carry `{ code, message, details? }`; requests are rate-limited per token.',
},
servers: [{ url: '/api/public/v1' }],
security: [...bearerSecurity],
components: {
securitySchemes: {
pat: {
type: 'http',
scheme: 'bearer',
description: 'Personal access token (`dt_pat_…`) from Settings → API tokens.',
},
},
schemas: {
Me: {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
id: { type: 'string' },
username: { type: 'string' },
displayName: { type: 'string' },
},
},
scope: { type: 'string', enum: ['read', 'write'] },
pondSlugs: { type: 'array', items: { type: 'string' } },
},
},
Pond: {
type: 'object',
properties: {
slug: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
type: { type: 'string', enum: ['personal', 'shared'] },
createdAt: { type: 'string', format: 'date-time' },
},
},
PageListItem: {
type: 'object',
properties: {
slug: { type: 'string' },
title: { type: 'string' },
parent: {
type: ['string', 'null'],
description:
'Parent page slug in the page tree; null at the root or when the parent ' +
'is not readable for this token.',
},
labels: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
Page: {
type: 'object',
properties: {
slug: { type: 'string' },
title: { type: 'string' },
pondSlug: { type: 'string' },
parent: {
type: ['string', 'null'],
description: 'Parent page slug; see PageListItem.parent.',
},
markdown: { type: 'string' },
html: { type: 'string', description: 'Server-rendered, sanitized HTML.' },
labels: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
CreatePage: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Initial content; empty allowed.' },
parent: {
type: 'string',
description: 'Parent page slug — nests the new page under it (max depth 6).',
},
},
},
UpdatePage: {
type: 'object',
minProperties: 1,
properties: {
title: { type: 'string', maxLength: 200 },
markdown: {
type: 'string',
description:
'Replaces the whole content. Applied through the collaborative document, ' +
'so open editors converge; the response may briefly lag the change.',
},
parent: {
type: ['string', 'null'],
description:
'Move the page in the tree: a page slug nests it (appended to the new ' +
'sibling group), null moves it to the top level. Moving a page into its ' +
'own subtree or past the depth limit is rejected (409).',
},
},
},
SearchResult: {
type: 'object',
properties: {
pondSlug: { type: 'string' },
pageSlug: { type: 'string' },
title: { type: 'string' },
snippet: { type: 'string', description: 'Matches wrapped in `**…**`.' },
},
},
Label: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
color: { type: 'string' },
parentId: { type: ['string', 'null'] },
},
},
LabelTreeNode: {
allOf: [
ref('Label'),
{
type: 'object',
properties: { children: { type: 'array', items: ref('LabelTreeNode') } },
},
],
},
CreateLabel: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', maxLength: 60 },
color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
parentId: { type: ['string', 'null'] },
},
},
UpdateLabel: {
type: 'object',
minProperties: 1,
description: 'Rename, recolour and/or move (parentId null = to the root).',
properties: {
name: { type: 'string', maxLength: 60 },
color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
parentId: { type: ['string', 'null'] },
},
},
Comment: {
type: 'object',
properties: {
id: { type: 'string' },
parentId: { type: ['string', 'null'] },
body: { type: 'string', description: 'Raw Markdown.' },
html: { type: 'string', description: 'Rendered, sanitized HTML.' },
author: { type: ['object', 'null'] },
resolvedAt: { type: ['string', 'null'], format: 'date-time' },
createdAt: { type: 'string', format: 'date-time' },
},
},
CreateComment: {
type: 'object',
required: ['body'],
properties: {
body: { type: 'string', description: 'Markdown.' },
parentId: {
type: ['string', 'null'],
description: 'Reply target (a thread root id); absent = new thread.',
},
},
},
PageComments: {
type: 'object',
properties: {
threads: { type: 'array', items: { type: 'object' } },
openCount: { type: 'integer' },
resolvedCount: { type: 'integer' },
},
},
Error: {
type: 'object',
properties: {
code: { type: 'string' },
message: { type: 'string' },
details: { type: 'object' },
},
},
},
},
paths: {
'/me': {
get: {
summary:
"The token's user — id, username, display name — plus scope and pond " +
'restriction. The way to find your own user id (client smoke test).',
responses: { '200': jsonResponse('Token identity', ref('Me')) },
},
},
'/ponds': {
get: {
summary: 'API-enabled ponds visible to the token.',
responses: {
'200': jsonResponse('Ponds', { type: 'array', items: ref('Pond') }),
},
},
},
'/ponds/{pondSlug}': {
get: {
summary: 'Pond metadata.',
parameters: [pondParam],
responses: { '200': jsonResponse('Pond', ref('Pond')) },
},
},
'/ponds/{pondSlug}/pages': {
get: {
summary: 'Readable pages of the pond.',
parameters: [
pondParam,
{
name: 'createdSince',
in: 'query',
required: false,
schema: { type: 'string', format: 'date-time' },
description: 'Only pages created at or after this ISO 8601 instant (issue #148).',
},
{
name: 'updatedSince',
in: 'query',
required: false,
schema: { type: 'string', format: 'date-time' },
description: 'Only pages updated at or after this ISO 8601 instant (issue #148).',
},
],
responses: {
'200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }),
},
},
post: {
summary: 'Create a page from title + Markdown (write scope).',
parameters: [pondParam],
requestBody: jsonBody(ref('CreatePage')),
responses: { '201': jsonResponse('Created page', ref('Page')) },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}': {
get: {
summary: 'One page as Markdown + rendered HTML.',
parameters: [pondParam, pageParam],
responses: { '200': jsonResponse('Page', ref('Page')) },
},
patch: {
summary: 'Update title and/or replace content (write scope).',
parameters: [pondParam, pageParam],
requestBody: jsonBody(ref('UpdatePage')),
responses: { '200': jsonResponse('Updated page', ref('Page')) },
},
delete: {
summary: 'Move the page to the trash (write scope).',
parameters: [pondParam, pageParam],
responses: { '204': { description: 'Trashed' } },
},
},
'/search': {
get: {
summary: 'Permission-filtered full-text search across exposed ponds.',
parameters: [
{ name: 'q', in: 'query', required: true, schema: { type: 'string' } },
{
name: 'pond',
in: 'query',
schema: { type: 'string' },
description: 'Restrict to one pond (slug).',
},
{
name: 'label',
in: 'query',
schema: { type: 'string' },
description: 'Restrict to pages carrying this label id.',
},
],
responses: {
'200': jsonResponse('Hits', { type: 'array', items: ref('SearchResult') }),
},
},
},
'/ponds/{pondSlug}/export/markdown': {
get: {
summary: 'ZIP of the readable pages as Markdown files (plus media).',
parameters: [pondParam],
responses: {
'200': {
description: 'ZIP stream',
content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } },
},
},
},
},
'/ponds/{pondSlug}/labels': {
get: {
summary: 'The pond label tree.',
parameters: [pondParam],
responses: {
'200': jsonResponse('Labels', { type: 'array', items: ref('LabelTreeNode') }),
},
},
post: {
summary: 'Create a label (write scope, Pond Admin).',
parameters: [pondParam],
requestBody: jsonBody(ref('CreateLabel')),
responses: { '201': jsonResponse('Created label', ref('Label')) },
},
},
'/ponds/{pondSlug}/labels/{labelId}': {
patch: {
summary: 'Rename, recolour and/or move a label (write scope, Pond Admin).',
parameters: [pondParam, labelParam],
requestBody: jsonBody(ref('UpdateLabel')),
responses: { '200': jsonResponse('Updated label', ref('Label')) },
},
delete: {
summary: 'Delete an unused label (write scope, Pond Admin).',
parameters: [pondParam, labelParam],
responses: { '204': { description: 'Deleted' } },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}/labels/{labelId}': {
put: {
summary: 'Assign a label to the page (write scope).',
parameters: [pondParam, pageParam, labelParam],
responses: {
'200': jsonResponse("The page's labels", { type: 'array', items: ref('Label') }),
},
},
delete: {
summary: 'Unassign a label from the page (write scope, idempotent).',
parameters: [pondParam, pageParam, labelParam],
responses: { '204': { description: 'Unassigned' } },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}/comments': {
get: {
summary: 'Comment threads of the page.',
parameters: [
pondParam,
pageParam,
{
name: 'filter',
in: 'query',
schema: { type: 'string', enum: ['all', 'open', 'resolved'] },
},
],
responses: { '200': jsonResponse('Threads', ref('PageComments')) },
},
post: {
summary: "Comment on the page (write scope; the pond's comment policy applies).",
parameters: [pondParam, pageParam],
requestBody: jsonBody(ref('CreateComment')),
responses: { '201': jsonResponse('Created comment', ref('Comment')) },
},
},
'/ponds/{pondSlug}/pages/{pageSlug}/comments/{commentId}/resolve': {
post: {
summary: 'Resolve a comment thread (write scope).',
parameters: [pondParam, pageParam, commentParam],
responses: { '201': jsonResponse('Resolved comment', ref('Comment')) },
},
delete: {
summary: 'Reopen a resolved thread (write scope).',
parameters: [pondParam, pageParam, commentParam],
responses: { '200': jsonResponse('Reopened comment', ref('Comment')) },
},
},
},
};
}