Skip to content

Commit 642846a

Browse files
committed
feat: configurable per schema
1 parent 3b5db0b commit 642846a

7 files changed

Lines changed: 209 additions & 34 deletions

File tree

packages/openapi-code-generator/src/core/input.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -418,14 +418,12 @@ export class Input {
418418
})
419419
}
420420

421-
const base: IRModelBase = {
422-
nullable: schemaObject.nullable || false,
423-
readOnly: schemaObject.readOnly || false,
424-
default: schemaObject.default,
425-
"x-internal-preprocess": schemaObject["x-internal-preprocess"],
426-
"x-enum-extensibility":
427-
schemaObject["x-enum-extensibility"] ?? self.config.enumExtensibility,
428-
}
421+
const base: IRModelBase = {
422+
nullable: schemaObject.nullable || false,
423+
readOnly: schemaObject.readOnly || false,
424+
default: schemaObject.default,
425+
"x-internal-preprocess": schemaObject["x-internal-preprocess"],
426+
}
429427

430428
switch (schemaObject.type) {
431429
case undefined: {
@@ -534,6 +532,11 @@ export class Input {
534532
maximum: schemaObject.maximum,
535533
minimum: schemaObject.minimum,
536534
multipleOf: schemaObject.multipleOf,
535+
536+
"x-enum-extensibility": enumValues.length
537+
? (schemaObject["x-enum-extensibility"] ??
538+
self.config.enumExtensibility)
539+
: undefined,
537540
} satisfies IRModelNumeric
538541
}
539542
case "string": {
@@ -552,6 +555,11 @@ export class Input {
552555
maxLength: schemaObject.maxLength,
553556
minLength: schemaObject.minLength,
554557
pattern: schemaObject.pattern,
558+
559+
"x-enum-extensibility": enumValues.length
560+
? (schemaObject["x-enum-extensibility"] ??
561+
self.config.enumExtensibility)
562+
: undefined,
555563
} satisfies IRModelString
556564
}
557565
case "boolean":

packages/openapi-code-generator/src/core/openapi-types-normalized.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export interface IRModelBase {
1111
readOnly: boolean /* false */
1212
default?: unknown | undefined
1313
"x-internal-preprocess"?: MaybeIRPreprocess | undefined
14-
"x-enum-extensibility": "open" | "closed"
1514
}
1615

1716
export type MaybeIRPreprocess = IRPreprocess | IRRef
@@ -38,6 +37,8 @@ export interface IRModelNumeric extends IRModelBase {
3837
maximum?: number | undefined
3938
minimum?: number | undefined
4039
multipleOf?: number | undefined
40+
41+
"x-enum-extensibility"?: "open" | "closed" | undefined
4142
}
4243

4344
export type IRModelStringFormat =
@@ -55,6 +56,8 @@ export interface IRModelString extends IRModelBase {
5556
maxLength?: number | undefined
5657
minLength?: number | undefined
5758
pattern?: string | undefined
59+
60+
"x-enum-extensibility"?: "open" | "closed" | undefined
5861
}
5962

6063
export interface IRModelBoolean extends IRModelBase {

packages/openapi-code-generator/src/test/input.test-utils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@ export async function unitTestInput(
5151
await TypespecLoader.create(),
5252
)
5353

54-
return {input: new Input(loader, {extractInlineSchemas: true}), file}
54+
return {
55+
input: new Input(loader, {
56+
extractInlineSchemas: true,
57+
enumExtensibility: "closed",
58+
}),
59+
file,
60+
}
5561
}
5662

5763
export async function createTestInputFromYamlString(
@@ -76,5 +82,8 @@ export async function createTestInputFromYamlString(
7682
new GenericLoader(new NodeFsAdaptor()),
7783
)
7884

79-
return new Input(loader, {extractInlineSchemas: true})
85+
return new Input(loader, {
86+
extractInlineSchemas: true,
87+
enumExtensibility: "closed",
88+
})
8089
}

packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.spec.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,11 @@ describe.each(testVersions)(
427427
)
428428
})
429429

430-
it("supports enum number", async () => {
430+
it("supports closed number enums", async () => {
431431
const {code, execute} = await getActualFromModel({
432432
...base,
433433
enum: [200, 301, 404],
434+
"x-enum-extensibility": "closed",
434435
})
435436

436437
expect(code).toMatchInlineSnapshot(
@@ -443,6 +444,21 @@ describe.each(testVersions)(
443444
await expect(execute(404)).resolves.toBe(404)
444445
})
445446

447+
it("supports open number enums", async () => {
448+
const {code, execute} = await getActualFromModel({
449+
...base,
450+
enum: [200, 301, 404],
451+
"x-enum-extensibility": "open",
452+
})
453+
454+
expect(code).toMatchInlineSnapshot(
455+
`"const x = joi.number().required()"`,
456+
)
457+
458+
await expect(execute(123)).resolves.toBe(123)
459+
await expect(execute(404)).resolves.toBe(404)
460+
})
461+
446462
it("supports minimum", async () => {
447463
const {code, execute} = await getActualFromModel({
448464
...base,
@@ -625,6 +641,45 @@ describe.each(testVersions)(
625641
await expect(execute(123)).rejects.toThrow('"value" must be a string')
626642
})
627643

644+
it("supports closed string enums", async () => {
645+
const enumValues = ["red", "blue", "green"]
646+
const {code, execute} = await getActualFromModel({
647+
...base,
648+
enum: enumValues,
649+
"x-enum-extensibility": "closed",
650+
})
651+
652+
expect(code).toMatchInlineSnapshot(
653+
`"const x = joi.string().valid("red", "blue", "green").required()"`,
654+
)
655+
656+
for (const value of enumValues) {
657+
await expect(execute(value)).resolves.toBe(value)
658+
}
659+
660+
await expect(execute("orange")).rejects.toThrow(
661+
'"value" must be one of [red, blue, green]',
662+
)
663+
})
664+
665+
it("supports open string enums", async () => {
666+
const enumValues = ["red", "blue", "green"]
667+
const {code, execute} = await getActualFromModel({
668+
...base,
669+
enum: enumValues,
670+
"x-enum-extensibility": "open",
671+
})
672+
673+
expect(code).toMatchInlineSnapshot(
674+
`"const x = joi.string().required()"`,
675+
)
676+
677+
for (const value of enumValues) {
678+
await expect(execute(value)).resolves.toBe(value)
679+
}
680+
await expect(execute("orange")).resolves.toBe("orange")
681+
})
682+
628683
it("supports minLength", async () => {
629684
const {code, execute} = await getActualFromModel({
630685
...base,

packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,15 @@ export class JoiBuilder extends AbstractSchemaBuilder<
200200
const result = [joi, "number()"].filter(isDefined).join(".")
201201

202202
if (model.enum) {
203-
return [result, `valid(${model.enum.join(", ")})`]
204-
.filter(isDefined)
205-
.join(".")
203+
if (model["x-enum-extensibility"] === "open") {
204+
return result
205+
}
206+
207+
if (model["x-enum-extensibility"] === "closed") {
208+
return [result, `valid(${model.enum.join(", ")})`]
209+
.filter(isDefined)
210+
.join(".")
211+
}
206212
}
207213

208214
return [
@@ -229,12 +235,18 @@ export class JoiBuilder extends AbstractSchemaBuilder<
229235
const result = [joi, "string()"].filter(isDefined).join(".")
230236

231237
if (model.enum) {
232-
return [
233-
result,
234-
`valid(${model.enum.map(quotedStringLiteral).join(", ")})`,
235-
]
236-
.filter(isDefined)
237-
.join(".")
238+
if (model["x-enum-extensibility"] === "open") {
239+
return result
240+
}
241+
242+
if (model["x-enum-extensibility"] === "closed") {
243+
return [
244+
result,
245+
`valid(${model.enum.map(quotedStringLiteral).join(", ")})`,
246+
]
247+
.filter(isDefined)
248+
.join(".")
249+
}
238250
}
239251

240252
return [

packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,11 @@ describe.each(testVersions)(
346346
)
347347
})
348348

349-
it("supports enum number", async () => {
349+
it("supports closed number enums", async () => {
350350
const {code, execute} = await getActualFromModel({
351351
...base,
352352
enum: [200, 301, 404],
353+
"x-enum-extensibility": "closed",
353354
})
354355

355356
expect(code).toMatchInlineSnapshot(
@@ -362,6 +363,26 @@ describe.each(testVersions)(
362363
await expect(execute(404)).resolves.toBe(404)
363364
})
364365

366+
it("supports open number enums", async () => {
367+
const {code, execute} = await getActualFromModel({
368+
...base,
369+
enum: [200, 301, 404],
370+
"x-enum-extensibility": "open",
371+
})
372+
373+
expect(code).toMatchInlineSnapshot(`
374+
"const x = z.union([
375+
z.literal(200),
376+
z.literal(301),
377+
z.literal(404),
378+
z.unknown().brand("unsupported enum value"),
379+
])"
380+
`)
381+
382+
await expect(execute(123)).resolves.toBe(123)
383+
await expect(execute(404)).resolves.toBe(404)
384+
})
385+
365386
it("supports minimum", async () => {
366387
const {code, execute} = await getActualFromModel({
367388
...base,
@@ -544,6 +565,48 @@ describe.each(testVersions)(
544565
)
545566
})
546567

568+
it("supports closed string enums", async () => {
569+
const enumValues = ["red", "blue", "green"]
570+
const {code, execute} = await getActualFromModel({
571+
...base,
572+
enum: enumValues,
573+
"x-enum-extensibility": "closed",
574+
})
575+
576+
expect(code).toMatchInlineSnapshot(
577+
`"const x = z.enum(["red", "blue", "green"])"`,
578+
)
579+
580+
for (const value of enumValues) {
581+
await expect(execute(value)).resolves.toBe(value)
582+
}
583+
584+
await expect(execute("orange")).rejects.toThrow(
585+
"Invalid enum value. Expected 'red' | 'blue' | 'green', received 'orange'",
586+
)
587+
})
588+
589+
it("supports open string enums", async () => {
590+
const enumValues = ["red", "blue", "green"]
591+
const {code, execute} = await getActualFromModel({
592+
...base,
593+
enum: enumValues,
594+
"x-enum-extensibility": "open",
595+
})
596+
597+
expect(code).toMatchInlineSnapshot(`
598+
"const x = z.union([
599+
z.enum(["red", "blue", "green"]),
600+
z.unknown().brand("unsupported enum value"),
601+
])"
602+
`)
603+
604+
for (const value of enumValues) {
605+
await expect(execute(value)).resolves.toBe(value)
606+
}
607+
await expect(execute("orange")).resolves.toBe("orange")
608+
})
609+
547610
it("supports nullable string using allOf", async () => {
548611
const {code, execute} = await getActualFromModel({
549612
type: "object",

packages/openapi-code-generator/src/typescript/common/schema-builders/zod-schema-builder.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,29 @@ export class ZodBuilder extends AbstractSchemaBuilder<
200200
protected number(model: IRModelNumeric) {
201201
if (model.enum) {
202202
// TODO: replace with enum after https://github.com/colinhacks/zod/issues/2686
203-
return [
204-
this.union([
205-
...model.enum.map((it) => [zod, `literal(${it})`].join(".")),
206-
`z.unknown().brand('unsupported enum value')`,
207-
]),
208-
]
209-
.filter(isDefined)
210-
.join(".")
203+
204+
if (model["x-enum-extensibility"] === "open") {
205+
return [
206+
this.union([
207+
...model.enum.map((it) => [zod, `literal(${it})`].join(".")),
208+
`z.unknown().brand('unsupported enum value')`,
209+
]),
210+
]
211+
.filter(isDefined)
212+
.join(".")
213+
}
214+
215+
if (model["x-enum-extensibility"] === "closed") {
216+
return [
217+
this.union(model.enum.map((it) => [zod, `literal(${it})`].join("."))),
218+
]
219+
.filter(isDefined)
220+
.join(".")
221+
}
222+
223+
throw new Error(
224+
`enum specified, but x-enum-extensibility is '${model["x-enum-extensibility"]}'`,
225+
)
211226
}
212227

213228
return [
@@ -233,10 +248,20 @@ export class ZodBuilder extends AbstractSchemaBuilder<
233248

234249
protected string(model: IRModelString) {
235250
if (model.enum) {
236-
return this.union([
237-
this.stringEnum(model),
238-
`z.unknown().brand('unsupported enum value')`,
239-
])
251+
if (model["x-enum-extensibility"] === "open") {
252+
return this.union([
253+
this.stringEnum(model),
254+
`z.unknown().brand('unsupported enum value')`,
255+
])
256+
}
257+
258+
if (model["x-enum-extensibility"] === "closed") {
259+
return this.stringEnum(model)
260+
}
261+
262+
throw new Error(
263+
`enum specified, but x-enum-extensibility is '${model["x-enum-extensibility"]}'`,
264+
)
240265
}
241266

242267
return [

0 commit comments

Comments
 (0)