Skip to content

Commit fde6863

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): deprecated JSDoc, response unions, binary body, cookie params, verbose flag
- @deprecated JSDoc emitted above the InjectionToken constant when the OpenAPI operation has `deprecated: true` - Response type unions: collect all 2xx JSON response codes and emit a union type alias when multiple codes exist (e.g. 200 | 201 responses) - Binary body: non-json/form/multipart content types (octet-stream, pdf, image/*) emit `Blob | ArrayBuffer` instead of the paths type, which is the correct Angular HttpClient body type for binary uploads - Cookie params: `in: cookie` parameters are surfaced as named string args (after header params) and combined into a single Cookie header value; optional cookies use a conditional spread so absent values are omitted - Verbose flag: --verbose prints a +/~/- summary of created, updated, and deleted files after generation using tree.listChanges() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 290ea7f commit fde6863

6 files changed

Lines changed: 309 additions & 32 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,19 @@ export interface EndpointModel {
2525
apiPath: string;
2626
pathParams: string[];
2727
headerParams: Array<{ name: string; required: boolean }>;
28+
cookieParams: Array<{ name: string; required: boolean }>;
2829
tokenName: string;
2930
fileName: string;
3031
hasQueryParams: boolean;
3132
hasBody: boolean;
3233
hasResponse: boolean;
33-
responseStatus: string | null;
34+
/** All 2xx response codes that carry application/json content, in priority order. */
35+
responseStatuses: string[];
3436
bodyContentType: string | null;
37+
/** True when bodyContentType is not json / form-urlencoded / multipart (e.g. octet-stream, pdf, image). */
38+
isBinaryBody: boolean;
39+
/** True when the operation is marked deprecated in the spec. */
40+
deprecated: boolean;
3541
/** Names of security schemes that apply to this endpoint (resolved from global + operation level). */
3642
securitySchemeNames: string[];
3743
}

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

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,210 @@ describe('api-resource generator', () => {
396396
});
397397
});
398398

399+
describe('deprecated operations', () => {
400+
it('emits /** @deprecated */ JSDoc on a deprecated token', async () => {
401+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
402+
paths: {
403+
'/old-resource': {
404+
get: {
405+
operationId: 'legacyGet',
406+
tags: ['legacy'],
407+
deprecated: true,
408+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
409+
},
410+
},
411+
},
412+
} as never);
413+
414+
await apiResourceGenerator(tree, {
415+
specPath: 'specs/petstore.yaml',
416+
outputDir: 'libs/deprecated/src',
417+
});
418+
const content = tree.read('libs/deprecated/src/legacy/legacy-get.token.ts', 'utf-8')!;
419+
expect(content).toContain('/** @deprecated */');
420+
});
421+
422+
it('does NOT emit @deprecated for a non-deprecated operation', async () => {
423+
await apiResourceGenerator(tree, {
424+
specPath: 'specs/petstore.yaml',
425+
outputDir: 'libs/not-deprecated/src',
426+
});
427+
const content = tree.read('libs/not-deprecated/src/pets/list-pets.token.ts', 'utf-8')!;
428+
expect(content).not.toContain('@deprecated');
429+
});
430+
});
431+
432+
describe('response type unions', () => {
433+
it('emits a union type when an endpoint returns 200 and 201 JSON responses', async () => {
434+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
435+
paths: {
436+
'/resources': {
437+
put: {
438+
operationId: 'upsertResource',
439+
tags: ['resources'],
440+
requestBody: { content: { 'application/json': { schema: {} } } },
441+
responses: {
442+
'200': { content: { 'application/json': { schema: {} } } },
443+
'201': { content: { 'application/json': { schema: {} } } },
444+
},
445+
},
446+
},
447+
},
448+
} as never);
449+
450+
await apiResourceGenerator(tree, {
451+
specPath: 'specs/petstore.yaml',
452+
outputDir: 'libs/union/src',
453+
});
454+
const content = tree.read('libs/union/src/resources/upsert-resource.token.ts', 'utf-8')!;
455+
expect(content).toContain("['responses']['200']['content']['application/json']");
456+
expect(content).toContain("['responses']['201']['content']['application/json']");
457+
// The union pipe character should appear in the type definition
458+
expect(content).toMatch(/\|\s*paths\[/);
459+
});
460+
461+
it('emits a single type when only one 2xx JSON response exists', async () => {
462+
await apiResourceGenerator(tree, {
463+
specPath: 'specs/petstore.yaml',
464+
outputDir: 'libs/single-resp/src',
465+
});
466+
const content = tree.read('libs/single-resp/src/pets/list-pets.token.ts', 'utf-8')!;
467+
// Single status — no leading pipe in the type alias line
468+
expect(content).toContain("export type ListPetsResponse =");
469+
expect(content).toContain("['responses']['200']");
470+
expect(content).not.toMatch(/export type ListPetsResponse =\s*\|/);
471+
});
472+
});
473+
474+
describe('binary body', () => {
475+
it('emits Blob | ArrayBuffer for octet-stream request body', async () => {
476+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
477+
paths: {
478+
'/upload': {
479+
post: {
480+
operationId: 'uploadBinary',
481+
tags: ['upload'],
482+
requestBody: {
483+
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
484+
},
485+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
486+
},
487+
},
488+
},
489+
} as never);
490+
491+
await apiResourceGenerator(tree, {
492+
specPath: 'specs/petstore.yaml',
493+
outputDir: 'libs/binary/src',
494+
});
495+
const content = tree.read('libs/binary/src/upload/upload-binary.token.ts', 'utf-8')!;
496+
expect(content).toContain('Blob | ArrayBuffer');
497+
// Must NOT reference the paths type for the body (would be wrong for binary)
498+
expect(content).not.toContain("['requestBody']['content']['application/octet-stream']");
499+
});
500+
501+
it('does not emit binary body type for standard json body', async () => {
502+
await apiResourceGenerator(tree, {
503+
specPath: 'specs/petstore.yaml',
504+
outputDir: 'libs/json-body/src',
505+
});
506+
const content = tree.read('libs/json-body/src/pets/create-pet.token.ts', 'utf-8')!;
507+
expect(content).not.toContain('Blob | ArrayBuffer');
508+
// Prettier may split the long path across lines, so check the key parts separately
509+
expect(content).toContain("requestBody']");
510+
expect(content).toContain("['content']['application/json']");
511+
});
512+
});
513+
514+
describe('cookie parameters', () => {
515+
it('adds required cookie param as a required function arg and Cookie header', async () => {
516+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
517+
paths: {
518+
'/me': {
519+
get: {
520+
operationId: 'getCurrentUser',
521+
tags: ['user'],
522+
parameters: [
523+
{ in: 'cookie', name: 'session', required: true, schema: { type: 'string' } },
524+
],
525+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
526+
},
527+
},
528+
},
529+
} as never);
530+
531+
await apiResourceGenerator(tree, {
532+
specPath: 'specs/petstore.yaml',
533+
outputDir: 'libs/cookies/src',
534+
});
535+
const content = tree.read('libs/cookies/src/user/get-current-user.token.ts', 'utf-8')!;
536+
expect(content).toContain('session: string');
537+
// Prettier strips quotes from valid identifier keys: 'Cookie' → Cookie
538+
expect(content).toContain('Cookie:');
539+
expect(content).toContain('session=');
540+
});
541+
542+
it('adds optional cookie param with conditional spread in Cookie header', async () => {
543+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
544+
paths: {
545+
'/prefs': {
546+
get: {
547+
operationId: 'getPreferences',
548+
tags: ['prefs'],
549+
parameters: [
550+
{ in: 'cookie', name: 'theme', required: false, schema: { type: 'string' } },
551+
],
552+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
553+
},
554+
},
555+
},
556+
} as never);
557+
558+
await apiResourceGenerator(tree, {
559+
specPath: 'specs/petstore.yaml',
560+
outputDir: 'libs/cookies-opt/src',
561+
});
562+
const content = tree.read('libs/cookies-opt/src/prefs/get-preferences.token.ts', 'utf-8')!;
563+
expect(content).toContain('theme?: string');
564+
// Prettier strips quotes from valid identifier keys: 'Cookie' → Cookie
565+
expect(content).toContain('Cookie:');
566+
// Optional cookie uses the conditional spread pattern
567+
expect(content).toContain('theme != null');
568+
});
569+
});
570+
571+
describe('verbose output', () => {
572+
it('prints a file summary when verbose is true', async () => {
573+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
574+
try {
575+
await apiResourceGenerator(tree, {
576+
specPath: 'specs/petstore.yaml',
577+
outputDir: 'libs/verbose/src',
578+
verbose: true,
579+
});
580+
expect(consoleSpy).toHaveBeenCalled();
581+
const output = consoleSpy.mock.calls.flat().join('\n');
582+
expect(output).toContain('[openapi-resource-gen]');
583+
expect(output).toContain('+');
584+
} finally {
585+
consoleSpy.mockRestore();
586+
}
587+
});
588+
589+
it('does not print anything when verbose is false', async () => {
590+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
591+
try {
592+
await apiResourceGenerator(tree, {
593+
specPath: 'specs/petstore.yaml',
594+
outputDir: 'libs/quiet/src',
595+
});
596+
expect(consoleSpy).not.toHaveBeenCalled();
597+
} finally {
598+
consoleSpy.mockRestore();
599+
}
600+
});
601+
});
602+
399603
describe('descriptive errors', () => {
400604
it('error message for missing openapi field includes version guidance', () => {
401605
// Verify the error text is descriptive before we even hit SwaggerParser.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface ApiResourceGeneratorSchema {
3737
includeMocks?: boolean;
3838
/** Identifier embedded in MockResourceMeta and mocks.manifest.json. */
3939
specId?: string;
40+
/** Print a summary of created, updated, and deleted files after generation. */
41+
verbose?: boolean;
4042
}
4143

4244
/** Derive a specId from the baseUrlToken: PETSTORE_BASE_URL → petstore */
@@ -377,6 +379,19 @@ export async function apiResourceGenerator(
377379
}
378380

379381
await formatFiles(tree);
382+
383+
if (options.verbose) {
384+
const changes = tree.listChanges();
385+
const created = changes.filter((c) => c.type === 'CREATE');
386+
const updated = changes.filter((c) => c.type === 'UPDATE');
387+
const deleted = changes.filter((c) => c.type === 'DELETE');
388+
const summary: string[] = ['\n[openapi-resource-gen] Generation complete:'];
389+
if (created.length) summary.push(...created.map((c) => ` + ${c.path}`));
390+
if (updated.length) summary.push(...updated.map((c) => ` ~ ${c.path}`));
391+
if (deleted.length) summary.push(...deleted.map((c) => ` - ${c.path}`));
392+
if (!created.length && !updated.length && !deleted.length) summary.push(' (no changes)');
393+
console.log(summary.join('\n'));
394+
}
380395
}
381396

382397
export default apiResourceGenerator;

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

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ const HTTP_METHODS: ReadonlyArray<OpenAPIV3.HttpMethods> = [
1010
OpenAPIV3.HttpMethods.DELETE,
1111
];
1212

13+
/** Content types that Angular's HttpClient handles natively (no special body wrapping). */
14+
const KNOWN_BODY_CONTENT_TYPES = [
15+
'application/json',
16+
'application/x-www-form-urlencoded',
17+
'multipart/form-data',
18+
];
19+
1320
export function toScreamingSnake(str: string): string {
1421
return str
1522
.replace(/\//g, '_')
@@ -111,6 +118,10 @@ export function buildEndpoints(
111118
.filter((p) => p.in === 'header')
112119
.map((p) => ({ name: p.name, required: p.required === true }));
113120

121+
const cookieParams = allParams
122+
.filter((p) => p.in === 'cookie')
123+
.map((p) => ({ name: p.name, required: p.required === true }));
124+
114125
const hasQueryParams = allParams.some((p) => p.in === 'query');
115126

116127
// Operation-level security overrides global; [] means explicitly no security.
@@ -127,19 +138,16 @@ export function buildEndpoints(
127138
| OpenAPIV3.RequestBodyObject
128139
| undefined;
129140
const bodyContent = requestBody?.content ?? {};
130-
const BODY_CONTENT_TYPES = [
131-
'application/json',
132-
'application/x-www-form-urlencoded',
133-
'multipart/form-data',
134-
];
135141
const bodyContentType =
136-
BODY_CONTENT_TYPES.find((ct) => bodyContent[ct]) ??
142+
KNOWN_BODY_CONTENT_TYPES.find((ct) => bodyContent[ct]) ??
137143
Object.keys(bodyContent)[0] ??
138144
null;
139145
const hasBody = bodyContentType !== null;
146+
// Binary: any content type not in the known set (e.g. octet-stream, pdf, image/*).
147+
const isBinaryBody =
148+
bodyContentType !== null && !KNOWN_BODY_CONTENT_TYPES.includes(bodyContentType);
140149

141-
// Pick the first 2xx response code that carries application/json content.
142-
// Covers 200, 201, 202, 206, and the catch-all '2XX' used by some specs.
150+
// Collect ALL 2xx response codes that carry application/json, in priority order.
143151
const RESPONSE_PRIORITY = ['200', '201', '202', '206', '2XX', '203', '207', '208', '226'];
144152
const allResponseCodes = Object.keys(operation.responses ?? {});
145153
const orderedCodes = [
@@ -148,12 +156,13 @@ export function buildEndpoints(
148156
(c) => !RESPONSE_PRIORITY.includes(c) && /^2/.test(c)
149157
),
150158
];
151-
const responseStatus =
152-
orderedCodes.find((code) => {
153-
const obj = operation.responses?.[code] as OpenAPIV3.ResponseObject | undefined;
154-
return obj?.content?.['application/json'] != null;
155-
}) ?? null;
156-
const hasResponse = responseStatus !== null;
159+
const responseStatuses = orderedCodes.filter((code) => {
160+
const obj = operation.responses?.[code] as OpenAPIV3.ResponseObject | undefined;
161+
return obj?.content?.['application/json'] != null;
162+
});
163+
const hasResponse = responseStatuses.length > 0;
164+
165+
const deprecated = operation.deprecated === true;
157166

158167
endpoints.push({
159168
tag: toKebabCase(tag),
@@ -162,13 +171,16 @@ export function buildEndpoints(
162171
apiPath,
163172
pathParams,
164173
headerParams,
174+
cookieParams,
165175
tokenName,
166176
fileName,
167177
hasQueryParams,
168178
hasBody,
169179
hasResponse,
170-
responseStatus,
180+
responseStatuses,
171181
bodyContentType,
182+
isBinaryBody,
183+
deprecated,
172184
securitySchemeNames,
173185
});
174186
}

0 commit comments

Comments
 (0)