Skip to content

05Project

Custom plugin example

Two small plugins applied to a Swagger 2.0 document (swagger.json):

  • plugins/sdk-names.ts names operations after an x-sdk-name vendor extension (Users_List → listUsers) with the operationName hook. Teams_ListMembers has no extension and keeps the default name, teamsListMembers.
  • plugins/operation-catalog.ts adds a @see link to each operation’s documentation with the jsdoc hook and emits catalog.ts, a typed constant listing every operation, with the generate hook.
Terminal window
pnpm example:custom-plugin # from the repository root: build swaggerz, generate, print the catalog

Or, inside this directory once swaggerz is built:

Terminal window
pnpm generate # src/generated/api, including catalog.ts
pnpm start # prints the catalog (src/catalog-report.ts, run by Node.js directly)
pnpm check # type-checks src/, the plugins and the config

Plugin files are owned by the generator like built-in files: they are recorded in the manifest and removed when a later run no longer produces them. See docs/plugins.md for every hook.

Config

swaggerz.config.ts
import { defineConfig } from 'swaggerz';
import { operationCatalog } from './plugins/operation-catalog.ts';
import { sdkNames } from './plugins/sdk-names.ts';
export default defineConfig({
spec: { version: '2.0', input: './swagger.json' },
output: { directory: './src/generated/api' },
plugins: [sdkNames(), operationCatalog({ docsBaseUrl: 'https://docs.example.com/directory' })],
});

Sources

src/catalog-report.ts
// Prints the typed operation catalog that the operation-catalog plugin emits. Node.js runs this
// file directly (type stripping), so it imports the generated catalog with its `.ts` extension.
import { operations } from './generated/api/catalog.ts';
const width = Math.max(...operations.map((operation) => operation.method.length));
for (const operation of operations) {
console.log(`${operation.method.padEnd(width)} ${operation.path} → ${operation.name}()`);
console.log(`${' '.repeat(width)} ${operation.summary}`);
console.log(`${' '.repeat(width)} ${operation.docs}`);
}
src/index.ts
import { client, getUser, listUsers, teamsListMembers, type User } from './generated/api/index.js';
import { operations, type OperationName } from './generated/api/catalog.js';
client.setConfig({ baseUrl: process.env['DIRECTORY_API_URL'] ?? 'https://api.example.com/v2' });
// Named by the sdk-names plugin from `x-sdk-name`; `Teams_ListMembers` has none and keeps the
// default name.
export async function adminNames(): Promise<string[]> {
const admins: User[] = await listUsers({ role: 'admin', top: 10 });
return admins.map((user) => user.name);
}
export async function teamEmails(teamId: string): Promise<string[]> {
const members = await teamsListMembers({ teamId });
return members.flatMap((member) => (member.email == null ? [] : [member.email]));
}
export const fetchUser = (userId: string) => getUser({ userId });
// The catalog emitted by the operation-catalog plugin is fully typed.
export const readOperations: OperationName[] = operations
.filter((operation) => operation.method === 'GET')
.map((operation) => operation.name);
export function describe(name: OperationName): string {
const operation = operations.find((candidate) => candidate.name === name);
return operation === undefined
? name
: `${operation.method} ${operation.path} — ${operation.summary} (${operation.docs})`;
}
plugins/operation-catalog.ts
import { definePlugin, type GeneratedFile } from 'swaggerz';
export interface OperationCatalogOptions {
/** Base URL of the API reference; each operation links to `<base>#<operationId>`. */
readonly docsBaseUrl: string;
}
/**
* Adds a `@see` link to the reference documentation of every operation and emits `catalog.ts`
* (or `catalog.js`), a typed list of all operations for building menus, permissions or logs.
*/
export function operationCatalog(options: OperationCatalogOptions) {
const docsUrl = (operationId: string | undefined, fallback: string) =>
`${options.docsBaseUrl}#${encodeURIComponent(operationId ?? fallback)}`;
return definePlugin({
name: 'operation-catalog',
jsdoc(block, target) {
if (target.kind === 'model') return;
const { operation } = target;
return {
...block,
tags: [...block.tags, { tag: 'see', text: docsUrl(operation.operationId, operation.key) }],
};
},
generate(ctx): GeneratedFile[] {
const entries = ctx.project.operations.map((symbol) => ({
name: symbol.name,
method: symbol.operation.method.toUpperCase(),
path: symbol.operation.path,
summary: symbol.operation.summary ?? '',
tags: symbol.operation.tags,
docs: docsUrl(symbol.operation.operationId, symbol.key),
}));
// JSON.stringify produces valid, safely escaped JavaScript literals.
const list = JSON.stringify(entries, null, 2);
const typescript = ctx.config.output.language === 'typescript';
const content = typescript
? `// @generated by the operation-catalog plugin.\n\nexport const operations = ${list} as const;\n\nexport type OperationName = (typeof operations)[number]['name'];\n`
: `// @generated by the operation-catalog plugin.\n\n/** @type {const} */\nexport const operations = ${list};\n`;
return [{ path: typescript ? 'catalog.ts' : 'catalog.js', content, origin: '' }];
},
});
}
plugins/sdk-names.ts
import { definePlugin } from 'swaggerz';
/**
* Names operations after an `x-sdk-name` vendor extension when the specification provides one,
* e.g. `Users_List` → `listUsers`. Other operations keep swaggerz's default name.
*/
export function sdkNames(extension = 'x-sdk-name') {
return definePlugin({
name: 'sdk-names',
operationName(operation) {
const name = operation.extensions[extension];
return typeof name === 'string' && /^[A-Za-z_$][\w$]*$/.test(name) ? name : undefined;
},
});
}

Generated files

17 files for 4 operations and 3 models, generated with this config when the site was built:

  • catalog.ts, client.ts, index.ts
  • models/3 files
  • runtime/7 files
  • teams/1 files
  • users/3 files

The project on GitHub