Skip to content

Commit 463f52c

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): generate webhook token files for OAS 3.1 webhooks
Reads the top-level webhooks field from OAS 3.1 specs and emits one <name>.webhook.ts per operation: InjectionToken<HttpInterceptorFn> plus typed XxxWebhookPayload / XxxWebhookResponse aliases sourced from the generated webhooks type in schema.d.ts. Webhook files are re-exported from the root index barrel and included in stale-file cleanup. The paths validator is loosened to accept specs that have webhooks but no paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bc1b2f4 commit 463f52c

5 files changed

Lines changed: 228 additions & 9 deletions

File tree

tools/openapi-resource-gen/src/generators/api-resource/endpoint-model.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ export interface DiscriminatorModel {
1212
isArrayResponse: boolean;
1313
}
1414

15+
export interface WebhookModel {
16+
/** The webhook name key as it appears in the spec, e.g. 'newPet'. */
17+
name: string;
18+
/** HTTP method of the webhook operation, e.g. 'post'. */
19+
method: string;
20+
/** SCREAMING_SNAKE token constant name, e.g. 'NEW_PET_WEBHOOK'. */
21+
tokenName: string;
22+
/** Kebab-case file name without extension, e.g. 'new-pet.webhook'. */
23+
fileName: string;
24+
deprecated: boolean;
25+
/** Content type of the request body, null if absent or non-JSON. */
26+
payloadContentType: string | null;
27+
/** 2xx response codes that carry application/json content. */
28+
responseStatuses: string[];
29+
}
30+
1531
/** Non-default serialization style for a query parameter. */
1632
export type QueryParamSerializer = 'deepObject' | 'csv' | 'pipes' | 'spaces';
1733

tools/openapi-resource-gen/src/generators/api-resource/generator.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,97 @@ describe('api-resource generator', () => {
10611061
});
10621062
});
10631063

1064+
describe('webhook generation', () => {
1065+
const WEBHOOK_SPEC = {
1066+
paths: {},
1067+
webhooks: {
1068+
newPet: {
1069+
post: {
1070+
requestBody: { content: { 'application/json': { schema: {} } } },
1071+
responses: {
1072+
'200': { content: { 'application/json': { schema: {} } } },
1073+
},
1074+
},
1075+
},
1076+
},
1077+
};
1078+
1079+
it('emits a .webhook.ts file with InjectionToken<HttpInterceptorFn>', async () => {
1080+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(WEBHOOK_SPEC as never);
1081+
await apiResourceGenerator(tree, {
1082+
specPath: 'specs/petstore.yaml',
1083+
outputDir: 'libs/wh-basic/src',
1084+
});
1085+
const content = tree.read('libs/wh-basic/src/new-pet.webhook.ts', 'utf-8')!;
1086+
expect(content).toContain('InjectionToken<HttpInterceptorFn>');
1087+
expect(content).toContain('NEW_PET_WEBHOOK');
1088+
});
1089+
1090+
it('emits XxxWebhookPayload type when requestBody has application/json', async () => {
1091+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(WEBHOOK_SPEC as never);
1092+
await apiResourceGenerator(tree, {
1093+
specPath: 'specs/petstore.yaml',
1094+
outputDir: 'libs/wh-payload/src',
1095+
});
1096+
const content = tree.read('libs/wh-payload/src/new-pet.webhook.ts', 'utf-8')!;
1097+
expect(content).toContain('NewPetWebhookPayload');
1098+
expect(content).toContain("webhooks['newPet']['post']");
1099+
expect(content).toContain("import type { webhooks } from './schema.d'");
1100+
});
1101+
1102+
it('emits XxxWebhookResponse type when 2xx response has application/json', async () => {
1103+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(WEBHOOK_SPEC as never);
1104+
await apiResourceGenerator(tree, {
1105+
specPath: 'specs/petstore.yaml',
1106+
outputDir: 'libs/wh-response/src',
1107+
});
1108+
const content = tree.read('libs/wh-response/src/new-pet.webhook.ts', 'utf-8')!;
1109+
expect(content).toContain('NewPetWebhookResponse');
1110+
expect(content).toContain("['responses']['200']");
1111+
});
1112+
1113+
it('re-exports webhook file from the root index barrel', async () => {
1114+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(WEBHOOK_SPEC as never);
1115+
await apiResourceGenerator(tree, {
1116+
specPath: 'specs/petstore.yaml',
1117+
outputDir: 'libs/wh-barrel/src',
1118+
});
1119+
const index = tree.read('libs/wh-barrel/src/index.ts', 'utf-8')!;
1120+
expect(index).toContain("export * from './new-pet.webhook'");
1121+
});
1122+
1123+
it('omits webhooks import when no payload or response schemas exist', async () => {
1124+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1125+
paths: {},
1126+
webhooks: {
1127+
ping: {
1128+
post: {
1129+
responses: { '204': {} }, // no JSON content
1130+
},
1131+
},
1132+
},
1133+
} as never);
1134+
await apiResourceGenerator(tree, {
1135+
specPath: 'specs/petstore.yaml',
1136+
outputDir: 'libs/wh-notypes/src',
1137+
});
1138+
const content = tree.read('libs/wh-notypes/src/ping.webhook.ts', 'utf-8')!;
1139+
expect(content).toContain('PING_WEBHOOK');
1140+
expect(content).not.toContain("from './schema.d'");
1141+
expect(content).not.toContain('WebhookPayload');
1142+
expect(content).not.toContain('WebhookResponse');
1143+
});
1144+
1145+
it('does not emit webhook files for specs without webhooks field', async () => {
1146+
await apiResourceGenerator(tree, {
1147+
specPath: 'specs/petstore.yaml',
1148+
outputDir: 'libs/wh-empty/src',
1149+
});
1150+
const files = tree.listChanges().map((c) => c.path);
1151+
expect(files.some((f) => f.endsWith('.webhook.ts'))).toBe(false);
1152+
});
1153+
});
1154+
10641155
describe('descriptive errors', () => {
10651156
it('error message for missing openapi field includes version guidance', () => {
10661157
// Verify the error text is descriptive before we even hit SwaggerParser.

tools/openapi-resource-gen/src/generators/api-resource/generator.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import * as path from 'path';
2222
import { pathToFileURL } from 'url';
2323
import SwaggerParser from '@apidevtools/swagger-parser';
2424
import { OpenAPIV3 } from 'openapi-types';
25-
import { buildEndpoints, parseSecuritySchemes } from './parse-spec';
26-
import { renderTokenFile, renderSecurityTokenFile } from './render-token';
25+
import { buildEndpoints, parseSecuritySchemes, parseWebhooks } from './parse-spec';
26+
import { renderTokenFile, renderSecurityTokenFile, renderWebhookTokenFile } from './render-token';
2727
import { renderMockFile } from './render-mock-file';
2828
import type { SecuritySchemeModel } from './endpoint-model';
2929

@@ -181,6 +181,7 @@ export async function apiResourceGenerator(
181181
(f) =>
182182
f.endsWith('.token.ts') ||
183183
f.endsWith('.security-token.ts') ||
184+
f.endsWith('.webhook.ts') ||
184185
f.endsWith('.mock.ts') ||
185186
f.endsWith('mocks.manifest.json') ||
186187
f.endsWith('/index.ts') ||
@@ -231,9 +232,11 @@ export async function apiResourceGenerator(
231232
`For Swagger 2.x specs, convert first with swagger2openapi.`
232233
);
233234
}
234-
if (!specObj['paths'] || typeof specObj['paths'] !== 'object') {
235+
const hasPaths = specObj['paths'] && typeof specObj['paths'] === 'object';
236+
const hasWebhooks = specObj['webhooks'] && typeof specObj['webhooks'] === 'object';
237+
if (!hasPaths && !hasWebhooks) {
235238
throw new Error(
236-
`No "paths" object found in spec. Is "${specPath}" a valid OpenAPI 3.x file?`
239+
`No "paths" or "webhooks" object found in spec. Is "${specPath}" a valid OpenAPI 3.x file?`
237240
);
238241
}
239242

@@ -288,6 +291,14 @@ export async function apiResourceGenerator(
288291
writtenFiles.add(filePath);
289292
}
290293

294+
// 5b. Parse and emit webhook token files (OAS 3.1 webhooks).
295+
const webhookModels = parseWebhooks(api, namingConvention);
296+
for (const wh of webhookModels) {
297+
const filePath = joinPathFragments(outputDir, `${wh.fileName}.ts`);
298+
tree.write(filePath, renderWebhookTokenFile(wh));
299+
writtenFiles.add(filePath);
300+
}
301+
291302
const endpoints = buildEndpoints(api, allowedTags, namingConvention);
292303

293304
// 6. Group by tag
@@ -336,6 +347,7 @@ export async function apiResourceGenerator(
336347
const rootBarrel =
337348
`export * from './api-base-url.token';\n` +
338349
securitySchemes.map((s) => `export * from './${s.fileName}';\n`).join('') +
350+
webhookModels.map((wh) => `export * from './${wh.fileName}';\n`).join('') +
339351
[...byTag.keys()].map((tag) => `export * from './${tag}';\n`).join('');
340352
const rootBarrelPath = joinPathFragments(outputDir, 'index.ts');
341353
tree.write(rootBarrelPath, rootBarrel);

tools/openapi-resource-gen/src/generators/api-resource/parse-spec.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import SwaggerParser from '@apidevtools/swagger-parser';
22
import { OpenAPIV3 } from 'openapi-types';
3-
import type { DiscriminatorModel, DiscriminatorVariant, EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam } from './endpoint-model';
3+
import type { DiscriminatorModel, DiscriminatorVariant, EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam, WebhookModel } from './endpoint-model';
44

55
const HTTP_METHODS: ReadonlyArray<OpenAPIV3.HttpMethods> = [
66
OpenAPIV3.HttpMethods.GET,
@@ -73,6 +73,8 @@ export function parseSecuritySchemes(api: OpenAPIV3.Document): SecuritySchemeMod
7373
return result;
7474
}
7575

76+
const RESPONSE_PRIORITY_CODES = ['200', '201', '202', '206', '2XX', '203', '207', '208', '226'];
77+
7678
function extractDiscriminatorFromSchema(
7779
schema: OpenAPIV3.SchemaObject | null | undefined,
7880
isArrayResponse: boolean
@@ -196,12 +198,11 @@ export function buildEndpoints(
196198
bodyContentType !== null && !KNOWN_BODY_CONTENT_TYPES.includes(bodyContentType);
197199

198200
// Collect ALL 2xx response codes that carry application/json, in priority order.
199-
const RESPONSE_PRIORITY = ['200', '201', '202', '206', '2XX', '203', '207', '208', '226'];
200201
const allResponseCodes = Object.keys(operation.responses ?? {});
201202
const orderedCodes = [
202-
...RESPONSE_PRIORITY.filter((c) => allResponseCodes.includes(c)),
203+
...RESPONSE_PRIORITY_CODES.filter((c) => allResponseCodes.includes(c)),
203204
...allResponseCodes.filter(
204-
(c) => !RESPONSE_PRIORITY.includes(c) && /^2/.test(c)
205+
(c) => !RESPONSE_PRIORITY_CODES.includes(c) && /^2/.test(c)
205206
),
206207
];
207208
const responseStatuses = orderedCodes.filter((code) => {
@@ -261,6 +262,54 @@ export function buildEndpoints(
261262
return endpoints;
262263
}
263264

265+
/** Extract WebhookModel entries from api.webhooks (OAS 3.1). */
266+
export function parseWebhooks(
267+
api: OpenAPIV3.Document,
268+
namingConvention: 'camel' | 'kebab'
269+
): WebhookModel[] {
270+
const webhooks = (api as unknown as { webhooks?: Record<string, OpenAPIV3.PathItemObject> })
271+
.webhooks;
272+
if (!webhooks) return [];
273+
274+
const result: WebhookModel[] = [];
275+
for (const [name, pathItem] of Object.entries(webhooks)) {
276+
if (!pathItem) continue;
277+
for (const method of HTTP_METHODS) {
278+
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
279+
if (!operation) continue;
280+
281+
const fileName =
282+
(namingConvention === 'kebab' ? toKebabCase(name) : name) + '.webhook';
283+
const tokenName = toScreamingSnake(name) + '_WEBHOOK';
284+
285+
const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject | undefined;
286+
const payloadContentType =
287+
requestBody?.content?.['application/json'] != null ? 'application/json' : null;
288+
289+
const allResponseCodes = Object.keys(operation.responses ?? {});
290+
const orderedCodes = [
291+
...RESPONSE_PRIORITY_CODES.filter((c) => allResponseCodes.includes(c)),
292+
...allResponseCodes.filter((c) => !RESPONSE_PRIORITY_CODES.includes(c) && /^2/.test(c)),
293+
];
294+
const responseStatuses = orderedCodes.filter((code) => {
295+
const obj = operation.responses?.[code] as OpenAPIV3.ResponseObject | undefined;
296+
return obj?.content?.['application/json'] != null;
297+
});
298+
299+
result.push({
300+
name,
301+
method,
302+
tokenName,
303+
fileName,
304+
deprecated: operation.deprecated === true,
305+
payloadContentType,
306+
responseStatuses,
307+
});
308+
}
309+
}
310+
return result;
311+
}
312+
264313
/** Convenience wrapper that dereferences the spec before building endpoints. */
265314
export async function parseSpec(
266315
specPath: string,

tools/openapi-resource-gen/src/generators/api-resource/render-token.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { EndpointModel, SecuritySchemeModel } from './endpoint-model';
1+
import type { EndpointModel, SecuritySchemeModel, WebhookModel } from './endpoint-model';
22

33
export function toPascalCase(str: string): string {
44
return str
@@ -69,6 +69,57 @@ export function renderSecurityTokenFile(
6969
].join('\n');
7070
}
7171

72+
export function renderWebhookTokenFile(wh: WebhookModel): string {
73+
const pascal = toPascalCase(wh.name);
74+
const hasPayload = wh.payloadContentType !== null;
75+
const hasResponse = wh.responseStatuses.length > 0;
76+
const needsWebhooksType = hasPayload || hasResponse;
77+
78+
const lines: string[] = [];
79+
lines.push(`import { InjectionToken } from '@angular/core';`);
80+
lines.push(`import { HttpInterceptorFn } from '@angular/common/http';`);
81+
if (needsWebhooksType) {
82+
lines.push(`import type { webhooks } from './schema.d';`);
83+
}
84+
lines.push('');
85+
86+
if (hasPayload) {
87+
lines.push(
88+
`export type ${pascal}WebhookPayload =`,
89+
` NonNullable<webhooks[${JSON.stringify(wh.name)}][${JSON.stringify(wh.method)}]['requestBody']>['content'][${JSON.stringify(wh.payloadContentType)}];`,
90+
''
91+
);
92+
}
93+
94+
if (hasResponse) {
95+
if (wh.responseStatuses.length === 1) {
96+
lines.push(
97+
`export type ${pascal}WebhookResponse =`,
98+
` webhooks[${JSON.stringify(wh.name)}][${JSON.stringify(wh.method)}]['responses'][${JSON.stringify(wh.responseStatuses[0])}]['content']['application/json'];`,
99+
''
100+
);
101+
} else {
102+
lines.push(`export type ${pascal}WebhookResponse =`);
103+
for (const code of wh.responseStatuses) {
104+
lines.push(
105+
` | webhooks[${JSON.stringify(wh.name)}][${JSON.stringify(wh.method)}]['responses'][${JSON.stringify(code)}]['content']['application/json']`
106+
);
107+
}
108+
lines.push('');
109+
}
110+
}
111+
112+
if (wh.deprecated) {
113+
lines.push('/** @deprecated */');
114+
}
115+
lines.push(
116+
`export const ${wh.tokenName} = new InjectionToken<HttpInterceptorFn>('${wh.tokenName}');`,
117+
''
118+
);
119+
120+
return lines.join('\n');
121+
}
122+
72123
export function renderTokenFile(
73124
ep: EndpointModel,
74125
baseUrlToken: string,

0 commit comments

Comments
 (0)