Skip to content

Commit cbf7128

Browse files
JohnMcLearclaude
andauthored
feat(api): clean up published OpenAPI spec for downstream codegens (#7714)
* feat(api): clean up published OpenAPI spec for downstream codegens The /api/openapi.json doc had four issues that broke generated tooling (printingpress.dev, openapi-generator, Postman): empty top-level tags array, every operation duplicated as GET+POST, 14 operations missing from the resources map (drift since API 1.2.8), and empty summaries on several tracked ops. This PR splits the runtime spec from the published spec via a {public} flag on generateDefinitionForVersion: the runtime definition fed to openapi-backend keeps both verbs (existing third-party clients that call GET /api/x.x.x/foo?apikey=... continue to work), while the spec served at /api/openapi.json, /rest/openapi.json, and per-version paths advertises only POST. Other changes: - top-level tags array declares pad/author/session/group/chat/server - per-op tags override added to SwaggerUIResource type so chat ops (still nested under pad for routing) and checkToken can be tagged without changing existing REST URLs - 14 missing ops (getAttributePool, getRevisionChangeset, copyPad, movePad, getPadID, getSavedRevisionsCount, listSavedRevisions, saveRevision, restoreRevision, appendText, copyPadWithoutHistory, compactPad, anonymizeAuthor, getStats) backfilled with summaries - empty summaries on listSessionsOfGroup, listAllGroups, createDiffHTML, createPad filled in - new backend tests assert the public spec shape (tags, summaries, POST-only) and that runtime routing still resolves both verbs Driven by integrating Etherpad with printingpress.dev: pointing the generator at the previous spec produced a 96-command CLI with no resource grouping and many empty descriptions. Design notes in docs/superpowers/specs/2026-05-10-openapi-cleanup-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): keep checkToken at /rest/<ver>/pad/checkToken (Qodo #2) Moving checkToken to a new `server` resource broke REST-style backward compat: existing callers of /rest/<ver>/pad/checkToken would have hit `code: 3` (no such function). The whole point of per-op tag overrides is to preserve REST URLs while still grouping correctly in OpenAPI tags — checkToken should follow the same pattern as the chat ops. Keep checkToken in `resources.pad`, give it `tags: ['server']`, and add a regression test asserting /rest/<ver>/pad/checkToken still resolves. The `server` resource still exists for `getStats` (genuinely new server-level op with no prior REST URL). Updates the design doc accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent df0ecec commit cbf7128

4 files changed

Lines changed: 369 additions & 25 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# OpenAPI cleanup for downstream tooling — Design
2+
3+
**Date:** 2026-05-10
4+
**Owner:** John McLear
5+
**Scope:** `src/node/hooks/express/openapi.ts` + type tweak + tests
6+
**Driven by:** integrating Etherpad with [printingpress.dev](https://printingpress.dev). Generating a Go CLI / Claude Code skill from `/api/openapi.json` revealed structural problems in the served spec that hurt every downstream consumer (printing-press, Postman, Swagger UI, openapi-generator, etc.). This PR fixes Etherpad's spec; generating and publishing a CLI is a follow-up that depends on this landing.
7+
8+
## Problems in the current spec
9+
10+
A live capture of `/api/openapi.json` (Etherpad 1.3.0, 48 paths) showed:
11+
12+
1. **Top-level `tags` array is empty/null.** Per-operation `tags: ["pad"]`, `["group"]`, etc. are populated for ops in the `resources` map, but consumers that group by tag (printing-press, Swagger UI sidebar, openapi-generator's resource modules) need the top-level array to discover and order them.
13+
14+
2. **Every operation duplicated as GET and POST.** Lines 562–573 of `openapi.ts` deliberately emit `paths[path] = { get: {...UsingGET}, post: {...UsingPOST} }`. The original comment ("It may be confusing that every operation can be called with both GET and POST") acknowledges this. A 48-path API generates a 96-operation CLI with `check-token using-get` + `check-token using-post`, etc.
15+
16+
3. **14 operations missing from the `resources` map.** As new API versions were added (1.2.8 → 1.3.1), `APIHandler.ts` got new functions but the `resources` map in `openapi.ts` was never updated. Affected ops have no `tags`, no `summary`, no `description`:
17+
- `getAttributePool`, `getRevisionChangeset`, `copyPad`, `movePad`, `getPadID`, `getSavedRevisionsCount`, `listSavedRevisions`, `saveRevision`, `restoreRevision`, `appendText`, `getStats`, `copyPadWithoutHistory`, `compactPad`, `anonymizeAuthor`
18+
19+
4. **Empty summaries on tracked ops.** `listSessionsOfGroup`, `listAllGroups`, `createDiffHTML` had `summary: ''`. `createPad` had no summary at all.
20+
21+
## Non-goals
22+
23+
- **Deprecating GET routes at runtime.** Existing third-party clients use `GET /api/1.x.x/foo?apikey=...`. Removing GET would be a breaking change for them — out of scope. This PR only changes what the spec *advertises*.
24+
- **Fixing printing-press's operationId derivation bug** (`get-html_get-htmlusing-get`). Generator-side issue.
25+
- **Admin API spec** (`/admin/openapi.json`). Different surface, separate cleanup if needed later.
26+
27+
## Design
28+
29+
### Per-op tag overrides
30+
31+
The simplest fix for Problem 3-and-friends is to allow per-operation `tags` overrides in the `resources` map. Existing chat ops (`getChatHistory`, `getChatHead`, `appendChatMessage`) and `checkToken` are nested under `pad` for routing reasons; tagging them as `chat` / `server` without restructuring the map preserves all REST URLs.
32+
33+
The operations builder destructures `tags` from each spec entry and falls back to `[resource]` when absent:
34+
35+
```ts
36+
const {operationId, responseSchema, tags: customTags, ...operation} = spec as any;
37+
// ...
38+
operations[operationId] = {
39+
operationId,
40+
...operation,
41+
responses,
42+
tags: customTags || [resource],
43+
_restPath: `/${resource}/${action}`,
44+
};
45+
```
46+
47+
The `SwaggerUIResource` type gains an optional `tags?: string[]` field.
48+
49+
### Top-level tags array
50+
51+
Added inside `generateDefinitionForVersion`'s returned `definition` object:
52+
53+
```ts
54+
tags: [
55+
{name: 'pad', description: 'Pad lifecycle, content, revisions, attributes'},
56+
{name: 'author', description: 'Authors and authorship'},
57+
{name: 'session', description: 'Group sessions'},
58+
{name: 'group', description: 'Groups (multi-tenant pads)'},
59+
{name: 'chat', description: 'In-pad chat history'},
60+
{name: 'server', description: 'Server-level operations (stats, token check)'},
61+
],
62+
```
63+
64+
### Backfill missing entries
65+
66+
14 operations added to `resources` with proper summaries. Most go under `pad`; `anonymizeAuthor` under `author`; `getStats` under a new `server` resource group.
67+
68+
| Tag | Operation | Summary |
69+
|--------|----------------------------|---------------------------------------------------------------|
70+
| pad | `getAttributePool` | returns the attribute pool of a pad |
71+
| pad | `getRevisionChangeset` | returns the changeset at a given revision of a pad |
72+
| pad | `copyPad` | copies a pad with full history and chat |
73+
| pad | `movePad` | moves a pad — copy then delete the original |
74+
| pad | `getPadID` | returns the read-write pad ID for a given read-only pad ID |
75+
| pad | `getSavedRevisionsCount` | returns the number of saved revisions of a pad |
76+
| pad | `listSavedRevisions` | returns the list of saved revisions of a pad |
77+
| pad | `saveRevision` | saves a revision of a pad |
78+
| pad | `restoreRevision` | restores a pad to a specific revision |
79+
| pad | `appendText` | appends text to a pad |
80+
| pad | `copyPadWithoutHistory` | copies a pad without history or chat |
81+
| pad | `compactPad` | compacts a pad's revision history, keeping recent ones |
82+
| author | `anonymizeAuthor` | anonymizes an author across all their edits |
83+
| server | `getStats` | returns server-wide statistics |
84+
85+
`checkToken` stays under `pad` in the resources map (preserves `/rest/<ver>/pad/checkToken`) but gains an explicit `tags: ['server']` override so it groups correctly in OpenAPI without changing its REST URL.
86+
87+
### Runtime vs published spec split
88+
89+
`generateDefinitionForVersion` gains a `{public}` flag:
90+
91+
```ts
92+
const generateDefinitionForVersion = (
93+
version: string,
94+
style: string = APIPathStyle.FLAT,
95+
{public: isPublic = false}: {public?: boolean} = {},
96+
) => { ... }
97+
```
98+
99+
When `isPublic`, paths emit only `post:`. Otherwise both `get:` and `post:` (current behavior).
100+
101+
- The `definition` passed to `new OpenAPIBackend({...})` stays as-is (no flag) → both verbs routed at runtime → backward compat preserved.
102+
- The handlers serving `/api/openapi.json`, `/rest/openapi.json`, `/api/{version}/openapi.json` call with `{public: true}` → clients see clean POST-only API.
103+
104+
operationIds in the public spec are unchanged (`${name}UsingPOST`), so any tooling already generated from the previous spec still finds its operations — strict subset, not rename.
105+
106+
## Test plan
107+
108+
Two new describe blocks in `src/tests/backend/specs/api/api.ts` (existing home for `/api/openapi.json` tests):
109+
110+
1. **public OpenAPI spec shape** — fetches `/api/openapi.json` once, asserts:
111+
- Top-level `tags` array contains `{pad, author, session, group, chat, server}`
112+
- Every operation has `tags: [...]` with ≥1 non-empty entry
113+
- Every operation has a non-empty `summary` (≥3 chars)
114+
- Every path advertises only `post:`
115+
116+
2. **runtime backward compatibility** — drives the live API:
117+
- `GET /api/{v}/checkToken?apikey=...` returns code 0
118+
- `POST /api/{v}/checkToken` returns code 0
119+
120+
These assert both halves of the design: the published spec is clean, and the runtime hasn't lost backward-compat routing.
121+
122+
## Blast radius
123+
124+
- **Runtime callers** (third-party scripts, ep_ai_mcp's HTTP fallback paths if any, dashboards, CI hooks): zero impact. Both GET and POST routes still resolve.
125+
- **Tooling regenerators** (Postman collections, Swagger UI, openapi-generator clients): strict improvement. Smaller, better-named, properly-grouped surface. operationIds stable.
126+
- **REST-style URLs** (`/rest/...`): unchanged for every existing op. No restructuring of `resources` was needed because per-op tag overrides do the work. New backfilled ops (`getAttributePool` etc.) gain a `/rest/X/pad/getAttributePool` path; their previous fallback `/rest/X/getAttributePool` is no longer the canonical REST route, but FLAT (`/api/...`) is unchanged.
127+
128+
## Out-of-band note
129+
130+
The spec already serves at three URLs (`/api/openapi.json`, `/rest/openapi.json`, `/api/{version}/openapi.json`); the cleanup applies to all three because the same builder backs them.
131+
132+
A separate admin spec exists at `/admin/openapi.json` (added in #7693/#7705) — out of scope here, worth a similar audit later.
133+
134+
## Follow-up phases (not part of this PR)
135+
136+
- **Phase B:** point printing-press at the cleaned spec, generate `etherpad` Go CLI + Claude Code skill, push to a new `ether/etherpad-cli` repo, submit to printingpress.dev community library.
137+
- **Phase C:** submit `ep_ai_mcp` as the canonical MCP entry in printingpress.dev's library — generated MCP from OpenAPI would be strictly worse (no changeset/authorship reach-through).

src/node/hooks/express/openapi.ts

Lines changed: 124 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,14 @@ const resources:SwaggerUIResource = {
8686
},
8787
listSessions: {
8888
operationId: 'listSessionsOfGroup',
89-
summary: '',
89+
summary: 'returns all sessions of a group',
9090
responseSchema: {
9191
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
9292
},
9393
},
9494
list: {
9595
operationId: 'listAllGroups',
96-
summary: '',
96+
summary: 'returns the IDs of all groups on this server',
9797
responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},
9898
},
9999
},
@@ -128,6 +128,10 @@ const resources:SwaggerUIResource = {
128128
summary: 'Returns the Author Name of the author',
129129
responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},
130130
},
131+
anonymize: {
132+
operationId: 'anonymizeAuthor',
133+
summary: 'anonymizes an author across all their edits',
134+
},
131135
},
132136

133137
// Session
@@ -158,11 +162,12 @@ const resources:SwaggerUIResource = {
158162
},
159163
createDiffHTML: {
160164
operationId: 'createDiffHTML',
161-
summary: '',
165+
summary: 'returns an HTML diff between two revisions of a pad',
162166
responseSchema: {},
163167
},
164168
create: {
165169
operationId: 'createPad',
170+
summary: 'creates a new (non-group) pad',
166171
description:
167172
'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +
168173
'you should call createGroupPad',
@@ -234,22 +239,82 @@ const resources:SwaggerUIResource = {
234239
},
235240
checkToken: {
236241
operationId: 'checkToken',
237-
summary: 'returns ok when the current api token is valid',
242+
summary: 'returns ok when the current API token is valid',
243+
tags: ['server'],
238244
},
239245
getChatHistory: {
240246
operationId: 'getChatHistory',
241247
summary: 'returns the chat history',
248+
tags: ['chat'],
242249
responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},
243250
},
244251
// We need an operation that returns a Message so it can be picked up by the codegen :(
245252
getChatHead: {
246253
operationId: 'getChatHead',
247254
summary: 'returns the chatHead (chat-message) of the pad',
255+
tags: ['chat'],
248256
responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},
249257
},
250258
appendChatMessage: {
251259
operationId: 'appendChatMessage',
252260
summary: 'appends a chat message',
261+
tags: ['chat'],
262+
},
263+
getAttributePool: {
264+
operationId: 'getAttributePool',
265+
summary: 'returns the attribute pool of a pad',
266+
},
267+
getRevisionChangeset: {
268+
operationId: 'getRevisionChangeset',
269+
summary: 'returns the changeset at a given revision of a pad',
270+
},
271+
copyPad: {
272+
operationId: 'copyPad',
273+
summary: 'copies a pad with full history and chat',
274+
},
275+
movePad: {
276+
operationId: 'movePad',
277+
summary: 'moves a pad — copy then delete the original',
278+
},
279+
getPadID: {
280+
operationId: 'getPadID',
281+
summary: 'returns the read-write pad ID for a given read-only pad ID',
282+
},
283+
getSavedRevisionsCount: {
284+
operationId: 'getSavedRevisionsCount',
285+
summary: 'returns the number of saved revisions of a pad',
286+
},
287+
listSavedRevisions: {
288+
operationId: 'listSavedRevisions',
289+
summary: 'returns the list of saved revisions of a pad',
290+
},
291+
saveRevision: {
292+
operationId: 'saveRevision',
293+
summary: 'saves a revision of a pad',
294+
},
295+
restoreRevision: {
296+
operationId: 'restoreRevision',
297+
summary: 'restores a pad to a specific revision',
298+
},
299+
appendText: {
300+
operationId: 'appendText',
301+
summary: 'appends text to a pad',
302+
},
303+
copyPadWithoutHistory: {
304+
operationId: 'copyPadWithoutHistory',
305+
summary: 'copies a pad without history or chat',
306+
},
307+
compactPad: {
308+
operationId: 'compactPad',
309+
summary: 'compacts a pad\'s revision history, keeping recent revisions only',
310+
},
311+
},
312+
313+
// Server
314+
server: {
315+
getStats: {
316+
operationId: 'getStats',
317+
summary: 'returns server-wide statistics',
253318
},
254319
},
255320
};
@@ -396,7 +461,7 @@ const defaultResponseRefs:OpenAPISuccessResponse = {
396461
const operations: OpenAPIOperations = {};
397462
for (const [resource, actions] of Object.entries(resources)) {
398463
for (const [action, spec] of Object.entries(actions)) {
399-
const {operationId,responseSchema, ...operation} = spec;
464+
const {operationId, responseSchema, tags: customTags, ...operation} = spec as any;
400465

401466
// add response objects
402467
const responses:OpenAPISuccessResponse = {...defaultResponseRefs};
@@ -409,20 +474,43 @@ for (const [resource, actions] of Object.entries(resources)) {
409474
}
410475

411476
// add final operation object to dictionary
477+
// tags default to [resource] but can be overridden per-op via spec.tags
478+
// (e.g. chat ops nested under pad use tags: ['chat']).
412479
operations[operationId] = {
413480
operationId,
414481
...operation,
415482
responses,
416-
tags: [resource],
483+
tags: customTags || [resource],
417484
_restPath: `/${resource}/${action}`,
418485
};
419486
}
420487
}
421488

422-
const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => {
423-
const definition = {
489+
/**
490+
* Generate the OpenAPI definition for a given API version + path style.
491+
*
492+
* The `public` flag controls whether the spec is the *runtime* definition (used
493+
* to dispatch requests via openapi-backend, which still routes both GET and
494+
* POST for backward compatibility with older clients) or the *published* spec
495+
* (served at /api/openapi.json etc., advertising only POST so generated tooling
496+
* — printingpress.dev, openapi-generator, Postman — sees a clean surface).
497+
*/
498+
const generateDefinitionForVersion = (
499+
version: string,
500+
style: string = APIPathStyle.FLAT,
501+
{public: isPublic = false}: {public?: boolean} = {},
502+
) => {
503+
const definition: any = {
424504
openapi: OPENAPI_VERSION,
425505
info,
506+
tags: [
507+
{name: 'pad', description: 'Pad lifecycle, content, revisions, attributes'},
508+
{name: 'author', description: 'Authors and authorship'},
509+
{name: 'session', description: 'Group sessions'},
510+
{name: 'group', description: 'Groups (multi-tenant pads)'},
511+
{name: 'chat', description: 'In-pad chat history'},
512+
{name: 'server', description: 'Server-level operations (stats, token check)'},
513+
],
426514
paths: {},
427515
components: {
428516
parameters: {},
@@ -559,18 +647,29 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
559647
delete operation._restPath;
560648

561649
// add to definition
562-
// NOTE: It may be confusing that every operation can be called with both GET and POST
563-
// @ts-ignore
564-
definition.paths[path] = {
565-
get: {
566-
...operation,
567-
operationId: `${operation.operationId}UsingGET`,
568-
},
569-
post: {
570-
...operation,
571-
operationId: `${operation.operationId}UsingPOST`,
572-
},
573-
};
650+
// The runtime spec advertises both GET and POST so existing clients (some of
651+
// which still pass apikey/params via query string) keep working. The public
652+
// spec served at /api/openapi.json advertises only POST — that is the
653+
// recommended call style and what downstream codegens should target.
654+
if (isPublic) {
655+
definition.paths[path] = {
656+
post: {
657+
...operation,
658+
operationId: `${operation.operationId}UsingPOST`,
659+
},
660+
};
661+
} else {
662+
definition.paths[path] = {
663+
get: {
664+
...operation,
665+
operationId: `${operation.operationId}UsingGET`,
666+
},
667+
post: {
668+
...operation,
669+
operationId: `${operation.operationId}UsingPOST`,
670+
},
671+
};
672+
}
574673
}
575674
return definition;
576675
};
@@ -588,11 +687,13 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
588687
const definition = generateDefinitionForVersion(version, style);
589688

590689
// serve version specific openapi definition; regenerate per request so runtime
591-
// settings (e.g. authenticationMethod) are reflected
690+
// settings (e.g. authenticationMethod) are reflected. The served spec uses
691+
// {public: true} to advertise only POST per path (the runtime backend below
692+
// still routes both GET and POST for backward compatibility).
592693
app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => {
593694
// For openapi definitions, wide CORS is probably fine
594695
res.header('Access-Control-Allow-Origin', '*');
595-
const liveDefinition = generateDefinitionForVersion(version, style);
696+
const liveDefinition = generateDefinitionForVersion(version, style, {public: true});
596697
res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]});
597698
});
598699

@@ -601,7 +702,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
601702
if (isLatestAPIVersion) {
602703
app.get(`/${style}/openapi.json`, (req:any, res:any) => {
603704
res.header('Access-Control-Allow-Origin', '*');
604-
const liveDefinition = generateDefinitionForVersion(version, style);
705+
const liveDefinition = generateDefinitionForVersion(version, style, {public: true});
605706
res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]});
606707
});
607708
}

src/node/types/SwaggerUIResource.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ export type SwaggerUIResource = {
33
[secondKey: string]: {
44
operationId: string,
55
summary?: string,
6-
description?:string
7-
responseSchema?: object
6+
description?: string,
7+
responseSchema?: object,
8+
tags?: string[],
89
}
910
}
1011
}

0 commit comments

Comments
 (0)