Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 79 additions & 59 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -584,3 +591,16 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
function hasKey<K extends string>(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;
}
1 change: 1 addition & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export type SchemaObject = {
const?: unknown;
default?: unknown;
format?: string;
additionalProperties?: boolean | Record<string, never> | SchemaObject | ReferenceObject;
/** @deprecated in 3.1 (still valid for 3.0) */
nullable?: boolean;
oneOf?: (SchemaObject | ReferenceObject)[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading