Skip to content

Commit 12f9c29

Browse files
authored
fix(enum-values): access optional prop child props (#2139)
* fix(enum-values): access optional prop child props Fixes: #2138 * fix(enum-values): access array props Fixes: #2140 * fix(enum-values): handle more corner cases * test(enum-values): fix options > enumValues * feat(enum-values): support record type enums * feat(enum-values): support anyof/oneof * fix(enum-values): handle enums in request body * chore: add changeset
1 parent 1349929 commit 12f9c29

File tree

5 files changed

+396
-14
lines changed

5 files changed

+396
-14
lines changed

.changeset/modern-jars-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript": patch
3+
---
4+
5+
All kinds of enum related fixes (enums in arrays, in optional props, in unions, in request body, with record types...)

packages/openapi-typescript/src/lib/ts.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode,
164164
* must check the parameter definition to determine the how to index into
165165
* the openapi-typescript type.
166166
**/
167-
export function oapiRef(path: string, resolved?: OapiRefResolved): ts.TypeNode {
167+
export function oapiRef(path: string, resolved?: OapiRefResolved, deep = false): ts.TypeNode {
168168
const { pointer } = parseRef(path);
169169
if (pointer.length === 0) {
170170
throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`);
@@ -179,7 +179,9 @@ export function oapiRef(path: string, resolved?: OapiRefResolved): ts.TypeNode {
179179
const restSegments = pointer.slice(3);
180180

181181
const leadingType = addIndexedAccess(
182-
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier(String(initialSegment))),
182+
ts.factory.createTypeReferenceNode(
183+
ts.factory.createIdentifier(deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment)),
184+
),
183185
...leadingSegments,
184186
);
185187

@@ -305,6 +307,18 @@ export function tsArrayLiteralExpression(
305307
let variableName = sanitizeMemberName(name);
306308
variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`;
307309

310+
if (
311+
options?.injectFooter &&
312+
!options.injectFooter.some(
313+
(node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "FlattenedDeepRequired",
314+
)
315+
) {
316+
const helper = stringToAST(
317+
"type FlattenedDeepRequired<T> = { [K in keyof T]-?: FlattenedDeepRequired<T[K] extends unknown[] | undefined | null ? Extract<T[K], unknown[]>[number] : T[K]>; };",
318+
)[0] as any;
319+
options.injectFooter.push(helper);
320+
}
321+
308322
const arrayType = options?.readonly
309323
? tsReadonlyArray(elementType, options.injectFooter)
310324
: ts.factory.createArrayTypeNode(elementType);

packages/openapi-typescript/src/transform/request-body-object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function transformRequestBodyObject(
1515
): ts.TypeNode {
1616
const type: ts.TypeElement[] = [];
1717
for (const [contentType, mediaTypeObject] of getEntries(requestBodyObject.content ?? {}, options.ctx)) {
18-
const nextPath = createRef([options.path, contentType]);
18+
const nextPath = createRef([options.path, "content", contentType]);
1919
const mediaType =
2020
"$ref" in mediaTypeObject
2121
? transformSchemaObject(mediaTypeObject, {

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../typ
3434
export default function transformSchemaObject(
3535
schemaObject: SchemaObject | ReferenceObject,
3636
options: TransformNodeOptions,
37+
fromAdditionalProperties = false,
3738
): ts.TypeNode {
38-
const type = transformSchemaObjectWithComposition(schemaObject, options);
39+
const type = transformSchemaObjectWithComposition(schemaObject, options, fromAdditionalProperties);
3940
if (typeof options.ctx.postTransform === "function") {
4041
const postTransformResult = options.ctx.postTransform(type, options);
4142
if (postTransformResult) {
@@ -51,6 +52,7 @@ export default function transformSchemaObject(
5152
export function transformSchemaObjectWithComposition(
5253
schemaObject: SchemaObject | ReferenceObject,
5354
options: TransformNodeOptions,
55+
fromAdditionalProperties = false,
5456
): ts.TypeNode {
5557
/**
5658
* Unexpected types & edge cases
@@ -138,14 +140,39 @@ export function transformSchemaObjectWithComposition(
138140

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

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+
146167
const enumValuesArray = tsArrayLiteralExpression(
147168
enumValuesVariableName,
148-
oapiRef(options.path ?? ""),
169+
// If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
170+
fromAdditionalProperties
171+
? ts.factory.createIndexedAccessTypeNode(
172+
oapiRef(cleanedRefPath, undefined, true),
173+
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
174+
)
175+
: oapiRef(cleanedRefPath, undefined, true),
149176
schemaObject.enum as (string | number)[],
150177
{
151178
export: true,
@@ -165,10 +192,16 @@ export function transformSchemaObjectWithComposition(
165192
*/
166193

167194
/** Collect oneOf/anyOf */
168-
function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[]) {
195+
function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[], unionKey: "anyOf" | "oneOf") {
169196
const output: ts.TypeNode[] = [];
170-
for (const item of items) {
171-
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+
);
172205
}
173206

174207
return output;
@@ -233,7 +266,7 @@ export function transformSchemaObjectWithComposition(
233266
}
234267
// anyOf: union
235268
// (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf)
236-
const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? []);
269+
const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? [], "anyOf");
237270
if (anyOfType.length) {
238271
finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]);
239272
}
@@ -244,6 +277,7 @@ export function transformSchemaObjectWithComposition(
244277
schemaObject.type === "object" &&
245278
(schemaObject.enum as (SchemaObject | ReferenceObject)[])) ||
246279
[],
280+
"oneOf",
247281
);
248282
if (oneOfType.length) {
249283
// note: oneOf is the only type that may include primitives
@@ -578,7 +612,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
578612
typeof schemaObject.patternProperties === "object" && Object.keys(schemaObject.patternProperties).length;
579613
const stringIndexTypes = [];
580614
if (hasExplicitAdditionalProperties) {
581-
stringIndexTypes.push(transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options));
615+
stringIndexTypes.push(transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true));
582616
}
583617
if (hasImplicitAdditionalProperties || (!schemaObject.additionalProperties && options.ctx.additionalProperties)) {
584618
stringIndexTypes.push(UNKNOWN);

0 commit comments

Comments
 (0)