diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 9c5725126..d821dfea0 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -92,72 +92,79 @@ export function transformSchemaObjectWithComposition( if ( Array.isArray(schemaObject.enum) && (!("type" in schemaObject) || schemaObject.type !== "object") && - !("properties" in schemaObject) && - !("additionalProperties" in schemaObject) + !("properties" in schemaObject) ) { - // hoist enum to top level if string/number enum and option is enabled - if ( - options.ctx.enum && - schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null) - ) { - let enumName = parseRef(options.path ?? "").pointer.join("/"); - // allow #/components/schemas to have simpler names - enumName = enumName.replace("components/schemas", ""); - const metadata = schemaObject.enum.map((_, i) => ({ - name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i], - description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i], - })); - - // enums can contain null values, but dont want to output them - let hasNull = false; - const validSchemaEnums = schemaObject.enum.filter((enumValue) => { - if (enumValue === null) { - hasNull = true; - return false; + const hasAdditionalProperties = "additionalProperties" in schemaObject && !!schemaObject.additionalProperties; + + if (!hasAdditionalProperties || (schemaObject.type === "string" && hasAdditionalProperties)) { + // hoist enum to top level if string/number enum and option is enabled + if ( + options.ctx.enum && + schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null) + ) { + let enumName = parseRef(options.path ?? "").pointer.join("/"); + // allow #/components/schemas to have simpler names + enumName = enumName.replace("components/schemas", ""); + const metadata = schemaObject.enum.map((_, i) => ({ + name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i], + description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i], + })); + + // enums can contain null values, but dont want to output them + let hasNull = false; + const validSchemaEnums = schemaObject.enum.filter((enumValue) => { + if (enumValue === null) { + hasNull = true; + return false; + } + + return true; + }); + const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, { + shouldCache: options.ctx.dedupeEnums, + export: true, + // readonly: TS enum do not support the readonly modifier + }); + if (!options.ctx.injectFooter.includes(enumType)) { + options.ctx.injectFooter.push(enumType); } + const ref = ts.factory.createTypeReferenceNode(enumType.name); + + const finalType: ts.TypeNode = hasNull ? tsUnion([ref, NULL]) : ref; - return true; - }); - const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, { - shouldCache: options.ctx.dedupeEnums, - export: true, - // readonly: TS enum do not support the readonly modifier - }); - if (!options.ctx.injectFooter.includes(enumType)) { - options.ctx.injectFooter.push(enumType); + return applyAdditionalPropertiesToEnum(hasAdditionalProperties, finalType, schemaObject); } - const ref = ts.factory.createTypeReferenceNode(enumType.name); - return hasNull ? tsUnion([ref, NULL]) : ref; - } - const enumType = schemaObject.enum.map(tsLiteral); - if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { - enumType.push(NULL); - } - const unionType = tsUnion(enumType); + const enumType = schemaObject.enum.map(tsLiteral); + if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { + enumType.push(NULL); + } - // 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("/"); - // allow #/components/schemas to have simpler names - enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); - enumValuesVariableName = `${enumValuesVariableName}Values`; + const unionType = applyAdditionalPropertiesToEnum(hasAdditionalProperties, tsUnion(enumType), schemaObject); + + // 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("/"); + // allow #/components/schemas to have simpler names + enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); + enumValuesVariableName = `${enumValuesVariableName}Values`; + + const enumValuesArray = tsArrayLiteralExpression( + enumValuesVariableName, + oapiRef(options.path ?? ""), + schemaObject.enum as (string | number)[], + { + export: true, + readonly: true, + injectFooter: options.ctx.injectFooter, + }, + ); - const enumValuesArray = tsArrayLiteralExpression( - enumValuesVariableName, - oapiRef(options.path ?? ""), - schemaObject.enum as (string | number)[], - { - export: true, - readonly: true, - injectFooter: options.ctx.injectFooter, - }, - ); + options.ctx.injectFooter.push(enumValuesArray); + } - options.ctx.injectFooter.push(enumValuesArray); + return unionType; } - - return unionType; } /** @@ -446,7 +453,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor ("$defs" in schemaObject && schemaObject.$defs) ) { // properties - if (Object.keys(schemaObject.properties ?? {}).length) { + if ("properties" in schemaObject && schemaObject.properties && Object.keys(schemaObject?.properties).length) { for (const [k, v] of getEntries(schemaObject.properties ?? {}, options.ctx)) { if ((typeof v !== "object" && typeof v !== "boolean") || Array.isArray(v)) { throw new Error( @@ -515,7 +522,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } // $defs - if (schemaObject.$defs && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) { + if ("$defs" in schemaObject && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) { const defKeys: ts.TypeElement[] = []; for (const [k, v] of Object.entries(schemaObject.$defs)) { const property = ts.factory.createPropertySignature( @@ -584,3 +591,16 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor function hasKey(possibleObject: unknown, key: K): possibleObject is { [key in K]: unknown } { return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject; } + +function applyAdditionalPropertiesToEnum( + hasAdditionalProperties: boolean, + unionType: ts.TypeNode, + schemaObject: SchemaObject, +) { + // If additionalProperties is true, add (string & {}) to the union + if (hasAdditionalProperties && schemaObject.type === "string") { + const stringAndEmptyObject = tsIntersection([STRING, ts.factory.createTypeLiteralNode([])]); + return tsUnion([unionType, stringAndEmptyObject]); + } + return unionType; +} diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 75d8f8c07..a1c55beeb 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -436,6 +436,7 @@ export type SchemaObject = { const?: unknown; default?: unknown; format?: string; + additionalProperties?: boolean | Record | SchemaObject | ReferenceObject; /** @deprecated in 3.1 (still valid for 3.0) */ nullable?: boolean; oneOf?: (SchemaObject | ReferenceObject)[]; diff --git a/packages/openapi-typescript/test/transform/schema-object/string.test.ts b/packages/openapi-typescript/test/transform/schema-object/string.test.ts index 3ff497833..4e1c87257 100644 --- a/packages/openapi-typescript/test/transform/schema-object/string.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/string.test.ts @@ -119,6 +119,17 @@ describe("transformSchemaObject > string", () => { want: "string | null", }, ], + [ + "enum + additionalProperties", + { + given: { + type: "string", + enum: ["A", "B", "C"], + additionalProperties: true, + }, + want: `("A" | "B" | "C") | (string & {})`, + }, + ], ]; for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) {