diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index fabf29580..51ec1d27a 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -164,7 +164,7 @@ function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode, * must check the parameter definition to determine the how to index into * the openapi-typescript type. **/ -export function oapiRef(path: string, resolved?: OapiRefResolved): ts.TypeNode { +export function oapiRef(path: string, resolved?: OapiRefResolved, deep = false): ts.TypeNode { const { pointer } = parseRef(path); if (pointer.length === 0) { 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 { const restSegments = pointer.slice(3); const leadingType = addIndexedAccess( - ts.factory.createTypeReferenceNode(ts.factory.createIdentifier(String(initialSegment))), + ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier(deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment)), + ), ...leadingSegments, ); @@ -305,6 +307,18 @@ export function tsArrayLiteralExpression( let variableName = sanitizeMemberName(name); variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`; + if ( + options?.injectFooter && + !options.injectFooter.some( + (node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "FlattenedDeepRequired", + ) + ) { + const helper = stringToAST( + "type FlattenedDeepRequired = { [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; };", + )[0] as any; + options.injectFooter.push(helper); + } + const arrayType = options?.readonly ? tsReadonlyArray(elementType, options.injectFooter) : ts.factory.createArrayTypeNode(elementType); diff --git a/packages/openapi-typescript/src/transform/request-body-object.ts b/packages/openapi-typescript/src/transform/request-body-object.ts index a89e5475d..8872313e5 100644 --- a/packages/openapi-typescript/src/transform/request-body-object.ts +++ b/packages/openapi-typescript/src/transform/request-body-object.ts @@ -15,7 +15,7 @@ export default function transformRequestBodyObject( ): ts.TypeNode { const type: ts.TypeElement[] = []; for (const [contentType, mediaTypeObject] of getEntries(requestBodyObject.content ?? {}, options.ctx)) { - const nextPath = createRef([options.path, contentType]); + const nextPath = createRef([options.path, "content", contentType]); const mediaType = "$ref" in mediaTypeObject ? transformSchemaObject(mediaTypeObject, { diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 9c5725126..2e872b768 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -34,8 +34,9 @@ import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../typ export default function transformSchemaObject( schemaObject: SchemaObject | ReferenceObject, options: TransformNodeOptions, + fromAdditionalProperties = false, ): ts.TypeNode { - const type = transformSchemaObjectWithComposition(schemaObject, options); + const type = transformSchemaObjectWithComposition(schemaObject, options, fromAdditionalProperties); if (typeof options.ctx.postTransform === "function") { const postTransformResult = options.ctx.postTransform(type, options); if (postTransformResult) { @@ -51,6 +52,7 @@ export default function transformSchemaObject( export function transformSchemaObjectWithComposition( schemaObject: SchemaObject | ReferenceObject, options: TransformNodeOptions, + fromAdditionalProperties = false, ): ts.TypeNode { /** * Unexpected types & edge cases @@ -138,14 +140,39 @@ export function transformSchemaObjectWithComposition( // hoist array with valid enum values to top level if string/number enum and option is enabled if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) { - let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/"); + const parsed = parseRef(options.path ?? ""); + let enumValuesVariableName = parsed.pointer.join("/"); // allow #/components/schemas to have simpler names enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); enumValuesVariableName = `${enumValuesVariableName}Values`; + // build a ref path for the type that ignores union indices (anyOf/oneOf) so + // type references remain stable even when names include union positions + const cleanedPointer: string[] = []; + for (let i = 0; i < parsed.pointer.length; i++) { + // Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message + const segment = parsed.pointer[i]; + if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) { + const next = parsed.pointer[i + 1]; + if (/^\d+$/.test(next)) { + // If we encounter something like "anyOf/0", we want to skip that part of the path + i++; + continue; + } + } + cleanedPointer.push(segment); + } + const cleanedRefPath = createRef(cleanedPointer); + const enumValuesArray = tsArrayLiteralExpression( enumValuesVariableName, - oapiRef(options.path ?? ""), + // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type + fromAdditionalProperties + ? ts.factory.createIndexedAccessTypeNode( + oapiRef(cleanedRefPath, undefined, true), + ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")), + ) + : oapiRef(cleanedRefPath, undefined, true), schemaObject.enum as (string | number)[], { export: true, @@ -165,10 +192,16 @@ export function transformSchemaObjectWithComposition( */ /** Collect oneOf/anyOf */ - function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[]) { + function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[], unionKey: "anyOf" | "oneOf") { const output: ts.TypeNode[] = []; - for (const item of items) { - output.push(transformSchemaObject(item, options)); + for (const [index, item] of items.entries()) { + output.push( + transformSchemaObject(item, { + ...options, + // include index in path so generated names from nested enums/enumValues are unique + path: createRef([options.path, unionKey, String(index)]), + }), + ); } return output; @@ -233,7 +266,7 @@ export function transformSchemaObjectWithComposition( } // anyOf: union // (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf) - const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? []); + const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? [], "anyOf"); if (anyOfType.length) { finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]); } @@ -244,6 +277,7 @@ export function transformSchemaObjectWithComposition( schemaObject.type === "object" && (schemaObject.enum as (SchemaObject | ReferenceObject)[])) || [], + "oneOf", ); if (oneOfType.length) { // note: oneOf is the only type that may include primitives @@ -547,7 +581,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor const hasExplicitAdditionalProperties = typeof schemaObject.additionalProperties === "object" && Object.keys(schemaObject.additionalProperties).length; const addlType = hasExplicitAdditionalProperties - ? transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options) + ? transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true) : UNKNOWN; return tsIntersection([ ...(coreObjectType.length ? [ts.factory.createTypeLiteralNode(coreObjectType)] : []), diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index ecf84e032..cbd79bd12 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -830,18 +830,254 @@ export interface components { pathItems: never; } export type $defs = Record; +type FlattenedDeepRequired = { + [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; +}; type ReadonlyArray = [ Exclude ] extends [ unknown[] ] ? Readonly> : Readonly[]>; -export const pathsUrlGetParametersQueryStatusValues: ReadonlyArray = ["active", "inactive"]; -export const statusValues: ReadonlyArray = ["active", "inactive"]; -export const errorCodeValues: ReadonlyArray = [100, 101, 102, 103, 104, 105]; +export const pathsUrlGetParametersQueryStatusValues: ReadonlyArray["/url"]["get"]["parameters"]["query"]["status"]> = ["active", "inactive"]; +export const statusValues: ReadonlyArray["schemas"]["Status"]> = ["active", "inactive"]; +export const errorCodeValues: ReadonlyArray["schemas"]["ErrorCode"]> = [100, 101, 102, 103, 104, 105]; export type operations = Record;`, options: { enumValues: true }, }, ], + [ + "options > enumValues with record types", + { + given: { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + components: { + schemas: { + ComplexEditKeyDto: { + type: "object", + properties: { + states: { + type: "object", + additionalProperties: { + type: "string", + enum: ["TRANSLATED", "REVIEWED"], + }, + }, + }, + }, + }, + }, + }, + want: `export type paths = Record; +export type webhooks = Record; +export interface components { + schemas: { + ComplexEditKeyDto: { + states?: { + [key: string]: "TRANSLATED" | "REVIEWED"; + }; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +type FlattenedDeepRequired = { + [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; +}; +type ReadonlyArray = [ + Exclude +] extends [ + unknown[] +] ? Readonly> : Readonly[]>; +export const complexEditKeyDtoStatesValues: ReadonlyArray["schemas"]["ComplexEditKeyDto"]["states"][string]> = ["TRANSLATED", "REVIEWED"]; +export type operations = Record;`, + options: { enumValues: true }, + }, + ], + [ + "options > enumValues with unions", + { + given: { + openapi: "3.1.0", + info: { + title: "API Portal", + version: "1.0.0", + description: "This is the **Analytics API** description", + }, + paths: { + "/analytics/data": { + get: { + operationId: "analytics.data", + tags: ["Analytics"], + responses: { + "400": { + description: "", + content: { + "application/json": { + schema: { + anyOf: [ + { + type: "object", + properties: { + message: { + type: "string", + enum: ["Bad request. (InvalidFilterException)"], + }, + errors: { + type: "object", + properties: { + filters: { + type: "string", + }, + }, + required: ["filters"], + }, + }, + required: ["message", "errors"], + }, + { + type: "object", + properties: { + message: { + type: "string", + enum: ["Bad request. (InvalidDimensionException)"], + }, + errors: { + type: "object", + properties: { + dimensions: { + type: "array", + prefixItems: [ + { + type: "string", + }, + ], + minItems: 1, + maxItems: 1, + additionalItems: false, + }, + }, + required: ["dimensions"], + }, + }, + required: ["message", "errors"], + }, + { + type: "object", + properties: { + message: { + type: "string", + enum: ["Bad request. (InvalidMetricException)"], + }, + errors: { + type: "object", + properties: { + metrics: { + type: "string", + }, + }, + required: ["metrics"], + }, + }, + required: ["message", "errors"], + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: `export interface paths { + "/analytics/data": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["analytics.data"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "analytics.data": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + message: "Bad request. (InvalidFilterException)"; + errors: { + filters: string; + }; + } | { + /** @enum {string} */ + message: "Bad request. (InvalidDimensionException)"; + errors: { + dimensions: [ + string + ]; + }; + } | { + /** @enum {string} */ + message: "Bad request. (InvalidMetricException)"; + errors: { + metrics: string; + }; + }; + }; + }; + }; + }; +} +type FlattenedDeepRequired = { + [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; +}; +type ReadonlyArray = [ + Exclude +] extends [ + unknown[] +] ? Readonly> : Readonly[]>; +export const pathsAnalyticsDataGetResponses400ContentApplicationJsonAnyOf0MessageValues: ReadonlyArray["/analytics/data"]["get"]["responses"]["400"]["content"]["application/json"]["message"]> = ["Bad request. (InvalidFilterException)"]; +export const pathsAnalyticsDataGetResponses400ContentApplicationJsonAnyOf1MessageValues: ReadonlyArray["/analytics/data"]["get"]["responses"]["400"]["content"]["application/json"]["message"]> = ["Bad request. (InvalidDimensionException)"]; +export const pathsAnalyticsDataGetResponses400ContentApplicationJsonAnyOf2MessageValues: ReadonlyArray["/analytics/data"]["get"]["responses"]["400"]["content"]["application/json"]["message"]> = ["Bad request. (InvalidMetricException)"];`, + options: { enumValues: true }, + }, + ], [ "options > dedupeEnums", { @@ -974,6 +1210,99 @@ export type operations = Record;`, ci: { timeout: 30_000 }, }, ], + [ + "options > enumValues with request body enum", + { + given: { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + paths: { + "/test": { + get: { + requestBody: { + content: { + "application/json": { + schema: { + properties: { + status: { + type: "string", + enum: ["active", "inactive"], + }, + }, + }, + }, + }, + }, + responses: { 200: { description: "OK" } }, + }, + }, + }, + }, + want: `export interface paths { + "/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @enum {string} */ + status?: "active" | "inactive"; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +type FlattenedDeepRequired = { + [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; +}; +type ReadonlyArray = [ + Exclude +] extends [ + unknown[] +] ? Readonly> : Readonly[]>; +export const pathsTestGetRequestBodyContentApplicationJsonStatusValues: ReadonlyArray["/test"]["get"]["requestBody"]["content"]["application/json"]["status"]> = ["active", "inactive"]; +export type operations = Record;`, + options: { enumValues: true }, + }, + ], ]; for (const [testName, { given, want, options, ci }] of tests) { @@ -991,3 +1320,4 @@ export type operations = Record;`, ); } }); +