Skip to content

Commit bc1b2f4

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): emit discriminated union types for oneOf/anyOf with discriminator
When a response schema has discriminator.propertyName + oneOf/anyOf, the generator now emits XxxDiscriminatorKey (literal union of values), per-variant XxxCat/XxxDog aliases (using components intersection for mapping-based specs, Extract for enum-based fallback), and XxxDiscriminated (the proper narrowable union or array thereof). Imports components from schema.d automatically when mapping-based variants are present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dc9bc1e commit bc1b2f4

4 files changed

Lines changed: 261 additions & 2 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
@@ -1,3 +1,17 @@
1+
export interface DiscriminatorVariant {
2+
/** Discriminant literal value, e.g. 'cat'. */
3+
key: string;
4+
/** Component schema name extracted from discriminator.mapping, e.g. 'Cat'. Absent when only enum-based. */
5+
schemaName?: string;
6+
}
7+
8+
export interface DiscriminatorModel {
9+
propertyName: string;
10+
variants: DiscriminatorVariant[];
11+
/** True when the response schema is an array whose items carry the discriminator. */
12+
isArrayResponse: boolean;
13+
}
14+
115
/** Non-default serialization style for a query parameter. */
216
export type QueryParamSerializer = 'deepObject' | 'csv' | 'pipes' | 'spaces';
317

@@ -52,4 +66,6 @@ export interface EndpointModel {
5266
deprecated: boolean;
5367
/** Names of security schemes that apply to this endpoint (resolved from global + operation level). */
5468
securitySchemeNames: string[];
69+
/** Present when the primary response schema carries a discriminator. */
70+
discriminator: DiscriminatorModel | null;
5571
}

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,174 @@ describe('api-resource generator', () => {
893893
});
894894
});
895895

896+
describe('discriminated union support', () => {
897+
const DISC_SPEC = {
898+
paths: {
899+
'/events': {
900+
get: {
901+
operationId: 'listEvents',
902+
tags: ['events'],
903+
responses: {
904+
'200': {
905+
content: {
906+
'application/json': {
907+
schema: {
908+
oneOf: [
909+
{ type: 'object', properties: { type: { type: 'string' }, x: { type: 'number' } } },
910+
{ type: 'object', properties: { type: { type: 'string' }, duration: { type: 'number' } } },
911+
],
912+
discriminator: {
913+
propertyName: 'type',
914+
mapping: {
915+
click: '#/components/schemas/ClickEvent',
916+
hover: '#/components/schemas/HoverEvent',
917+
},
918+
},
919+
},
920+
},
921+
},
922+
},
923+
},
924+
},
925+
},
926+
},
927+
};
928+
929+
it('emits DiscriminatorKey union type', async () => {
930+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(DISC_SPEC as never);
931+
await apiResourceGenerator(tree, {
932+
specPath: 'specs/petstore.yaml',
933+
outputDir: 'libs/disc-key/src',
934+
});
935+
const content = tree.read('libs/disc-key/src/events/list-events.token.ts', 'utf-8')!;
936+
expect(content).toContain("export type ListEventsDiscriminatorKey = 'click' | 'hover';");
937+
});
938+
939+
it('emits per-variant narrowed type aliases using component schema intersection', async () => {
940+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(DISC_SPEC as never);
941+
await apiResourceGenerator(tree, {
942+
specPath: 'specs/petstore.yaml',
943+
outputDir: 'libs/disc-variants/src',
944+
});
945+
const content = tree.read('libs/disc-variants/src/events/list-events.token.ts', 'utf-8')!;
946+
expect(content).toContain("components['schemas']['ClickEvent']");
947+
expect(content).toContain("components['schemas']['HoverEvent']");
948+
// Prettier reformats { "type": "click" } → { type: 'click'; }
949+
expect(content).toContain("type: 'click'");
950+
expect(content).toContain("type: 'hover'");
951+
expect(content).toContain('export type ListEventsClick =');
952+
expect(content).toContain('export type ListEventsHover =');
953+
});
954+
955+
it('imports components from schema.d when mapping-based variants are present', async () => {
956+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(DISC_SPEC as never);
957+
await apiResourceGenerator(tree, {
958+
specPath: 'specs/petstore.yaml',
959+
outputDir: 'libs/disc-import/src',
960+
});
961+
const content = tree.read('libs/disc-import/src/events/list-events.token.ts', 'utf-8')!;
962+
expect(content).toContain("import type { paths, components } from '../schema.d'");
963+
});
964+
965+
it('emits XxxDiscriminated union type', async () => {
966+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(DISC_SPEC as never);
967+
await apiResourceGenerator(tree, {
968+
specPath: 'specs/petstore.yaml',
969+
outputDir: 'libs/disc-union/src',
970+
});
971+
const content = tree.read('libs/disc-union/src/events/list-events.token.ts', 'utf-8')!;
972+
expect(content).toContain('export type ListEventsDiscriminated = ListEventsClick | ListEventsHover;');
973+
});
974+
975+
it('wraps XxxDiscriminated in array for array-of-discriminated-items responses', async () => {
976+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
977+
paths: {
978+
'/events': {
979+
get: {
980+
operationId: 'listEvents',
981+
tags: ['events'],
982+
responses: {
983+
'200': {
984+
content: {
985+
'application/json': {
986+
schema: {
987+
type: 'array',
988+
items: {
989+
oneOf: [
990+
{ type: 'object', properties: { type: { type: 'string' } } },
991+
{ type: 'object', properties: { type: { type: 'string' } } },
992+
],
993+
discriminator: {
994+
propertyName: 'type',
995+
mapping: {
996+
click: '#/components/schemas/ClickEvent',
997+
hover: '#/components/schemas/HoverEvent',
998+
},
999+
},
1000+
},
1001+
},
1002+
},
1003+
},
1004+
},
1005+
},
1006+
},
1007+
},
1008+
},
1009+
} as never);
1010+
await apiResourceGenerator(tree, {
1011+
specPath: 'specs/petstore.yaml',
1012+
outputDir: 'libs/disc-array/src',
1013+
});
1014+
const content = tree.read('libs/disc-array/src/events/list-events.token.ts', 'utf-8')!;
1015+
expect(content).toContain('export type ListEventsDiscriminated = (ListEventsClick | ListEventsHover)[];');
1016+
});
1017+
1018+
it('emits Extract-based variants when only enum values are available (no mapping)', async () => {
1019+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1020+
paths: {
1021+
'/events': {
1022+
get: {
1023+
operationId: 'listEvents',
1024+
tags: ['events'],
1025+
responses: {
1026+
'200': {
1027+
content: {
1028+
'application/json': {
1029+
schema: {
1030+
oneOf: [
1031+
{ type: 'object', properties: { type: { type: 'string', enum: ['click'] } } },
1032+
{ type: 'object', properties: { type: { type: 'string', enum: ['hover'] } } },
1033+
],
1034+
discriminator: { propertyName: 'type' },
1035+
},
1036+
},
1037+
},
1038+
},
1039+
},
1040+
},
1041+
},
1042+
},
1043+
} as never);
1044+
await apiResourceGenerator(tree, {
1045+
specPath: 'specs/petstore.yaml',
1046+
outputDir: 'libs/disc-enum/src',
1047+
});
1048+
const content = tree.read('libs/disc-enum/src/events/list-events.token.ts', 'utf-8')!;
1049+
expect(content).toContain('Extract<ListEventsResponse,');
1050+
expect(content).not.toContain("import type { paths, components }");
1051+
});
1052+
1053+
it('does not emit discriminated types when response has no discriminator', async () => {
1054+
await apiResourceGenerator(tree, {
1055+
specPath: 'specs/petstore.yaml',
1056+
outputDir: 'libs/disc-none/src',
1057+
});
1058+
const content = tree.read('libs/disc-none/src/pets/list-pets.token.ts', 'utf-8')!;
1059+
expect(content).not.toContain('DiscriminatorKey');
1060+
expect(content).not.toContain('Discriminated');
1061+
});
1062+
});
1063+
8961064
describe('descriptive errors', () => {
8971065
it('error message for missing openapi field includes version guidance', () => {
8981066
// Verify the error text is descriptive before we even hit SwaggerParser.

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

Lines changed: 48 additions & 1 deletion
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 { EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam } from './endpoint-model';
3+
import type { DiscriminatorModel, DiscriminatorVariant, EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam } from './endpoint-model';
44

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

76+
function extractDiscriminatorFromSchema(
77+
schema: OpenAPIV3.SchemaObject | null | undefined,
78+
isArrayResponse: boolean
79+
): DiscriminatorModel | null {
80+
if (!schema?.discriminator?.propertyName) return null;
81+
82+
const { propertyName, mapping } = schema.discriminator;
83+
const variants: DiscriminatorVariant[] = [];
84+
85+
if (mapping) {
86+
for (const [key, ref] of Object.entries(mapping)) {
87+
const schemaName = ref.split('/').pop();
88+
if (schemaName) variants.push({ key, schemaName });
89+
}
90+
}
91+
92+
// Fallback: look for enum values on the discriminator property in each oneOf/anyOf variant.
93+
if (variants.length === 0) {
94+
const subschemas = [
95+
...((schema.oneOf ?? []) as OpenAPIV3.SchemaObject[]),
96+
...((schema.anyOf ?? []) as OpenAPIV3.SchemaObject[]),
97+
];
98+
for (const sub of subschemas) {
99+
const discProp = sub.properties?.[propertyName] as OpenAPIV3.SchemaObject | undefined;
100+
const enumVal = discProp?.enum?.[0];
101+
if (enumVal !== undefined) variants.push({ key: String(enumVal) });
102+
}
103+
}
104+
105+
return variants.length > 0 ? { propertyName, variants, isArrayResponse } : null;
106+
}
107+
76108
export function buildEndpoints(
77109
api: OpenAPIV3.Document,
78110
allowedTags: string[] | null,
@@ -187,6 +219,20 @@ export function buildEndpoints(
187219

188220
const deprecated = operation.deprecated === true;
189221

222+
// Detect discriminated union on the primary response schema.
223+
let discriminator: DiscriminatorModel | null = null;
224+
if (responseStatuses.length > 0) {
225+
const primaryResponseObj = operation.responses?.[responseStatuses[0]] as OpenAPIV3.ResponseObject | undefined;
226+
const primarySchema = primaryResponseObj?.content?.['application/json']?.schema as OpenAPIV3.SchemaObject | undefined;
227+
if (primarySchema) {
228+
discriminator =
229+
extractDiscriminatorFromSchema(primarySchema, false) ??
230+
(primarySchema.type === 'array'
231+
? extractDiscriminatorFromSchema(primarySchema.items as OpenAPIV3.SchemaObject, true)
232+
: null);
233+
}
234+
}
235+
190236
endpoints.push({
191237
tag: toKebabCase(tag),
192238
operationId: rawId,
@@ -207,6 +253,7 @@ export function buildEndpoints(
207253
deprecated,
208254
securitySchemeNames,
209255
errorStatuses,
256+
discriminator,
210257
});
211258
}
212259
}

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export function renderTokenFile(
9595
if (providedIn === 'none') coreImports.push('FactoryProvider');
9696
lines.push(`import { ${coreImports.join(', ')} } from '@angular/core';`);
9797
lines.push(`import { httpResource } from '@angular/common/http';`);
98-
lines.push(`import type { paths } from '../schema.d';`);
98+
const needsComponents = ep.discriminator?.variants.some((v) => v.schemaName) ?? false;
99+
lines.push(`import type { paths${needsComponents ? ', components' : ''} } from '../schema.d';`);
99100
lines.push(`import { ${baseUrlToken} } from '../api-base-url.token';`);
100101
for (const scheme of applicableSchemes) {
101102
lines.push(`import { ${scheme.tokenName} } from '../${scheme.fileName}';`);
@@ -159,6 +160,33 @@ export function renderTokenFile(
159160
}
160161
}
161162

163+
// Discriminated union helpers — emitted when the primary response schema has a discriminator.
164+
if (ep.discriminator && hasResponse) {
165+
const { propertyName, variants, isArrayResponse } = ep.discriminator;
166+
const keyLiterals = variants.map((v) => JSON.stringify(v.key)).join(' | ');
167+
lines.push(`export type ${pascal}DiscriminatorKey = ${keyLiterals};`, '');
168+
169+
for (const v of variants) {
170+
const variantPascal = toPascalCase(v.key);
171+
let typeExpr: string;
172+
if (v.schemaName) {
173+
// Mapping-based: intersect component schema with a literal discriminant tag.
174+
typeExpr = `components['schemas'][${JSON.stringify(v.schemaName)}] & { ${JSON.stringify(propertyName)}: ${JSON.stringify(v.key)} }`;
175+
} else {
176+
// Enum-based fallback: narrow the response union with Extract.
177+
const base = isArrayResponse
178+
? `${pascal}Response extends (infer _I)[] ? _I : never`
179+
: `${pascal}Response`;
180+
typeExpr = `Extract<${base}, { ${JSON.stringify(propertyName)}: ${JSON.stringify(v.key)} }>`;
181+
}
182+
lines.push(`export type ${pascal}${variantPascal} = ${typeExpr};`, '');
183+
}
184+
185+
const variantNames = variants.map((v) => `${pascal}${toPascalCase(v.key)}`).join(' | ');
186+
const discriminatedExpr = isArrayResponse ? `(${variantNames})[]` : variantNames;
187+
lines.push(`export type ${pascal}Discriminated = ${discriminatedExpr};`, '');
188+
}
189+
162190
const responseT = hasResponse ? `${pascal}Response` : 'unknown';
163191
const fnArgs = buildFnArgs(ep, pascal, isGet);
164192

0 commit comments

Comments
 (0)