Skip to content

Commit baeb496

Browse files
committed
Add type support for enums with additionalProperties to provide ts intellisense
1 parent 61c176c commit baeb496

3 files changed

Lines changed: 89 additions & 57 deletions

File tree

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

Lines changed: 77 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -92,72 +92,79 @@ export function transformSchemaObjectWithComposition(
9292
if (
9393
Array.isArray(schemaObject.enum) &&
9494
(!("type" in schemaObject) || schemaObject.type !== "object") &&
95-
!("properties" in schemaObject) &&
96-
!("additionalProperties" in schemaObject)
95+
!("properties" in schemaObject)
9796
) {
98-
// hoist enum to top level if string/number enum and option is enabled
99-
if (
100-
options.ctx.enum &&
101-
schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null)
102-
) {
103-
let enumName = parseRef(options.path ?? "").pointer.join("/");
104-
// allow #/components/schemas to have simpler names
105-
enumName = enumName.replace("components/schemas", "");
106-
const metadata = schemaObject.enum.map((_, i) => ({
107-
name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i],
108-
description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i],
109-
}));
110-
111-
// enums can contain null values, but dont want to output them
112-
let hasNull = false;
113-
const validSchemaEnums = schemaObject.enum.filter((enumValue) => {
114-
if (enumValue === null) {
115-
hasNull = true;
116-
return false;
97+
const hasAdditionalProperties = "additionalProperties" in schemaObject && !!schemaObject.additionalProperties;
98+
99+
if (!hasAdditionalProperties || (schemaObject.type === "string" && hasAdditionalProperties)) {
100+
// hoist enum to top level if string/number enum and option is enabled
101+
if (
102+
options.ctx.enum &&
103+
schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null)
104+
) {
105+
let enumName = parseRef(options.path ?? "").pointer.join("/");
106+
// allow #/components/schemas to have simpler names
107+
enumName = enumName.replace("components/schemas", "");
108+
const metadata = schemaObject.enum.map((_, i) => ({
109+
name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i],
110+
description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i],
111+
}));
112+
113+
// enums can contain null values, but dont want to output them
114+
let hasNull = false;
115+
const validSchemaEnums = schemaObject.enum.filter((enumValue) => {
116+
if (enumValue === null) {
117+
hasNull = true;
118+
return false;
119+
}
120+
121+
return true;
122+
});
123+
const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, {
124+
shouldCache: options.ctx.dedupeEnums,
125+
export: true,
126+
// readonly: TS enum do not support the readonly modifier
127+
});
128+
if (!options.ctx.injectFooter.includes(enumType)) {
129+
options.ctx.injectFooter.push(enumType);
117130
}
131+
const ref = ts.factory.createTypeReferenceNode(enumType.name);
132+
133+
const finalType: ts.TypeNode = hasNull ? tsUnion([ref, NULL]) : ref;
118134

119-
return true;
120-
});
121-
const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, {
122-
shouldCache: options.ctx.dedupeEnums,
123-
export: true,
124-
// readonly: TS enum do not support the readonly modifier
125-
});
126-
if (!options.ctx.injectFooter.includes(enumType)) {
127-
options.ctx.injectFooter.push(enumType);
135+
return applyAdditionalPropertiesToEnum(hasAdditionalProperties, finalType, schemaObject);
128136
}
129-
const ref = ts.factory.createTypeReferenceNode(enumType.name);
130-
return hasNull ? tsUnion([ref, NULL]) : ref;
131-
}
132-
const enumType = schemaObject.enum.map(tsLiteral);
133-
if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) {
134-
enumType.push(NULL);
135-
}
136137

137-
const unionType = tsUnion(enumType);
138+
const enumType = schemaObject.enum.map(tsLiteral);
139+
if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) {
140+
enumType.push(NULL);
141+
}
138142

139-
// hoist array with valid enum values to top level if string/number enum and option is enabled
140-
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
141-
let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/");
142-
// allow #/components/schemas to have simpler names
143-
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
144-
enumValuesVariableName = `${enumValuesVariableName}Values`;
143+
const unionType = applyAdditionalPropertiesToEnum(hasAdditionalProperties, tsUnion(enumType), schemaObject);
144+
145+
// hoist array with valid enum values to top level if string/number enum and option is enabled
146+
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
147+
let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/");
148+
// allow #/components/schemas to have simpler names
149+
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
150+
enumValuesVariableName = `${enumValuesVariableName}Values`;
151+
152+
const enumValuesArray = tsArrayLiteralExpression(
153+
enumValuesVariableName,
154+
oapiRef(options.path ?? ""),
155+
schemaObject.enum as (string | number)[],
156+
{
157+
export: true,
158+
readonly: true,
159+
injectFooter: options.ctx.injectFooter,
160+
},
161+
);
145162

146-
const enumValuesArray = tsArrayLiteralExpression(
147-
enumValuesVariableName,
148-
oapiRef(options.path ?? ""),
149-
schemaObject.enum as (string | number)[],
150-
{
151-
export: true,
152-
readonly: true,
153-
injectFooter: options.ctx.injectFooter,
154-
},
155-
);
163+
options.ctx.injectFooter.push(enumValuesArray);
164+
}
156165

157-
options.ctx.injectFooter.push(enumValuesArray);
166+
return unionType;
158167
}
159-
160-
return unionType;
161168
}
162169

163170
/**
@@ -584,3 +591,16 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
584591
function hasKey<K extends string>(possibleObject: unknown, key: K): possibleObject is { [key in K]: unknown } {
585592
return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject;
586593
}
594+
595+
function applyAdditionalPropertiesToEnum(
596+
hasAdditionalProperties: boolean,
597+
unionType: ts.TypeNode,
598+
schemaObject: SchemaObject,
599+
) {
600+
// If additionalProperties is true, add (string & {}) to the union
601+
if (hasAdditionalProperties && schemaObject.type === "string") {
602+
const stringAndEmptyObject = tsIntersection([STRING, ts.factory.createTypeLiteralNode([])]);
603+
return tsUnion([unionType, stringAndEmptyObject]);
604+
}
605+
return unionType;
606+
}

packages/openapi-typescript/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ export type SchemaObject = {
436436
const?: unknown;
437437
default?: unknown;
438438
format?: string;
439+
additionalProperties?: boolean | Record<string, never> | SchemaObject | ReferenceObject;
439440
/** @deprecated in 3.1 (still valid for 3.0) */
440441
nullable?: boolean;
441442
oneOf?: (SchemaObject | ReferenceObject)[];

packages/openapi-typescript/test/transform/schema-object/string.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ describe("transformSchemaObject > string", () => {
119119
want: "string | null",
120120
},
121121
],
122+
[
123+
"enum + additionalProperties",
124+
{
125+
given: {
126+
type: "string",
127+
enum: ["A", "B", "C"],
128+
additionalProperties: true,
129+
},
130+
want: `("A" | "B" | "C") | (string & {})`,
131+
},
132+
],
122133
];
123134

124135
for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) {

0 commit comments

Comments
 (0)