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>
This commit is contained in:
John McLear 2026-05-10 20:02:05 +08:00 committed by GitHub
parent df0ecec834
commit cbf71285a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 369 additions and 25 deletions

View file

@ -86,14 +86,14 @@ const resources:SwaggerUIResource = {
},
listSessions: {
operationId: 'listSessionsOfGroup',
summary: '',
summary: 'returns all sessions of a group',
responseSchema: {
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
},
},
list: {
operationId: 'listAllGroups',
summary: '',
summary: 'returns the IDs of all groups on this server',
responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},
},
},
@ -128,6 +128,10 @@ const resources:SwaggerUIResource = {
summary: 'Returns the Author Name of the author',
responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},
},
anonymize: {
operationId: 'anonymizeAuthor',
summary: 'anonymizes an author across all their edits',
},
},
// Session
@ -158,11 +162,12 @@ const resources:SwaggerUIResource = {
},
createDiffHTML: {
operationId: 'createDiffHTML',
summary: '',
summary: 'returns an HTML diff between two revisions of a pad',
responseSchema: {},
},
create: {
operationId: 'createPad',
summary: 'creates a new (non-group) pad',
description:
'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +
'you should call createGroupPad',
@ -234,22 +239,82 @@ const resources:SwaggerUIResource = {
},
checkToken: {
operationId: 'checkToken',
summary: 'returns ok when the current api token is valid',
summary: 'returns ok when the current API token is valid',
tags: ['server'],
},
getChatHistory: {
operationId: 'getChatHistory',
summary: 'returns the chat history',
tags: ['chat'],
responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},
},
// We need an operation that returns a Message so it can be picked up by the codegen :(
getChatHead: {
operationId: 'getChatHead',
summary: 'returns the chatHead (chat-message) of the pad',
tags: ['chat'],
responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},
},
appendChatMessage: {
operationId: 'appendChatMessage',
summary: 'appends a chat message',
tags: ['chat'],
},
getAttributePool: {
operationId: 'getAttributePool',
summary: 'returns the attribute pool of a pad',
},
getRevisionChangeset: {
operationId: 'getRevisionChangeset',
summary: 'returns the changeset at a given revision of a pad',
},
copyPad: {
operationId: 'copyPad',
summary: 'copies a pad with full history and chat',
},
movePad: {
operationId: 'movePad',
summary: 'moves a pad — copy then delete the original',
},
getPadID: {
operationId: 'getPadID',
summary: 'returns the read-write pad ID for a given read-only pad ID',
},
getSavedRevisionsCount: {
operationId: 'getSavedRevisionsCount',
summary: 'returns the number of saved revisions of a pad',
},
listSavedRevisions: {
operationId: 'listSavedRevisions',
summary: 'returns the list of saved revisions of a pad',
},
saveRevision: {
operationId: 'saveRevision',
summary: 'saves a revision of a pad',
},
restoreRevision: {
operationId: 'restoreRevision',
summary: 'restores a pad to a specific revision',
},
appendText: {
operationId: 'appendText',
summary: 'appends text to a pad',
},
copyPadWithoutHistory: {
operationId: 'copyPadWithoutHistory',
summary: 'copies a pad without history or chat',
},
compactPad: {
operationId: 'compactPad',
summary: 'compacts a pad\'s revision history, keeping recent revisions only',
},
},
// Server
server: {
getStats: {
operationId: 'getStats',
summary: 'returns server-wide statistics',
},
},
};
@ -396,7 +461,7 @@ const defaultResponseRefs:OpenAPISuccessResponse = {
const operations: OpenAPIOperations = {};
for (const [resource, actions] of Object.entries(resources)) {
for (const [action, spec] of Object.entries(actions)) {
const {operationId,responseSchema, ...operation} = spec;
const {operationId, responseSchema, tags: customTags, ...operation} = spec as any;
// add response objects
const responses:OpenAPISuccessResponse = {...defaultResponseRefs};
@ -409,20 +474,43 @@ for (const [resource, actions] of Object.entries(resources)) {
}
// add final operation object to dictionary
// tags default to [resource] but can be overridden per-op via spec.tags
// (e.g. chat ops nested under pad use tags: ['chat']).
operations[operationId] = {
operationId,
...operation,
responses,
tags: [resource],
tags: customTags || [resource],
_restPath: `/${resource}/${action}`,
};
}
}
const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => {
const definition = {
/**
* Generate the OpenAPI definition for a given API version + path style.
*
* The `public` flag controls whether the spec is the *runtime* definition (used
* to dispatch requests via openapi-backend, which still routes both GET and
* POST for backward compatibility with older clients) or the *published* spec
* (served at /api/openapi.json etc., advertising only POST so generated tooling
* printingpress.dev, openapi-generator, Postman sees a clean surface).
*/
const generateDefinitionForVersion = (
version: string,
style: string = APIPathStyle.FLAT,
{public: isPublic = false}: {public?: boolean} = {},
) => {
const definition: any = {
openapi: OPENAPI_VERSION,
info,
tags: [
{name: 'pad', description: 'Pad lifecycle, content, revisions, attributes'},
{name: 'author', description: 'Authors and authorship'},
{name: 'session', description: 'Group sessions'},
{name: 'group', description: 'Groups (multi-tenant pads)'},
{name: 'chat', description: 'In-pad chat history'},
{name: 'server', description: 'Server-level operations (stats, token check)'},
],
paths: {},
components: {
parameters: {},
@ -559,18 +647,29 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
delete operation._restPath;
// add to definition
// NOTE: It may be confusing that every operation can be called with both GET and POST
// @ts-ignore
definition.paths[path] = {
get: {
...operation,
operationId: `${operation.operationId}UsingGET`,
},
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
// The runtime spec advertises both GET and POST so existing clients (some of
// which still pass apikey/params via query string) keep working. The public
// spec served at /api/openapi.json advertises only POST — that is the
// recommended call style and what downstream codegens should target.
if (isPublic) {
definition.paths[path] = {
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
} else {
definition.paths[path] = {
get: {
...operation,
operationId: `${operation.operationId}UsingGET`,
},
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
}
}
return definition;
};
@ -588,11 +687,13 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
const definition = generateDefinitionForVersion(version, style);
// serve version specific openapi definition; regenerate per request so runtime
// settings (e.g. authenticationMethod) are reflected
// settings (e.g. authenticationMethod) are reflected. The served spec uses
// {public: true} to advertise only POST per path (the runtime backend below
// still routes both GET and POST for backward compatibility).
app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => {
// For openapi definitions, wide CORS is probably fine
res.header('Access-Control-Allow-Origin', '*');
const liveDefinition = generateDefinitionForVersion(version, style);
const liveDefinition = generateDefinitionForVersion(version, style, {public: true});
res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]});
});
@ -601,7 +702,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
if (isLatestAPIVersion) {
app.get(`/${style}/openapi.json`, (req:any, res:any) => {
res.header('Access-Control-Allow-Origin', '*');
const liveDefinition = generateDefinitionForVersion(version, style);
const liveDefinition = generateDefinitionForVersion(version, style, {public: true});
res.json({...liveDefinition, servers: [generateServerForApiVersion(apiRoot, req)]});
});
}