Skip to content

Commit 38b73b0

Browse files
committed
feat(enum-values): support anyof/oneof
1 parent 270aad6 commit 38b73b0

File tree

2 files changed

+213
-7
lines changed

2 files changed

+213
-7
lines changed

packages/openapi-typescript/src/transform/schema-object.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,39 @@ export function transformSchemaObjectWithComposition(
140140

141141
// hoist array with valid enum values to top level if string/number enum and option is enabled
142142
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
143-
let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/");
143+
const parsed = parseRef(options.path ?? "");
144+
let enumValuesVariableName = parsed.pointer.join("/");
144145
// allow #/components/schemas to have simpler names
145146
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
146147
enumValuesVariableName = `${enumValuesVariableName}Values`;
147148

149+
// build a ref path for the type that ignores union indices (anyOf/oneOf) so
150+
// type references remain stable even when names include union positions
151+
const cleanedPointer: string[] = [];
152+
for (let i = 0; i < parsed.pointer.length; i++) {
153+
// Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
154+
const segment = parsed.pointer[i];
155+
if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) {
156+
const next = parsed.pointer[i + 1];
157+
if (/^\d+$/.test(next)) {
158+
// If we encounter something like "anyOf/0", we want to skip that part of the path
159+
i++;
160+
continue;
161+
}
162+
}
163+
cleanedPointer.push(segment);
164+
}
165+
const cleanedRefPath = createRef(cleanedPointer);
166+
148167
const enumValuesArray = tsArrayLiteralExpression(
149168
enumValuesVariableName,
150169
// If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
151170
fromAdditionalProperties
152171
? ts.factory.createIndexedAccessTypeNode(
153-
oapiRef(options.path ?? "", undefined, true),
172+
oapiRef(cleanedRefPath, undefined, true),
154173
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
155174
)
156-
: oapiRef(options.path ?? "", undefined, true),
175+
: oapiRef(cleanedRefPath, undefined, true),
157176
schemaObject.enum as (string | number)[],
158177
{
159178
export: true,
@@ -173,10 +192,16 @@ export function transformSchemaObjectWithComposition(
173192
*/
174193

175194
/** Collect oneOf/anyOf */
176-
function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[]) {
195+
function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[], unionKey: "anyOf" | "oneOf") {
177196
const output: ts.TypeNode[] = [];
178-
for (const item of items) {
179-
output.push(transformSchemaObject(item, options));
197+
for (const [index, item] of items.entries()) {
198+
output.push(
199+
transformSchemaObject(item, {
200+
...options,
201+
// include index in path so generated names from nested enums/enumValues are unique
202+
path: createRef([options.path, unionKey, String(index)]),
203+
}),
204+
);
180205
}
181206

182207
return output;
@@ -241,7 +266,7 @@ export function transformSchemaObjectWithComposition(
241266
}
242267
// anyOf: union
243268
// (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf)
244-
const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? []);
269+
const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? [], "anyOf");
245270
if (anyOfType.length) {
246271
finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]);
247272
}
@@ -252,6 +277,7 @@ export function transformSchemaObjectWithComposition(
252277
schemaObject.type === "object" &&
253278
(schemaObject.enum as (SchemaObject | ReferenceObject)[])) ||
254279
[],
280+
"oneOf",
255281
);
256282
if (oneOfType.length) {
257283
// note: oneOf is the only type that may include primitives

packages/openapi-typescript/test/node-api.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,186 @@ export type operations = Record<string, never>;`,
898898
options: { enumValues: true },
899899
},
900900
],
901+
[
902+
"options > enumValues with unions",
903+
{
904+
given: {
905+
openapi: "3.1.0",
906+
info: {
907+
title: "API Portal",
908+
version: "1.0.0",
909+
description: "This is the **Analytics API** description",
910+
},
911+
paths: {
912+
"/analytics/data": {
913+
get: {
914+
operationId: "analytics.data",
915+
tags: ["Analytics"],
916+
responses: {
917+
"400": {
918+
description: "",
919+
content: {
920+
"application/json": {
921+
schema: {
922+
anyOf: [
923+
{
924+
type: "object",
925+
properties: {
926+
message: {
927+
type: "string",
928+
enum: ["Bad request. (InvalidFilterException)"],
929+
},
930+
errors: {
931+
type: "object",
932+
properties: {
933+
filters: {
934+
type: "string",
935+
},
936+
},
937+
required: ["filters"],
938+
},
939+
},
940+
required: ["message", "errors"],
941+
},
942+
{
943+
type: "object",
944+
properties: {
945+
message: {
946+
type: "string",
947+
enum: ["Bad request. (InvalidDimensionException)"],
948+
},
949+
errors: {
950+
type: "object",
951+
properties: {
952+
dimensions: {
953+
type: "array",
954+
prefixItems: [
955+
{
956+
type: "string",
957+
},
958+
],
959+
minItems: 1,
960+
maxItems: 1,
961+
additionalItems: false,
962+
},
963+
},
964+
required: ["dimensions"],
965+
},
966+
},
967+
required: ["message", "errors"],
968+
},
969+
{
970+
type: "object",
971+
properties: {
972+
message: {
973+
type: "string",
974+
enum: ["Bad request. (InvalidMetricException)"],
975+
},
976+
errors: {
977+
type: "object",
978+
properties: {
979+
metrics: {
980+
type: "string",
981+
},
982+
},
983+
required: ["metrics"],
984+
},
985+
},
986+
required: ["message", "errors"],
987+
},
988+
],
989+
},
990+
},
991+
},
992+
},
993+
},
994+
},
995+
},
996+
},
997+
},
998+
want: `export interface paths {
999+
"/analytics/data": {
1000+
parameters: {
1001+
query?: never;
1002+
header?: never;
1003+
path?: never;
1004+
cookie?: never;
1005+
};
1006+
get: operations["analytics.data"];
1007+
put?: never;
1008+
post?: never;
1009+
delete?: never;
1010+
options?: never;
1011+
head?: never;
1012+
patch?: never;
1013+
trace?: never;
1014+
};
1015+
}
1016+
export type webhooks = Record<string, never>;
1017+
export interface components {
1018+
schemas: never;
1019+
responses: never;
1020+
parameters: never;
1021+
requestBodies: never;
1022+
headers: never;
1023+
pathItems: never;
1024+
}
1025+
export type $defs = Record<string, never>;
1026+
export interface operations {
1027+
"analytics.data": {
1028+
parameters: {
1029+
query?: never;
1030+
header?: never;
1031+
path?: never;
1032+
cookie?: never;
1033+
};
1034+
requestBody?: never;
1035+
responses: {
1036+
400: {
1037+
headers: {
1038+
[name: string]: unknown;
1039+
};
1040+
content: {
1041+
"application/json": {
1042+
/** @enum {string} */
1043+
message: "Bad request. (InvalidFilterException)";
1044+
errors: {
1045+
filters: string;
1046+
};
1047+
} | {
1048+
/** @enum {string} */
1049+
message: "Bad request. (InvalidDimensionException)";
1050+
errors: {
1051+
dimensions: [
1052+
string
1053+
];
1054+
};
1055+
} | {
1056+
/** @enum {string} */
1057+
message: "Bad request. (InvalidMetricException)";
1058+
errors: {
1059+
metrics: string;
1060+
};
1061+
};
1062+
};
1063+
};
1064+
};
1065+
};
1066+
}
1067+
type FlattenedDeepRequired<T> = {
1068+
[K in keyof T]-?: FlattenedDeepRequired<T[K] extends unknown[] | undefined | null ? Extract<T[K], unknown[]>[number] : T[K]>;
1069+
};
1070+
type ReadonlyArray<T> = [
1071+
Exclude<T, undefined>
1072+
] extends [
1073+
unknown[]
1074+
] ? Readonly<Exclude<T, undefined>> : Readonly<Exclude<T, undefined>[]>;
1075+
export const pathsAnalyticsDataGetResponses400ContentApplicationJsonAnyOf0MessageValues: ReadonlyArray<FlattenedDeepRequired<paths>["/analytics/data"]["get"]["responses"]["400"]["content"]["application/json"]["message"]> = ["Bad request. (InvalidFilterException)"];
1076+
export const pathsAnalyticsDataGetResponses400ContentApplicationJsonAnyOf1MessageValues: ReadonlyArray<FlattenedDeepRequired<paths>["/analytics/data"]["get"]["responses"]["400"]["content"]["application/json"]["message"]> = ["Bad request. (InvalidDimensionException)"];
1077+
export const pathsAnalyticsDataGetResponses400ContentApplicationJsonAnyOf2MessageValues: ReadonlyArray<FlattenedDeepRequired<paths>["/analytics/data"]["get"]["responses"]["400"]["content"]["application/json"]["message"]> = ["Bad request. (InvalidMetricException)"];`,
1078+
options: { enumValues: true },
1079+
},
1080+
],
9011081
[
9021082
"options > dedupeEnums",
9031083
{

0 commit comments

Comments
 (0)