Skip to content

Commit 2a610c5

Browse files
authored
fix: intersected union schemas (#283)
`merge` is only available on `ZodObject` and so we must use `intersect` if a `ZodIntersection` or `ZodUnion` is involved. it'd probably be simpler to just always use `intersect` but that comes with drawbacks like being unable to `pick` or `omit` the resulting schema. solves #282
1 parent 6693e1d commit 2a610c5

File tree

4 files changed

+363
-3
lines changed

4 files changed

+363
-3
lines changed

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,17 @@ export abstract class AbstractSchemaBuilder<
250250
)
251251
}
252252

253+
// Note: for zod in particular it's desirable to use merge over intersection
254+
// where possible, as it returns a more malleable schema
253255
const isMergable = model.allOf
254256
.map((it) => this.input.schema(it))
255-
.every((it) => it.type === "object" && !it.additionalProperties)
257+
.every(
258+
(it) =>
259+
it.type === "object" &&
260+
!it.additionalProperties &&
261+
!it.anyOf.length &&
262+
!it.oneOf.length,
263+
)
256264

257265
const schemas = model.allOf.map((it) => this.fromModel(it, true))
258266

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

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import type {
99
} from "../../../core/openapi-types-normalized"
1010
import {testVersions} from "../../../test/input.test-utils"
1111
import type {SchemaBuilderConfig} from "./abstract-schema-builder"
12-
import {schemaBuilderTestHarness} from "./schema-builder.test-utils"
12+
import {
13+
irModelNumber,
14+
irModelObject,
15+
irModelString,
16+
schemaBuilderTestHarness,
17+
} from "./schema-builder.test-utils"
1318
import {staticSchemas} from "./zod-schema-builder"
1419

1520
describe.each(testVersions)(
@@ -1068,6 +1073,181 @@ describe.each(testVersions)(
10681073
})
10691074
})
10701075

1076+
describe("unions", () => {
1077+
it("can union a string and number", async () => {
1078+
const {code, execute} = await getActualFromModel(
1079+
irModelObject({
1080+
anyOf: [irModelString(), irModelNumber()],
1081+
}),
1082+
)
1083+
1084+
expect(code).toMatchInlineSnapshot(`
1085+
"const x = joi
1086+
.alternatives()
1087+
.try(joi.string().required(), joi.number().required())
1088+
.required()"
1089+
`)
1090+
1091+
await expect(execute("some string")).resolves.toEqual("some string")
1092+
await expect(execute(1234)).resolves.toEqual(1234)
1093+
await expect(execute(undefined)).rejects.toThrow('"value" is required')
1094+
})
1095+
1096+
it("can union an intersected object and string", async () => {
1097+
const {code, execute} = await getActualFromModel(
1098+
irModelObject({
1099+
anyOf: [
1100+
irModelString(),
1101+
irModelObject({
1102+
allOf: [
1103+
irModelObject({
1104+
properties: {foo: irModelString()},
1105+
required: ["foo"],
1106+
}),
1107+
irModelObject({
1108+
properties: {bar: irModelString()},
1109+
required: ["bar"],
1110+
}),
1111+
],
1112+
}),
1113+
],
1114+
}),
1115+
)
1116+
1117+
expect(code).toMatchInlineSnapshot(`
1118+
"const x = joi
1119+
.alternatives()
1120+
.try(
1121+
joi.string().required(),
1122+
joi
1123+
.object()
1124+
.keys({ foo: joi.string().required() })
1125+
.options({ stripUnknown: true })
1126+
.required()
1127+
.concat(
1128+
joi
1129+
.object()
1130+
.keys({ bar: joi.string().required() })
1131+
.options({ stripUnknown: true })
1132+
.required(),
1133+
)
1134+
.required(),
1135+
)
1136+
.required()"
1137+
`)
1138+
1139+
await expect(execute("some string")).resolves.toEqual("some string")
1140+
await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({
1141+
foo: "bla",
1142+
bar: "foobar",
1143+
})
1144+
await expect(execute({foo: "bla"})).rejects.toThrow('"bar" is required')
1145+
})
1146+
})
1147+
1148+
describe("intersections", () => {
1149+
it("can intersect objects", async () => {
1150+
const {code, execute} = await getActualFromModel(
1151+
irModelObject({
1152+
allOf: [
1153+
irModelObject({
1154+
properties: {foo: irModelString()},
1155+
required: ["foo"],
1156+
}),
1157+
irModelObject({
1158+
properties: {bar: irModelString()},
1159+
required: ["bar"],
1160+
}),
1161+
],
1162+
}),
1163+
)
1164+
1165+
expect(code).toMatchInlineSnapshot(`
1166+
"const x = joi
1167+
.object()
1168+
.keys({ foo: joi.string().required() })
1169+
.options({ stripUnknown: true })
1170+
.required()
1171+
.concat(
1172+
joi
1173+
.object()
1174+
.keys({ bar: joi.string().required() })
1175+
.options({ stripUnknown: true })
1176+
.required(),
1177+
)
1178+
.required()"
1179+
`)
1180+
1181+
await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({
1182+
foo: "bla",
1183+
bar: "foobar",
1184+
})
1185+
await expect(execute({foo: "bla"})).rejects.toThrow('"bar" is required')
1186+
})
1187+
1188+
// TODO: https://github.com/hapijs/joi/issues/3057
1189+
it.skip("can intersect unions", async () => {
1190+
const {code, execute} = await getActualFromModel(
1191+
irModelObject({
1192+
allOf: [
1193+
irModelObject({
1194+
oneOf: [
1195+
irModelObject({
1196+
properties: {foo: irModelString()},
1197+
required: ["foo"],
1198+
}),
1199+
irModelObject({
1200+
properties: {bar: irModelString()},
1201+
required: ["bar"],
1202+
}),
1203+
],
1204+
}),
1205+
irModelObject({
1206+
properties: {id: irModelString()},
1207+
required: ["id"],
1208+
}),
1209+
],
1210+
}),
1211+
)
1212+
1213+
expect(code).toMatchInlineSnapshot(`
1214+
"const x = joi
1215+
.alternatives()
1216+
.try(
1217+
joi
1218+
.object()
1219+
.keys({ foo: joi.string().required() })
1220+
.options({ stripUnknown: true })
1221+
.required(),
1222+
joi
1223+
.object()
1224+
.keys({ bar: joi.string().required() })
1225+
.options({ stripUnknown: true })
1226+
.required(),
1227+
)
1228+
.required()
1229+
.concat(
1230+
joi
1231+
.object()
1232+
.keys({ id: joi.string().required() })
1233+
.options({ stripUnknown: true })
1234+
.required(),
1235+
)
1236+
.required()"
1237+
`)
1238+
1239+
await expect(execute({id: "1234", foo: "bla"})).resolves.toEqual({
1240+
id: "1234",
1241+
foo: "bla",
1242+
})
1243+
await expect(execute({id: "1234", bar: "bla"})).resolves.toEqual({
1244+
id: "1234",
1245+
bar: "bla",
1246+
})
1247+
await expect(execute({foo: "bla"})).rejects.toThrow("Required")
1248+
})
1249+
})
1250+
10711251
describe("unspecified schemas when allowAny: true", () => {
10721252
const config: SchemaBuilderConfig = {allowAny: true}
10731253
const base: IRModelObject = {

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type {Input} from "../../../core/input"
22
import type {
33
IRModel,
4+
IRModelNumeric,
5+
IRModelObject,
6+
IRModelString,
47
MaybeIRModel,
58
} from "../../../core/openapi-types-normalized"
69
import {
@@ -94,3 +97,42 @@ export function schemaBuilderTestHarness(
9497
getActual,
9598
}
9699
}
100+
101+
export function irModelObject(
102+
partial: Partial<IRModelObject> = {},
103+
): IRModelObject {
104+
return {
105+
type: "object",
106+
allOf: [],
107+
anyOf: [],
108+
oneOf: [],
109+
properties: {},
110+
additionalProperties: undefined,
111+
required: [],
112+
nullable: false,
113+
readOnly: false,
114+
...partial,
115+
}
116+
}
117+
118+
export function irModelString(
119+
partial: Partial<IRModelString> = {},
120+
): IRModelString {
121+
return {
122+
type: "string",
123+
nullable: false,
124+
readOnly: false,
125+
...partial,
126+
}
127+
}
128+
129+
export function irModelNumber(
130+
partial: Partial<IRModelNumeric> = {},
131+
): IRModelNumeric {
132+
return {
133+
type: "number",
134+
nullable: false,
135+
readOnly: false,
136+
...partial,
137+
}
138+
}

0 commit comments

Comments
 (0)