diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.integration.spec.ts similarity index 100% rename from packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.spec.ts rename to packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.integration.spec.ts diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.unit.spec.ts new file mode 100644 index 000000000..45dbba5dc --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.unit.spec.ts @@ -0,0 +1,1672 @@ +import vm from "node:vm" +import {describe, expect, it} from "@jest/globals" +import type { + SchemaArray, + SchemaBoolean, + SchemaNumber, + SchemaObject, + SchemaString, +} from "../../../core/openapi-types" +import {testVersions} from "../../../test/input.test-utils" +import type {SchemaBuilderConfig} from "./abstract-schema-builder" +import { + schemaBuilderTestHarness, + schemaNumber, + schemaObject, + schemaString, +} from "./schema-builder.test-utils" + +describe.each( + testVersions, +)("%s - typescript/common/schema-builders/joi-schema-builder", (version) => { + const executeParseSchema = async (code: string) => { + return vm.runInNewContext( + code, + // Note: joi relies on `pattern instanceof RegExp` which makes using regex literals + // problematic since the RegExp that joi sees isn't the same as the RegExp inside + // the context. + // I think it should be possible move loading of joi into the context, such that + // it gets the contexts global RegExp correctly, but I can't figure it out right now. + + {joi: require("joi"), RegExp}, + ) + } + + const {getActual, getActualFromModel} = schemaBuilderTestHarness( + "joi", + version, + executeParseSchema, + ) + + it("supports the SimpleObject", async () => { + const {code, schemas} = await getActual("components/schemas/SimpleObject") + + expect(code).toMatchInlineSnapshot(` + "import { s_SimpleObject } from "./unit-test.schemas" + + const x = s_SimpleObject.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_SimpleObject = joi + .object() + .keys({ + str: joi.string().required(), + num: joi.number().required(), + date: joi.string().required(), + datetime: joi.string().isoDate().required(), + optional_str: joi.string(), + required_nullable: joi.string().allow(null).required(), + $ref: joi.string(), + }) + .options({ stripUnknown: true }) + .required() + .id("s_SimpleObject")" + `) + }) + + it("supports the ObjectWithComplexProperties", async () => { + const {code, schemas} = await getActual( + "components/schemas/ObjectWithComplexProperties", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_ObjectWithComplexProperties } from "./unit-test.schemas" + + const x = s_ObjectWithComplexProperties.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_AString = joi.string().required().id("s_AString") + + export const s_OneOf = joi + .alternatives() + .try( + joi + .object() + .keys({ strs: joi.array().items(joi.string()).required() }) + .options({ stripUnknown: true }) + .required(), + joi.array().items(joi.string()).required(), + joi.string().required(), + ) + .required() + .id("s_OneOf") + + export const s_ObjectWithComplexProperties = joi + .object() + .keys({ + requiredOneOf: joi + .alternatives() + .try(joi.string().required(), joi.number().required()) + .required(), + requiredOneOfRef: s_OneOf.required(), + optionalOneOf: joi + .alternatives() + .try(joi.string().required(), joi.number().required()), + optionalOneOfRef: s_OneOf, + nullableSingularOneOf: joi + .boolean() + .truthy(1, "1") + .falsy(0, "0") + .required() + .allow(null), + nullableSingularOneOfRef: s_AString.required().allow(null), + }) + .options({ stripUnknown: true }) + .required() + .id("s_ObjectWithComplexProperties")" + `) + }) + + it("supports unions / oneOf", async () => { + const {code, schemas} = await getActual("components/schemas/OneOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_OneOf } from "./unit-test.schemas" + + const x = s_OneOf.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_OneOf = joi + .alternatives() + .try( + joi + .object() + .keys({ strs: joi.array().items(joi.string()).required() }) + .options({ stripUnknown: true }) + .required(), + joi.array().items(joi.string()).required(), + joi.string().required(), + ) + .required() + .id("s_OneOf")" + `) + }) + + it("supports unions / anyOf", async () => { + const {code, schemas} = await getActual("components/schemas/AnyOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_AnyOf } from "./unit-test.schemas" + + const x = s_AnyOf.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_AnyOf = joi + .alternatives() + .try(joi.number().required(), joi.string().required()) + .required() + .id("s_AnyOf")" + `) + }) + + it("supports allOf", async () => { + const {code, schemas} = await getActual("components/schemas/AllOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_AllOf } from "./unit-test.schemas" + + const x = s_AllOf.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_Base = joi + .object() + .keys({ name: joi.string().required(), breed: joi.string() }) + .options({ stripUnknown: true }) + .required() + .id("s_Base") + + export const s_AllOf = s_Base + .required() + .concat( + joi + .object() + .keys({ id: joi.number().required() }) + .options({ stripUnknown: true }) + .required(), + ) + .required() + .id("s_AllOf")" + `) + }) + + it("supports recursion", async () => { + const {code, schemas} = await getActual("components/schemas/Recursive") + + expect(code).toMatchInlineSnapshot(` + "import { s_Recursive } from "./unit-test.schemas" + + const x = joi.link("#s_Recursive.required()")" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_Recursive = joi + .object() + .keys({ child: joi.link("#s_Recursive") }) + .options({ stripUnknown: true }) + .required() + .id("s_Recursive")" + `) + }) + + it("orders schemas such that dependencies are defined first", async () => { + const {code, schemas} = await getActual("components/schemas/Ordering") + + expect(code).toMatchInlineSnapshot(` + "import { s_Ordering } from "./unit-test.schemas" + + const x = s_Ordering.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_AOrdering = joi + .object() + .keys({ name: joi.string() }) + .options({ stripUnknown: true }) + .required() + .id("s_AOrdering") + + export const s_ZOrdering = joi + .object() + .keys({ name: joi.string(), dependency1: s_AOrdering.required() }) + .options({ stripUnknown: true }) + .required() + .id("s_ZOrdering") + + export const s_Ordering = joi + .object() + .keys({ + dependency1: s_ZOrdering.required(), + dependency2: s_AOrdering.required(), + }) + .options({ stripUnknown: true }) + .required() + .id("s_Ordering")" + `) + }) + + it("supports string and numeric enums", async () => { + const {code, schemas} = await getActual("components/schemas/Enums") + + expect(code).toMatchInlineSnapshot(` + "import { s_Enums } from "./unit-test.schemas" + + const x = s_Enums.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_Enums = joi + .object() + .keys({ + str: joi.string().valid("foo", "bar").allow(null), + num: joi.number().valid(10, 20).allow(null), + }) + .options({ stripUnknown: true }) + .required() + .id("s_Enums")" + `) + }) + + describe("additionalProperties", () => { + it("handles additionalProperties set to true", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesBool", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesBool } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesBool.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_AdditionalPropertiesBool = joi + .object() + .pattern(joi.any(), joi.any()) + .required() + .id("s_AdditionalPropertiesBool")" + `) + }) + + it("handles additionalProperties set to {}", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesUnknownEmptySchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesUnknownEmptySchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesUnknownEmptySchema.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_AdditionalPropertiesUnknownEmptySchema = joi + .object() + .pattern(joi.any(), joi.any()) + .required() + .id("s_AdditionalPropertiesUnknownEmptySchema")" + `) + }) + + it("handles additionalProperties set to {type: 'object'}", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesUnknownEmptyObjectSchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesUnknownEmptyObjectSchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesUnknownEmptyObjectSchema.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_AdditionalPropertiesUnknownEmptyObjectSchema = joi + .object() + .pattern(joi.any(), joi.object().pattern(joi.any(), joi.any()).required()) + .required() + .id("s_AdditionalPropertiesUnknownEmptyObjectSchema")" + `) + }) + + it("handles additionalProperties specifying a schema", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesSchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesSchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesSchema.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + export const s_NamedNullableStringEnum = joi + .string() + .valid("", "one", "two", "three") + .allow(null) + .required() + .id("s_NamedNullableStringEnum") + + export const s_AdditionalPropertiesSchema = joi + .object() + .pattern(joi.any(), s_NamedNullableStringEnum.required()) + .required() + .id("s_AdditionalPropertiesSchema")" + `) + }) + + it("handles additionalProperties in conjunction with properties", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesMixed", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesMixed } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesMixed.required()" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import joi from "joi" + + /** + * Recursively re-distribute the type union/intersection such that joi can support it + * Eg: from A & (B | C) to (A & B) | (A & C) + * https://github.com/hapijs/joi/issues/3057 + */ + export function joiIntersect( + left: joi.Schema, + right: joi.Schema, + ): joi.ObjectSchema | joi.AlternativesSchema { + if (isAlternativesSchema(left)) { + return joi + .alternatives() + .match(left.$_getFlag("match") ?? "any") + .try(...getAlternatives(left).map((it) => joiIntersect(it, right))) + } + + if (isAlternativesSchema(right)) { + return joi + .alternatives() + .match(right.$_getFlag("match") ?? "any") + .try(...getAlternatives(right).map((it) => joiIntersect(left, it))) + } + + if (!isObjectSchema(left) || !isObjectSchema(right)) { + throw new Error( + "only objects, or unions of objects can be intersected together.", + ) + } + + return (left as joi.ObjectSchema).concat(right) + + function isAlternativesSchema(it: joi.Schema): it is joi.AlternativesSchema { + return it.type === "alternatives" + } + + function isObjectSchema(it: joi.Schema): it is joi.ObjectSchema { + return it.type === "object" + } + + function getAlternatives(it: joi.AlternativesSchema): joi.Schema[] { + const terms = it.$_terms + const matches = terms.matches + + if (!Array.isArray(matches)) { + throw new Error("$_terms.matches is not an array of schemas") + } + + return matches.map((it) => it.schema) + } + } + export const s_AdditionalPropertiesMixed = joiIntersect( + joi + .object() + .keys({ id: joi.string(), name: joi.string() }) + .options({ stripUnknown: true }), + joi.object().pattern(joi.any(), joi.any()).required(), + ) + .required() + .id("s_AdditionalPropertiesMixed")" + `) + }) + }) + + describe("numbers", () => { + const base: SchemaNumber = { + nullable: false, + readOnly: false, + type: "number", + } + + it("supports plain number", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + }) + + expect(code).toMatchInlineSnapshot('"const x = joi.number().required()"') + await expect(execute(123)).resolves.toBe(123) + await expect(execute("not a number 123")).rejects.toThrow( + '"value" must be a number', + ) + }) + + it("supports closed number enums", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: [200, 301, 404], + "x-enum-extensibility": "closed", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().valid(200, 301, 404).required()"', + ) + + await expect(execute(123)).rejects.toThrow( + '"value" must be one of [200, 301, 404]', + ) + await expect(execute(404)).resolves.toBe(404) + }) + + it("supports open number enums", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: [200, 301, 404], + "x-enum-extensibility": "open", + }) + + expect(code).toMatchInlineSnapshot(`"const x = joi.number().required()"`) + + await expect(execute(123)).resolves.toBe(123) + await expect(execute(404)).resolves.toBe(404) + await expect(execute("not a number")).rejects.toThrow( + '"value" must be a number', + ) + }) + + it("supports minimum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 10, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().min(10).required()"', + ) + + await expect(execute(5)).rejects.toThrow( + '"value" must be greater than or equal to 10', + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports maximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maximum: 16, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().max(16).required()"', + ) + + await expect(execute(25)).rejects.toThrow( + '"value" must be less than or equal to 16', + ) + await expect(execute(8)).resolves.toBe(8) + }) + + it("supports minimum/maximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 10, + maximum: 24, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().min(10).max(24).required()"', + ) + + await expect(execute(5)).rejects.toThrow( + '"value" must be greater than or equal to 10', + ) + await expect(execute(25)).rejects.toThrow( + '"value" must be less than or equal to 24', + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports exclusiveMinimum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + exclusiveMinimum: 4, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().greater(4).required()"', + ) + + await expect(execute(4)).rejects.toThrow('"value" must be greater than 4') + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports exclusiveMaximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + exclusiveMaximum: 4, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().less(4).required()"', + ) + + await expect(execute(4)).rejects.toThrow('"value" must be less than 4') + await expect(execute(3)).resolves.toBe(3) + }) + + it("supports multipleOf", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + multipleOf: 4, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().multiple(4).required()"', + ) + + await expect(execute(11)).rejects.toThrow( + '"value" must be a multiple of 4', + ) + await expect(execute(16)).resolves.toBe(16) + }) + + it("supports combining multipleOf and min/max", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + multipleOf: 4, + minimum: 10, + maximum: 20, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().multiple(4).min(10).max(20).required()"', + ) + + await expect(execute(11)).rejects.toThrow( + '"value" must be a multiple of 4', + ) + await expect(execute(8)).rejects.toThrow( + '"value" must be greater than or equal to 10', + ) + await expect(execute(24)).rejects.toThrow( + '"value" must be less than or equal to 20', + ) + await expect(execute(16)).resolves.toBe(16) + }) + + it("supports 0", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 0, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.number().min(0).required()"', + ) + + await expect(execute(-1)).rejects.toThrow( + '"value" must be greater than or equal to 0', + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 42, + }) + + expect(code).toMatchInlineSnapshot(`"const x = joi.number().default(42)"`) + + await expect(execute(undefined)).resolves.toBe(42) + }) + + it("supports default values of 0", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 0, + }) + + expect(code).toMatchInlineSnapshot(`"const x = joi.number().default(0)"`) + + await expect(execute(undefined)).resolves.toBe(0) + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.number().allow(null).default(null)"`, + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + }) + + describe("strings", () => { + const base: SchemaString = { + nullable: false, + readOnly: false, + type: "string", + } + + it("supports plain string", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot('"const x = joi.string().required()"') + + await expect(execute("a string")).resolves.toBe("a string") + await expect(execute(123)).rejects.toThrow('"value" must be a string') + }) + + it("supports closed string enums", async () => { + const enumValues = ["red", "blue", "green"] + const {code, execute} = await getActualFromModel({ + ...base, + enum: enumValues, + "x-enum-extensibility": "closed", + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.string().valid("red", "blue", "green").required()"`, + ) + + for (const value of enumValues) { + await expect(execute(value)).resolves.toBe(value) + } + + await expect(execute("orange")).rejects.toThrow( + '"value" must be one of [red, blue, green]', + ) + }) + + it("supports open string enums", async () => { + const enumValues = ["red", "blue", "green"] + const {code, execute} = await getActualFromModel({ + ...base, + enum: enumValues, + "x-enum-extensibility": "open", + }) + + expect(code).toMatchInlineSnapshot(`"const x = joi.string().required()"`) + + for (const value of enumValues) { + await expect(execute(value)).resolves.toBe(value) + } + await expect(execute("orange")).resolves.toBe("orange") + await expect(execute(404)).rejects.toThrow('"value" must be a string') + }) + + it("supports minLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minLength: 8, + }) + expect(code).toMatchInlineSnapshot( + '"const x = joi.string().min(8).required()"', + ) + + await expect(execute("12345678")).resolves.toBe("12345678") + await expect(execute("1234567")).rejects.toThrow( + '"value" length must be at least 8 characters long', + ) + }) + + it("supports maxLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maxLength: 8, + }) + expect(code).toMatchInlineSnapshot( + '"const x = joi.string().max(8).required()"', + ) + + await expect(execute("12345678")).resolves.toBe("12345678") + await expect(execute("123456789")).rejects.toThrow( + '"value" length must be less than or equal to 8 characters long', + ) + }) + + it("supports pattern", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + pattern: '"pk/\\d+"', + }) + expect(code).toMatchInlineSnapshot( + '"const x = joi.string().pattern(new RegExp(\'"pk/\\\\d+"\')).required()"', + ) + + await expect(execute('"pk/1234"')).resolves.toBe('"pk/1234"') + await expect(execute("pk/abcd")).rejects.toThrow( + '"value" with value "pk/abcd" fails to match the required pattern: /"pk\\/\\d+"/', + ) + }) + + it("supports pattern with minLength / maxLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + pattern: "pk-\\d+", + minLength: 5, + maxLength: 8, + }) + expect(code).toMatchInlineSnapshot( + '"const x = joi.string().min(5).max(8).pattern(new RegExp("pk-\\\\d+")).required()"', + ) + + await expect(execute("pk-12")).resolves.toBe("pk-12") + await expect(execute("pk-ab")).rejects.toThrow( + '"value" with value "pk-ab" fails to match the required pattern: /pk-\\d+/', + ) + await expect(execute("pk-1")).rejects.toThrow( + '"value" length must be at least 5 characters long', + ) + await expect(execute("pk-123456")).rejects.toThrow( + '"value" length must be less than or equal to 8 characters long', + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: "example", + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.string().default("example")"`, + ) + + await expect(execute(undefined)).resolves.toBe("example") + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.string().allow(null).default(null)"`, + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + + it("supports empty string default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: "", + }) + + expect(code).toMatchInlineSnapshot(`"const x = joi.string().default("")"`) + + await expect(execute(undefined)).resolves.toBe("") + }) + + it("supports default values with quotes", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 'this is an "example", it\'s got lots of `quotes`', + }) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .string() + .default('this is an "example", it\\'s got lots of \`quotes\`')" + `) + + await expect(execute(undefined)).resolves.toBe( + 'this is an "example", it\'s got lots of `quotes`', + ) + }) + + it("coerces incorrectly typed default values to be strings", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: false, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.string().default("false")"`, + ) + + await expect(execute(undefined)).resolves.toBe("false") + }) + + describe("formats", () => { + it("supports email", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + format: "email", + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.string().email().required()"`, + ) + + await expect(execute("test@example.com")).resolves.toBe( + "test@example.com", + ) + await expect(execute("some string")).rejects.toThrow( + '"value" must be a valid email', + ) + }) + it("supports date-time", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + format: "date-time", + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.string().isoDate().required()"`, + ) + + await expect(execute("2024-05-25T08:20:00.000Z")).resolves.toBe( + "2024-05-25T08:20:00.000Z", + ) + await expect(execute("some string")).rejects.toThrow( + '"value" must be in iso format', + ) + }) + }) + }) + + describe("booleans", () => { + const base: SchemaBoolean = { + nullable: false, + readOnly: false, + type: "boolean", + } + + it("supports plain boolean", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.boolean().truthy(1, "1").falsy(0, "0").required()"`, + ) + + await expect(execute(true)).resolves.toBe(true) + await expect(execute(false)).resolves.toBe(false) + + await expect(execute("false")).resolves.toBe(false) + await expect(execute("true")).resolves.toBe(true) + + await expect(execute(0)).resolves.toBe(false) + await expect(execute("0")).resolves.toBe(false) + await expect(execute(1)).resolves.toBe(true) + await expect(execute("1")).resolves.toBe(true) + + await expect(execute(12)).rejects.toThrow('"value" must be a boolean') + await expect(execute("yup")).rejects.toThrow('"value" must be a boolean') + await expect(execute([])).rejects.toThrow('"value" must be a boolean') + await expect(execute({})).rejects.toThrow('"value" must be a boolean') + }) + + it("supports default values of false", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: false, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.boolean().truthy(1, "1").falsy(0, "0").default(false)"`, + ) + + await expect(execute(undefined)).resolves.toBe(false) + }) + + it("supports default values of true", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: true, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.boolean().truthy(1, "1").falsy(0, "0").default(true)"`, + ) + + await expect(execute(undefined)).resolves.toBe(true) + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.boolean().truthy(1, "1").falsy(0, "0").allow(null).default(null)"`, + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + + it("support enum of 'true'", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: ["true"], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.boolean().truthy(1, "1").valid(true).required()"`, + ) + + await expect(execute(true)).resolves.toBe(true) + await expect(execute(false)).rejects.toThrow('"value" must be [true]') + }) + + it("support enum of 'false'", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: ["false"], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.boolean().falsy(0, "0").valid(false).required()"`, + ) + + await expect(execute(false)).resolves.toBe(false) + await expect(execute(true)).rejects.toThrow('"value" must be [false]') + }) + }) + + describe("arrays", () => { + const base: SchemaArray = { + nullable: false, + readOnly: false, + type: "array", + items: {nullable: false, readOnly: false, type: "string"}, + uniqueItems: false, + } + + it("supports arrays", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.string()).required()"`, + ) + + await expect(execute([])).resolves.toStrictEqual([]) + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute([1, 2])).rejects.toThrow('"[0]" must be a string') + }) + + it("supports uniqueItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + uniqueItems: true, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.string()).unique().required()"`, + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo", "foo"])).rejects.toThrow( + '"[1]" contains a duplicate value', + ) + }) + + it("supports minItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minItems: 2, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.string()).min(2).required()"`, + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo"])).rejects.toThrow( + '"value" must contain at least 2 items', + ) + }) + + it("supports maxItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maxItems: 2, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.string()).max(2).required()"`, + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo", "bar", "foobar"])).rejects.toThrow( + '"value" must contain less than or equal to 2 items', + ) + }) + + it("supports minItems / maxItems / uniqueItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + items: schemaNumber(), + minItems: 1, + maxItems: 3, + uniqueItems: true, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.number()).unique().min(1).max(3).required()"`, + ) + + await expect(execute([1, 2])).resolves.toStrictEqual([1, 2]) + await expect(execute([])).rejects.toThrow( + '"value" must contain at least 1 items', + ) + await expect(execute([1, 2, 3, 4])).rejects.toThrow( + '"value" must contain less than or equal to 3 items', + ) + await expect(execute([3, 3, 3])).rejects.toThrow( + '"[1]" contains a duplicate value', + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: ["example"], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.string()).default(["example"])"`, + ) + + await expect(execute(undefined)).resolves.toStrictEqual(["example"]) + }) + + it("supports empty array default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: [], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.array().items(joi.string()).default([])"`, + ) + + await expect(execute(undefined)).resolves.toStrictEqual([]) + }) + }) + + describe("objects", () => { + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: false, + required: [], + nullable: false, + readOnly: false, + } + + it("supports general objects", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + properties: { + name: {type: "string", nullable: false, readOnly: false}, + age: {type: "number", nullable: false, readOnly: false}, + }, + required: ["name", "age"], + }) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .object() + .keys({ name: joi.string().required(), age: joi.number().required() }) + .options({ stripUnknown: true }) + .required()" + `) + + await expect(execute({name: "John", age: 35})).resolves.toEqual({ + name: "John", + age: 35, + }) + + await expect(execute({age: 35})).rejects.toThrow('"name" is required') + }) + + it("supports record objects", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + additionalProperties: { + type: "number", + nullable: false, + readOnly: false, + }, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = joi.object().pattern(joi.any(), joi.number().required()).required()"', + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).rejects.toThrow( + '"key" must be a number', + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + properties: { + name: {type: "string", nullable: false, readOnly: false}, + age: {type: "number", nullable: false, readOnly: false}, + }, + required: ["name", "age"], + default: {name: "example", age: 22}, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .object() + .keys({ name: joi.string().required(), age: joi.number().required() }) + .options({ stripUnknown: true }) + .default({ name: "example", age: 22 })" + `) + + // HACK: If we do a toStrictEqual, we get 'Received: serializes to the same string' + // presumably due to the use of global that differs inside the VM to outside. + // Passing through `Object` doesn't fix it, so just use toEqual ¯\_(ツ)_/¯ + await expect(execute(undefined)).resolves.toEqual({ + name: "example", + age: 22, + }) + }) + }) + + describe("unions", () => { + it("can union a string and number", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + anyOf: [schemaString(), schemaNumber()], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .alternatives() + .try(joi.string().required(), joi.number().required()) + .required()" + `) + + await expect(execute("some string")).resolves.toEqual("some string") + await expect(execute(1234)).resolves.toEqual(1234) + await expect(execute(undefined)).rejects.toThrow('"value" is required') + }) + + it("can union an intersected object and string", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + anyOf: [ + schemaString(), + schemaObject({ + allOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .alternatives() + .try( + joi.string().required(), + joi + .object() + .keys({ foo: joi.string().required() }) + .options({ stripUnknown: true }) + .required() + .concat( + joi + .object() + .keys({ bar: joi.string().required() }) + .options({ stripUnknown: true }) + .required(), + ) + .required(), + ) + .required()" + `) + + await expect(execute("some string")).resolves.toEqual("some string") + await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({ + foo: "bla", + bar: "foobar", + }) + await expect(execute({foo: "bla"})).rejects.toThrow('"bar" is required') + }) + }) + + describe("intersections", () => { + it("can intersect objects", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + allOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .object() + .keys({ foo: joi.string().required() }) + .options({ stripUnknown: true }) + .required() + .concat( + joi + .object() + .keys({ bar: joi.string().required() }) + .options({ stripUnknown: true }) + .required(), + ) + .required()" + `) + + await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({ + foo: "bla", + bar: "foobar", + }) + await expect(execute({foo: "bla"})).rejects.toThrow('"bar" is required') + }) + + it("can intersect unions", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + allOf: [ + schemaObject({ + oneOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + schemaObject({ + properties: {id: schemaString()}, + required: ["id"], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "/** + * Recursively re-distribute the type union/intersection such that joi can support it + * Eg: from A & (B | C) to (A & B) | (A & C) + * https://github.com/hapijs/joi/issues/3057 + */ + function joiIntersect( + left: joi.Schema, + right: joi.Schema, + ): joi.ObjectSchema | joi.AlternativesSchema { + if (isAlternativesSchema(left)) { + return joi + .alternatives() + .match(left.$_getFlag("match") ?? "any") + .try(...getAlternatives(left).map((it) => joiIntersect(it, right))) + } + + if (isAlternativesSchema(right)) { + return joi + .alternatives() + .match(right.$_getFlag("match") ?? "any") + .try(...getAlternatives(right).map((it) => joiIntersect(left, it))) + } + + if (!isObjectSchema(left) || !isObjectSchema(right)) { + throw new Error( + "only objects, or unions of objects can be intersected together.", + ) + } + + return (left as joi.ObjectSchema).concat(right) + + function isAlternativesSchema(it: joi.Schema): it is joi.AlternativesSchema { + return it.type === "alternatives" + } + + function isObjectSchema(it: joi.Schema): it is joi.ObjectSchema { + return it.type === "object" + } + + function getAlternatives(it: joi.AlternativesSchema): joi.Schema[] { + const terms = it.$_terms + const matches = terms.matches + + if (!Array.isArray(matches)) { + throw new Error("$_terms.matches is not an array of schemas") + } + + return matches.map((it) => it.schema) + } + } + const x = joiIntersect( + joi + .alternatives() + .try( + joi + .object() + .keys({ foo: joi.string().required() }) + .options({ stripUnknown: true }) + .required(), + joi + .object() + .keys({ bar: joi.string().required() }) + .options({ stripUnknown: true }) + .required(), + ) + .required(), + joi + .object() + .keys({ id: joi.string().required() }) + .options({ stripUnknown: true }) + .required(), + ).required()" + `) + + await expect(execute({id: "1234", foo: "bla"})).resolves.toEqual({ + id: "1234", + foo: "bla", + }) + await expect(execute({id: "1234", bar: "bla"})).resolves.toEqual({ + id: "1234", + bar: "bla", + }) + await expect(execute({foo: "bla"})).rejects.toThrow( + '"value" does not match any of the allowed types', + ) + }) + }) + + describe("unspecified schemas when allowAny: true", () => { + const config: SchemaBuilderConfig = {allowAny: true} + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports any objects", async () => { + const {code, execute} = await getActualFromModel( + {...base, type: "any"}, + config, + ) + + expect(code).toMatchInlineSnapshot(`"const x = joi.any().required()"`) + + await expect(execute({any: "object"})).resolves.toEqual({ + any: "object", + }) + await expect(execute(["foo", 12])).resolves.toEqual(["foo", 12]) + await expect(execute(null)).resolves.toBeNull() + await expect(execute(123)).resolves.toBe(123) + await expect(execute("some string")).resolves.toBe("some string") + }) + + it("supports any record objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: true, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.object().pattern(joi.any(), joi.any()).required()"`, + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).resolves.toEqual({ + key: "string", + }) + await expect(execute(123)).rejects.toThrow( + '"value" must be of type object', + ) + }) + + it("supports any arrays", async () => { + const {code, execute} = await getActualFromModel( + { + type: "array", + nullable: false, + readOnly: false, + uniqueItems: false, + items: { + ...base, + additionalProperties: true, + }, + }, + config, + ) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .array() + .items(joi.object().pattern(joi.any(), joi.any())) + .required()" + `) + + await expect(execute([{key: 1}])).resolves.toEqual([ + { + key: 1, + }, + ]) + await expect(execute({key: "string"})).rejects.toThrow( + '"value" must be an array', + ) + }) + + it("supports empty objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: false, + }, + config, + ) + expect(code).toMatchInlineSnapshot( + `"const x = joi.object().keys({}).options({ stripUnknown: true }).required()"`, + ) + await expect(execute({any: "object"})).resolves.toEqual({}) + await expect(execute("some string")).rejects.toThrow( + '"value" must be of type object', + ) + }) + }) + + describe("unspecified schemas when allowAny: false", () => { + const config: SchemaBuilderConfig = {allowAny: false} + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports any objects", async () => { + const {code, execute} = await getActualFromModel( + {...base, type: "any"}, + config, + ) + + expect(code).toMatchInlineSnapshot(`"const x = joi.any().required()"`) + + await expect(execute({any: "object"})).resolves.toEqual({ + any: "object", + }) + await expect(execute(["foo", 12])).resolves.toEqual(["foo", 12]) + await expect(execute(null)).resolves.toBeNull() + await expect(execute(123)).resolves.toBe(123) + await expect(execute("some string")).resolves.toBe("some string") + }) + + it("supports any record objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: true, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = joi.object().pattern(joi.any(), joi.any()).required()"`, + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).resolves.toEqual({ + key: "string", + }) + await expect(execute(123)).rejects.toThrow( + '"value" must be of type object', + ) + }) + + it("supports any arrays", async () => { + const {code, execute} = await getActualFromModel( + { + type: "array", + nullable: false, + readOnly: false, + uniqueItems: false, + items: { + ...base, + additionalProperties: true, + }, + }, + config, + ) + + expect(code).toMatchInlineSnapshot(` + "const x = joi + .array() + .items(joi.object().pattern(joi.any(), joi.any())) + .required()" + `) + + await expect(execute([{key: 1}])).resolves.toEqual([ + { + key: 1, + }, + ]) + await expect(execute({key: "string"})).rejects.toThrow( + '"value" must be an array', + ) + }) + + it("supports empty objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: false, + }, + config, + ) + expect(code).toMatchInlineSnapshot( + `"const x = joi.object().keys({}).options({ stripUnknown: true }).required()"`, + ) + await expect(execute({any: "object"})).resolves.toEqual({}) + await expect(execute("some string")).rejects.toThrow( + '"value" must be of type object', + ) + }) + }) +}) diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.integration.spec.ts similarity index 100% rename from packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.spec.ts rename to packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.integration.spec.ts diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts new file mode 100644 index 000000000..b41bf78a4 --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts @@ -0,0 +1,1585 @@ +import * as vm from "node:vm" +import {describe, expect, it} from "@jest/globals" +import type { + SchemaArray, + SchemaBoolean, + SchemaNumber, + SchemaObject, + SchemaString, +} from "../../../core/openapi-types" +import {isDefined} from "../../../core/utils" +import {testVersions} from "../../../test/input.test-utils" +import type {SchemaBuilderConfig} from "./abstract-schema-builder" +import { + schemaBuilderTestHarness, + schemaNumber, + schemaObject, + schemaString, +} from "./schema-builder.test-utils" +import {staticSchemas} from "./zod-v3-schema-builder" + +describe.each( + testVersions, +)("%s - typescript/common/schema-builders/zod-v3-schema-builder", (version) => { + const executeParseSchema = async (code: string) => { + return vm.runInNewContext( + code, + // Note: done this way for consistency with joi tests + {z: require("zod/v3").z, RegExp}, + ) + } + + const {getActualFromModel, getActual} = schemaBuilderTestHarness( + "zod-v3", + version, + executeParseSchema, + ) + + it("supports the SimpleObject", async () => { + const {code, schemas} = await getActual("components/schemas/SimpleObject") + + expect(code).toMatchInlineSnapshot(` + "import { s_SimpleObject } from "./unit-test.schemas" + + const x = s_SimpleObject" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_SimpleObject = z.object({ + str: z.string(), + num: z.coerce.number(), + date: z.string(), + datetime: z.string().datetime({ offset: true }), + optional_str: z.string().optional(), + required_nullable: z.string().nullable(), + $ref: z.string().optional(), + })" + `) + }) + + it("supports the ObjectWithComplexProperties", async () => { + const {code, schemas} = await getActual( + "components/schemas/ObjectWithComplexProperties", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_ObjectWithComplexProperties } from "./unit-test.schemas" + + const x = s_ObjectWithComplexProperties" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const PermissiveBoolean = z.preprocess((value) => { + if (typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if (typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + export const s_AString = z.string() + + export const s_OneOf = z.union([ + z.object({ strs: z.array(z.string()) }), + z.array(z.string()), + z.string(), + ]) + + export const s_ObjectWithComplexProperties = z.object({ + requiredOneOf: z.union([z.string(), z.coerce.number()]), + requiredOneOfRef: s_OneOf, + optionalOneOf: z.union([z.string(), z.coerce.number()]).optional(), + optionalOneOfRef: s_OneOf.optional(), + nullableSingularOneOf: PermissiveBoolean.nullable().optional(), + nullableSingularOneOfRef: s_AString.nullable().optional(), + })" + `) + }) + + it("supports unions / oneOf", async () => { + const {code, schemas} = await getActual("components/schemas/OneOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_OneOf } from "./unit-test.schemas" + + const x = s_OneOf" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_OneOf = z.union([ + z.object({ strs: z.array(z.string()) }), + z.array(z.string()), + z.string(), + ])" + `) + }) + + it("supports unions / anyOf", async () => { + const {code, schemas} = await getActual("components/schemas/AnyOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_AnyOf } from "./unit-test.schemas" + + const x = s_AnyOf" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_AnyOf = z.union([z.coerce.number(), z.string()])" + `) + }) + + it("supports allOf", async () => { + const {code, schemas} = await getActual("components/schemas/AllOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_AllOf } from "./unit-test.schemas" + + const x = s_AllOf" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_Base = z.object({ + name: z.string(), + breed: z.string().optional(), + }) + + export const s_AllOf = s_Base.merge(z.object({ id: z.coerce.number() }))" + `) + }) + + it("supports recursion", async () => { + const {code, schemas} = await getActual("components/schemas/Recursive") + + expect(code).toMatchInlineSnapshot(` + "import { s_Recursive } from "./unit-test.schemas" + + const x = z.lazy(() => s_Recursive)" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + import type { t_Recursive } from "./unit-test.types" + + export const s_Recursive: z.ZodType = + z.object({ child: z.lazy(() => s_Recursive.optional()) })" + `) + }) + + it("orders schemas such that dependencies are defined first", async () => { + const {code, schemas} = await getActual("components/schemas/Ordering") + + expect(code).toMatchInlineSnapshot(` + "import { s_Ordering } from "./unit-test.schemas" + + const x = s_Ordering" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_AOrdering = z.object({ name: z.string().optional() }) + + export const s_ZOrdering = z.object({ + name: z.string().optional(), + dependency1: s_AOrdering, + }) + + export const s_Ordering = z.object({ + dependency1: s_ZOrdering, + dependency2: s_AOrdering, + })" + `) + }) + + it("supports string and numeric enums", async () => { + const {code, schemas} = await getActual("components/schemas/Enums") + + expect(code).toMatchInlineSnapshot(` + "import { s_Enums } from "./unit-test.schemas" + + const x = s_Enums" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_Enums = z.object({ + str: z.enum(["foo", "bar"]).nullable().optional(), + num: z + .union([z.literal(10), z.literal(20)]) + .nullable() + .optional(), + })" + `) + }) + + describe("additionalProperties", () => { + it("handles additionalProperties set to true", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesBool", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesBool } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesBool" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_AdditionalPropertiesBool = z.record(z.unknown())" + `) + }) + + it("handles additionalProperties set to {}", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesUnknownEmptySchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesUnknownEmptySchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesUnknownEmptySchema" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_AdditionalPropertiesUnknownEmptySchema = z.record(z.unknown())" + `) + }) + + it("handles additionalProperties set to {type: 'object'}", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesUnknownEmptyObjectSchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesUnknownEmptyObjectSchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesUnknownEmptyObjectSchema" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_AdditionalPropertiesUnknownEmptyObjectSchema = z.record( + z.record(z.unknown()), + )" + `) + }) + + it("handles additionalProperties specifying a schema", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesSchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesSchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesSchema" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_NamedNullableStringEnum = z + .enum(["", "one", "two", "three"]) + .nullable() + + export const s_AdditionalPropertiesSchema = z.record(s_NamedNullableStringEnum)" + `) + }) + + it("handles additionalProperties in conjunction with properties", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesMixed", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesMixed } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesMixed" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v3" + + export const s_AdditionalPropertiesMixed = z.intersection( + z.object({ id: z.string().optional(), name: z.string().optional() }), + z.record(z.unknown()), + )" + `) + }) + }) + + describe("numbers", () => { + const base: SchemaNumber = { + nullable: false, + readOnly: false, + type: "number", + } + + it("supports plain number", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number()"') + await expect(execute(123)).resolves.toBe(123) + await expect(execute("not a number 123")).rejects.toThrow( + "Expected number, received nan", + ) + }) + + it("supports closed number enums", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: [200, 301, 404], + "x-enum-extensibility": "closed", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.union([z.literal(200), z.literal(301), z.literal(404)])"', + ) + + await expect(execute(123)).rejects.toThrow( + "Invalid literal value, expected 404", + ) + await expect(execute(404)).resolves.toBe(404) + }) + + it("supports open number enums", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: [200, 301, 404], + "x-enum-extensibility": "open", + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z.union([ + z.literal(200), + z.literal(301), + z.literal(404), + z.number().transform((it) => it as typeof it & UnknownEnumNumberValue), + ])" + `) + + await expect(execute(123)).resolves.toBe(123) + await expect(execute(404)).resolves.toBe(404) + await expect(execute("not a number")).rejects.toThrow( + "Expected number, received string", + ) + }) + + it("supports minimum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 10, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().min(10)"', + ) + + await expect(execute(5)).rejects.toThrow( + "Number must be greater than or equal to 10", + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports maximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maximum: 16, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().max(16)"', + ) + + await expect(execute(25)).rejects.toThrow( + "Number must be less than or equal to 16", + ) + await expect(execute(8)).resolves.toBe(8) + }) + + it("supports minimum/maximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 10, + maximum: 24, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().min(10).max(24)"', + ) + + await expect(execute(5)).rejects.toThrow( + "Number must be greater than or equal to 10", + ) + await expect(execute(25)).rejects.toThrow( + "Number must be less than or equal to 24", + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports exclusiveMinimum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + exclusiveMinimum: 4, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number().gt(4)"') + + await expect(execute(4)).rejects.toThrow("Number must be greater than 4") + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports exclusiveMaximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + exclusiveMaximum: 4, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number().lt(4)"') + + await expect(execute(4)).rejects.toThrow("Number must be less than 4") + await expect(execute(3)).resolves.toBe(3) + }) + + it("supports multipleOf", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + multipleOf: 4, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().multipleOf(4)"', + ) + + await expect(execute(11)).rejects.toThrow( + "Number must be a multiple of 4", + ) + await expect(execute(16)).resolves.toBe(16) + }) + + it("supports combining multipleOf and min/max", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + multipleOf: 4, + minimum: 10, + maximum: 20, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().multipleOf(4).min(10).max(20)"', + ) + + await expect(execute(11)).rejects.toThrow( + "Number must be a multiple of 4", + ) + await expect(execute(8)).rejects.toThrow( + "Number must be greater than or equal to 10", + ) + await expect(execute(24)).rejects.toThrow( + "Number must be less than or equal to 20", + ) + await expect(execute(16)).resolves.toBe(16) + }) + + it("supports 0", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 0, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number().min(0)"') + + await expect(execute(-1)).rejects.toThrow( + "Number must be greater than or equal to 0", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 42, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().default(42)"', + ) + + await expect(execute(undefined)).resolves.toBe(42) + }) + + it("supports default values of 0", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 0, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().default(0)"', + ) + + await expect(execute(undefined)).resolves.toBe(0) + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().nullable().default(null)"', + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + }) + + describe("strings", () => { + const base: SchemaString = { + nullable: false, + readOnly: false, + type: "string", + } + + it("supports plain string", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot('"const x = z.string()"') + + await expect(execute("a string")).resolves.toBe("a string") + await expect(execute(123)).rejects.toThrow( + "Expected string, received number", + ) + }) + + it("supports closed string enums", async () => { + const enumValues = ["red", "blue", "green"] + const {code, execute} = await getActualFromModel({ + ...base, + enum: enumValues, + "x-enum-extensibility": "closed", + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.enum(["red", "blue", "green"])"`, + ) + + for (const value of enumValues) { + await expect(execute(value)).resolves.toBe(value) + } + + await expect(execute("orange")).rejects.toThrow( + "Invalid enum value. Expected 'red' | 'blue' | 'green', received 'orange'", + ) + }) + + it("supports open string enums", async () => { + const enumValues = ["red", "blue", "green"] + const {code, execute} = await getActualFromModel({ + ...base, + enum: enumValues, + "x-enum-extensibility": "open", + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z.union([ + z.enum(["red", "blue", "green"]), + z.string().transform((it) => it as typeof it & UnknownEnumStringValue), + ])" + `) + + for (const value of enumValues) { + await expect(execute(value)).resolves.toBe(value) + } + await expect(execute("orange")).resolves.toBe("orange") + await expect(execute(404)).rejects.toThrow( + "Expected string, received number", + ) + }) + + it("supports nullable string using allOf", async () => { + const {code, execute} = await getActualFromModel({ + type: "object", + anyOf: [ + {type: "string", nullable: false, readOnly: false}, + {type: "null", nullable: false, readOnly: false}, + ], + allOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.string().nullable()"') + + await expect(execute("a string")).resolves.toBe("a string") + await expect(execute(null)).resolves.toBe(null) + await expect(execute(123)).rejects.toThrow( + "Expected string, received number", + ) + }) + + it("supports minLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minLength: 8, + }) + expect(code).toMatchInlineSnapshot('"const x = z.string().min(8)"') + + await expect(execute("12345678")).resolves.toBe("12345678") + await expect(execute("1234567")).rejects.toThrow( + "String must contain at least 8 character(s)", + ) + }) + + it("supports maxLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maxLength: 8, + }) + expect(code).toMatchInlineSnapshot('"const x = z.string().max(8)"') + + await expect(execute("12345678")).resolves.toBe("12345678") + await expect(execute("123456789")).rejects.toThrow( + "String must contain at most 8 character(s)", + ) + }) + + it("supports pattern", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + pattern: '"pk/\\d+"', + }) + expect(code).toMatchInlineSnapshot( + '"const x = z.string().regex(new RegExp(\'"pk/\\\\d+"\'))"', + ) + + await expect(execute('"pk/1234"')).resolves.toBe('"pk/1234"') + await expect(execute("pk/abcd")).rejects.toThrow("invalid_string") + }) + + it("supports pattern with minLength / maxLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + pattern: "pk-\\d+", + minLength: 5, + maxLength: 8, + }) + expect(code).toMatchInlineSnapshot( + '"const x = z.string().min(5).max(8).regex(new RegExp("pk-\\\\d+"))"', + ) + + await expect(execute("pk-12")).resolves.toBe("pk-12") + await expect(execute("pk-ab")).rejects.toThrow("invalid_string") + await expect(execute("pk-1")).rejects.toThrow( + "String must contain at least 5 character(s)", + ) + await expect(execute("pk-123456")).rejects.toThrow( + "String must contain at most 8 character(s)", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: "example", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().default("example")"', + ) + + await expect(execute(undefined)).resolves.toBe("example") + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().nullable().default(null)"', + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + + it("supports empty string default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: "", + }) + + expect(code).toMatchInlineSnapshot('"const x = z.string().default("")"') + + await expect(execute(undefined)).resolves.toBe("") + }) + + it("supports default values with quotes", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 'this is an "example", it\'s got lots of `quotes`', + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.string().default('this is an "example", it\\'s got lots of \`quotes\`')"`, + ) + + await expect(execute(undefined)).resolves.toBe( + 'this is an "example", it\'s got lots of `quotes`', + ) + }) + + it("coerces incorrectly typed default values to be strings", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: false, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().default("false")"', + ) + + await expect(execute(undefined)).resolves.toBe("false") + }) + + describe("formats", () => { + it("supports email", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + format: "email", + }) + + expect(code).toMatchInlineSnapshot('"const x = z.string().email()"') + + await expect(execute("test@example.com")).resolves.toBe( + "test@example.com", + ) + await expect(execute("some string")).rejects.toThrow("Invalid email") + }) + it("supports date-time", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + format: "date-time", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().datetime({ offset: true })"', + ) + + await expect(execute("2024-05-25T08:20:00.000Z")).resolves.toBe( + "2024-05-25T08:20:00.000Z", + ) + await expect(execute("some string")).rejects.toThrow("Invalid datetime") + }) + }) + }) + + describe("booleans", () => { + const executeBooleanTest = (code: string, input: unknown) => { + return executeParseSchema(`(async function () { + ${code} + return x.parse(${JSON.stringify(input)}) + })()`) + } + + const base: SchemaBoolean = { + nullable: false, + readOnly: false, + type: "boolean", + } + + function inlineStaticSchemas(code: string) { + const importRegex = + /import {([^}]+)} from "\.\/unit-test\.schemas(?:\.ts)?"\n/ + + const match = code.match(importRegex)?.[1] + + if (match) { + const definitions = match + .split(",") + .map((s) => s.trim()) + .map((it) => { + const definition = Reflect.get(staticSchemas, it) + + if (definition) { + return `const ${it} = ${definition}` + } + return undefined + }) + .filter(isDefined) + .join("\n") + + return `${definitions}\n${code.replace(importRegex, "")}` + } + + return code + } + + it("supports plain boolean", async () => { + const {code} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot(` + "import { PermissiveBoolean } from "./unit-test.schemas" + + const x = PermissiveBoolean" + `) + }) + + it("supports default values of false", async () => { + const {code} = await getActualFromModel({ + ...base, + default: false, + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + const x = PermissiveBoolean.default(false)" + `) + + await expect( + executeBooleanTest(codeWithoutImport, undefined), + ).resolves.toBe(false) + }) + + it("supports default values of true", async () => { + const {code} = await getActualFromModel({ + ...base, + default: true, + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + const x = PermissiveBoolean.default(true)" + `) + + await expect( + executeBooleanTest(codeWithoutImport, undefined), + ).resolves.toBe(true) + }) + + it("supports default values of null when nullable", async () => { + const {code} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + const x = PermissiveBoolean.nullable().default(null)" + `) + + await expect( + executeBooleanTest(codeWithoutImport, undefined), + ).resolves.toBeNull() + }) + + it("support enum of 'true'", async () => { + const {code} = await getActualFromModel({ + ...base, + enum: ["true"], + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + const PermissiveLiteralTrue = z.preprocess((value) => { + return PermissiveBoolean.parse(value) + }, z.literal(true)) + + const x = PermissiveLiteralTrue" + `) + + await expect(executeBooleanTest(codeWithoutImport, true)).resolves.toBe( + true, + ) + await expect( + executeBooleanTest(codeWithoutImport, false), + ).rejects.toThrow("Invalid literal value, expected true") + }) + + it("support enum of 'false'", async () => { + const {code} = await getActualFromModel({ + ...base, + enum: ["false"], + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + const PermissiveLiteralFalse = z.preprocess((value) => { + return PermissiveBoolean.parse(value) + }, z.literal(false)) + + const x = PermissiveLiteralFalse" + `) + + await expect(executeBooleanTest(codeWithoutImport, false)).resolves.toBe( + false, + ) + await expect(executeBooleanTest(codeWithoutImport, true)).rejects.toThrow( + "Invalid literal value, expected false", + ) + }) + + it("PermissiveBoolean works as expected", async () => { + const code = ` + const x = ${staticSchemas.PermissiveBoolean} + ` + + await expect(executeBooleanTest(code, true)).resolves.toBe(true) + await expect(executeBooleanTest(code, false)).resolves.toBe(false) + + await expect(executeBooleanTest(code, "false")).resolves.toBe(false) + await expect(executeBooleanTest(code, "true")).resolves.toBe(true) + + await expect(executeBooleanTest(code, 0)).resolves.toBe(false) + await expect(executeBooleanTest(code, 1)).resolves.toBe(true) + + await expect(executeBooleanTest(code, 12)).rejects.toThrow( + "Expected boolean, received number", + ) + await expect(executeBooleanTest(code, "yup")).rejects.toThrow( + "Expected boolean, received string", + ) + await expect(executeBooleanTest(code, [])).rejects.toThrow( + "Expected boolean, received array", + ) + await expect(executeBooleanTest(code, {})).rejects.toThrow( + "Expected boolean, received object", + ) + }) + }) + + describe("arrays", () => { + const base: SchemaArray = { + nullable: false, + readOnly: false, + type: "array", + items: {nullable: false, readOnly: false, type: "string"}, + uniqueItems: false, + } + + it("supports arrays", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot('"const x = z.array(z.string())"') + + await expect(execute([])).resolves.toStrictEqual([]) + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute([1, 2])).rejects.toThrow( + "Expected string, received number", + ) + }) + + it("supports uniqueItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + uniqueItems: true, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z + .array(z.string()) + .refine((array) => new Set([...array]).size === array.length, { + message: "Array must contain unique element(s)", + })" + `) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo", "foo"])).rejects.toThrow( + "Array must contain unique element(s)", + ) + }) + + it("supports minItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minItems: 2, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.array(z.string()).min(2)"', + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo"])).rejects.toThrow( + "Array must contain at least 2 element(s)", + ) + }) + + it("supports maxItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maxItems: 2, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.array(z.string()).max(2)"', + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo", "bar", "foobar"])).rejects.toThrow( + "Array must contain at most 2 element(s)", + ) + }) + + it("supports minItems / maxItems / uniqueItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + items: schemaNumber(), + minItems: 1, + maxItems: 3, + uniqueItems: true, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z + .array(z.coerce.number()) + .min(1) + .max(3) + .refine((array) => new Set([...array]).size === array.length, { + message: "Array must contain unique element(s)", + })" + `) + + await expect(execute([1, 2])).resolves.toStrictEqual([1, 2]) + await expect(execute([])).rejects.toThrow( + "Array must contain at least 1 element(s)", + ) + await expect(execute([1, 2, 3, 4])).rejects.toThrow( + "Array must contain at most 3 element(s)", + ) + await expect(execute([3, 3, 3])).rejects.toThrow( + "Array must contain unique element(s)", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: ["example"], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.string()).default(["example"])"`, + ) + + await expect(execute(undefined)).resolves.toStrictEqual(["example"]) + }) + + it("supports empty array default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: [], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.string()).default([])"`, + ) + + await expect(execute(undefined)).resolves.toStrictEqual([]) + }) + }) + + describe("objects", () => { + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports general objects", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + properties: { + name: {type: "string", nullable: false, readOnly: false}, + age: {type: "number", nullable: false, readOnly: false}, + }, + required: ["name", "age"], + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.object({ name: z.string(), age: z.coerce.number() })"', + ) + + await expect(execute({name: "John", age: 35})).resolves.toEqual({ + name: "John", + age: 35, + }) + + await expect(execute({age: 35})).rejects.toThrow("Required") + }) + + it("supports record objects", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + additionalProperties: { + type: "number", + nullable: false, + readOnly: false, + }, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.record(z.coerce.number())"', + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).rejects.toThrow( + // TODO: the error here would be better if we avoided using coerce + "Expected number, received nan", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + properties: { + name: {type: "string", nullable: false, readOnly: false}, + age: {type: "number", nullable: false, readOnly: false}, + }, + required: ["name", "age"], + default: {name: "example", age: 22}, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z + .object({ name: z.string(), age: z.coerce.number() }) + .default({ name: "example", age: 22 })" + `) + + await expect(execute(undefined)).resolves.toStrictEqual({ + name: "example", + age: 22, + }) + }) + + it("supports null default when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.record(z.unknown()).nullable().default(null)"`, + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + }) + + describe("unions", () => { + it("can union a string and number", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + anyOf: [schemaString(), schemaNumber()], + }), + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.union([z.string(), z.coerce.number()])"`, + ) + + await expect(execute("some string")).resolves.toEqual("some string") + await expect(execute(1234)).resolves.toEqual(1234) + await expect(execute(undefined)).rejects.toThrow("Required") + }) + + it("can union an intersected object and string", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + anyOf: [ + schemaString(), + schemaObject({ + allOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = z.union([ + z.string(), + z.object({ foo: z.string() }).merge(z.object({ bar: z.string() })), + ])" + `) + + await expect(execute("some string")).resolves.toEqual("some string") + await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({ + foo: "bla", + bar: "foobar", + }) + await expect(execute({foo: "bla"})).rejects.toThrow("Required") + }) + }) + + describe("intersections", () => { + it("can intersect objects", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + allOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.object({ foo: z.string() }).merge(z.object({ bar: z.string() }))"`, + ) + + await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({ + foo: "bla", + bar: "foobar", + }) + await expect(execute({foo: "bla"})).rejects.toThrow("Required") + }) + + it("can intersect unions", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + allOf: [ + schemaObject({ + oneOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + schemaObject({ + properties: {id: schemaString()}, + required: ["id"], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = z.intersection( + z.union([z.object({ foo: z.string() }), z.object({ bar: z.string() })]), + z.object({ id: z.string() }), + )" + `) + + await expect(execute({id: "1234", foo: "bla"})).resolves.toEqual({ + id: "1234", + foo: "bla", + }) + await expect(execute({id: "1234", bar: "bla"})).resolves.toEqual({ + id: "1234", + bar: "bla", + }) + await expect(execute({foo: "bla"})).rejects.toThrow("Required") + }) + }) + + describe("unspecified schemas when allowAny: true", () => { + const config: SchemaBuilderConfig = {allowAny: true} + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports any objects", async () => { + const {code, execute} = await getActualFromModel( + {...base, type: "any"}, + config, + ) + + expect(code).toMatchInlineSnapshot('"const x = z.any()"') + + await expect(execute({any: "object"})).resolves.toEqual({ + any: "object", + }) + await expect(execute(["foo", 12])).resolves.toEqual(["foo", 12]) + await expect(execute(null)).resolves.toBeNull() + await expect(execute(123)).resolves.toBe(123) + await expect(execute("some string")).resolves.toBe("some string") + }) + + it("supports any record objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: true, + }, + config, + ) + + expect(code).toMatchInlineSnapshot('"const x = z.record(z.any())"') + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).resolves.toEqual({ + key: "string", + }) + await expect(execute(123)).rejects.toThrow( + "Expected object, received number", + ) + }) + + it("supports any arrays", async () => { + const {code, execute} = await getActualFromModel( + { + type: "array", + nullable: false, + readOnly: false, + uniqueItems: false, + items: { + ...base, + additionalProperties: true, + }, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.record(z.any()))"`, + ) + + await expect(execute([{key: 1}])).resolves.toEqual([ + { + key: 1, + }, + ]) + await expect(execute({key: "string"})).rejects.toThrow( + "Expected array, received object", + ) + }) + + it("supports empty objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: false, + }, + config, + ) + expect(code).toMatchInlineSnapshot('"const x = z.object({})"') + await expect(execute({any: "object"})).resolves.toEqual({}) + await expect(execute("some string")).rejects.toThrow( + "Expected object, received string", + ) + }) + }) + + describe("unspecified schemas when allowAny: false", () => { + const config: SchemaBuilderConfig = {allowAny: false} + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports any objects", async () => { + const {code, execute} = await getActualFromModel( + {...base, type: "any"}, + config, + ) + + expect(code).toMatchInlineSnapshot(`"const x = z.unknown()"`) + + await expect(execute({any: "object"})).resolves.toEqual({ + any: "object", + }) + await expect(execute(["foo", 12])).resolves.toEqual(["foo", 12]) + await expect(execute(null)).resolves.toBeNull() + await expect(execute(123)).resolves.toBe(123) + await expect(execute("some string")).resolves.toBe("some string") + }) + + it("supports any record objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: true, + }, + config, + ) + + expect(code).toMatchInlineSnapshot(`"const x = z.record(z.unknown())"`) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).resolves.toEqual({ + key: "string", + }) + await expect(execute(123)).rejects.toThrow( + "Expected object, received number", + ) + }) + + it("supports any arrays", async () => { + const {code, execute} = await getActualFromModel( + { + type: "array", + nullable: false, + readOnly: false, + uniqueItems: false, + items: { + ...base, + additionalProperties: true, + }, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.record(z.unknown()))"`, + ) + + await expect(execute([{key: 1}])).resolves.toEqual([ + { + key: 1, + }, + ]) + await expect(execute({key: "string"})).rejects.toThrow( + "Expected array, received object", + ) + }) + + it("supports empty objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: false, + }, + config, + ) + expect(code).toMatchInlineSnapshot('"const x = z.object({})"') + await expect(execute({any: "object"})).resolves.toEqual({}) + await expect(execute("some string")).rejects.toThrow( + "Expected object, received string", + ) + }) + }) +}) diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.integration.spec.ts similarity index 100% rename from packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.spec.ts rename to packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.integration.spec.ts diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts new file mode 100644 index 000000000..dfa7c1624 --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts @@ -0,0 +1,1612 @@ +import * as vm from "node:vm" +import {describe, expect, it} from "@jest/globals" +import type { + SchemaArray, + SchemaBoolean, + SchemaNumber, + SchemaObject, + SchemaString, +} from "../../../core/openapi-types" +import {isDefined} from "../../../core/utils" +import {testVersions} from "../../../test/input.test-utils" +import type {SchemaBuilderConfig} from "./abstract-schema-builder" +import { + schemaBuilderTestHarness, + schemaNumber, + schemaObject, + schemaString, +} from "./schema-builder.test-utils" +import {staticSchemas} from "./zod-v4-schema-builder" + +describe.each( + testVersions, +)("%s - typescript/common/schema-builders/zod-v4-schema-builder", (version) => { + const executeParseSchema = async (code: string) => { + return vm.runInNewContext(code, {z: require("zod/v4").z, RegExp}) + } + + const {getActualFromModel, getActual} = schemaBuilderTestHarness( + "zod-v4", + version, + executeParseSchema, + ) + + it("supports the SimpleObject", async () => { + const {code, schemas} = await getActual("components/schemas/SimpleObject") + + expect(code).toMatchInlineSnapshot(` + "import { s_SimpleObject } from "./unit-test.schemas" + + const x = s_SimpleObject" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_SimpleObject = z.object({ + str: z.string(), + num: z.coerce.number(), + date: z.string(), + datetime: z.iso.datetime({ offset: true }), + optional_str: z.string().optional(), + required_nullable: z.string().nullable(), + $ref: z.string().optional(), + })" + `) + }) + + it("supports the ObjectWithComplexProperties", async () => { + const {code, schemas} = await getActual( + "components/schemas/ObjectWithComplexProperties", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_ObjectWithComplexProperties } from "./unit-test.schemas" + + const x = s_ObjectWithComplexProperties" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const PermissiveBoolean = z.preprocess((value) => { + if (typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if (typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + export const s_AString = z.string() + + export const s_OneOf = z.union([ + z.object({ strs: z.array(z.string()) }), + z.array(z.string()), + z.string(), + ]) + + export const s_ObjectWithComplexProperties = z.object({ + requiredOneOf: z.union([z.string(), z.coerce.number()]), + requiredOneOfRef: s_OneOf, + optionalOneOf: z.union([z.string(), z.coerce.number()]).optional(), + optionalOneOfRef: s_OneOf.optional(), + nullableSingularOneOf: PermissiveBoolean.nullable().optional(), + nullableSingularOneOfRef: s_AString.nullable().optional(), + })" + `) + }) + + it("supports unions / oneOf", async () => { + const {code, schemas} = await getActual("components/schemas/OneOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_OneOf } from "./unit-test.schemas" + + const x = s_OneOf" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_OneOf = z.union([ + z.object({ strs: z.array(z.string()) }), + z.array(z.string()), + z.string(), + ])" + `) + }) + + it("supports unions / anyOf", async () => { + const {code, schemas} = await getActual("components/schemas/AnyOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_AnyOf } from "./unit-test.schemas" + + const x = s_AnyOf" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_AnyOf = z.union([z.coerce.number(), z.string()])" + `) + }) + + it("supports allOf", async () => { + const {code, schemas} = await getActual("components/schemas/AllOf") + + expect(code).toMatchInlineSnapshot(` + "import { s_AllOf } from "./unit-test.schemas" + + const x = s_AllOf" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_Base = z.object({ + name: z.string(), + breed: z.string().optional(), + }) + + export const s_AllOf = s_Base.merge(z.object({ id: z.coerce.number() }))" + `) + }) + + it("supports recursion", async () => { + const {code, schemas} = await getActual("components/schemas/Recursive") + + expect(code).toMatchInlineSnapshot(` + "import { s_Recursive } from "./unit-test.schemas" + + const x = z.lazy(() => s_Recursive)" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + import type { t_Recursive } from "./unit-test.types" + + export const s_Recursive: z.ZodType = z.object({ + child: z.lazy(() => s_Recursive.optional()), + })" + `) + }) + + it("orders schemas such that dependencies are defined first", async () => { + const {code, schemas} = await getActual("components/schemas/Ordering") + + expect(code).toMatchInlineSnapshot(` + "import { s_Ordering } from "./unit-test.schemas" + + const x = s_Ordering" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_AOrdering = z.object({ name: z.string().optional() }) + + export const s_ZOrdering = z.object({ + name: z.string().optional(), + dependency1: s_AOrdering, + }) + + export const s_Ordering = z.object({ + dependency1: s_ZOrdering, + dependency2: s_AOrdering, + })" + `) + }) + + it("supports string and numeric enums", async () => { + const {code, schemas} = await getActual("components/schemas/Enums") + + expect(code).toMatchInlineSnapshot(` + "import { s_Enums } from "./unit-test.schemas" + + const x = s_Enums" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_Enums = z.object({ + str: z.enum(["foo", "bar"]).nullable().optional(), + num: z + .union([z.literal(10), z.literal(20)]) + .nullable() + .optional(), + })" + `) + }) + + describe("additionalProperties", () => { + it("handles additionalProperties set to true", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesBool", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesBool } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesBool" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_AdditionalPropertiesBool = z.record(z.string(), z.unknown())" + `) + }) + + it("handles additionalProperties set to {}", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesUnknownEmptySchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesUnknownEmptySchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesUnknownEmptySchema" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_AdditionalPropertiesUnknownEmptySchema = z.record( + z.string(), + z.unknown(), + )" + `) + }) + + it("handles additionalProperties set to {type: 'object'}", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesUnknownEmptyObjectSchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesUnknownEmptyObjectSchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesUnknownEmptyObjectSchema" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_AdditionalPropertiesUnknownEmptyObjectSchema = z.record( + z.string(), + z.record(z.string(), z.unknown()), + )" + `) + }) + + it("handles additionalProperties specifying a schema", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesSchema", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesSchema } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesSchema" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_NamedNullableStringEnum = z + .enum(["", "one", "two", "three"]) + .nullable() + + export const s_AdditionalPropertiesSchema = z.record( + z.string(), + s_NamedNullableStringEnum, + )" + `) + }) + + it("handles additionalProperties in conjunction with properties", async () => { + const {code, schemas} = await getActual( + "components/schemas/AdditionalPropertiesMixed", + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_AdditionalPropertiesMixed } from "./unit-test.schemas" + + const x = s_AdditionalPropertiesMixed" + `) + + expect(schemas).toMatchInlineSnapshot(` + "import { z } from "zod/v4" + + export const s_AdditionalPropertiesMixed = z.intersection( + z.object({ id: z.string().optional(), name: z.string().optional() }), + z.record(z.string(), z.unknown()), + )" + `) + }) + }) + + describe("numbers", () => { + const base: SchemaNumber = { + nullable: false, + readOnly: false, + type: "number", + } + + it("supports plain number", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number()"') + await expect(execute(123)).resolves.toBe(123) + await expect(execute("not a number 123")).rejects.toThrow( + "Invalid input: expected number, received NaN", + ) + }) + + it("supports closed number enums", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: [200, 301, 404], + "x-enum-extensibility": "closed", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.union([z.literal(200), z.literal(301), z.literal(404)])"', + ) + + await expect(execute(123)).rejects.toThrow("Invalid input: expected 404") + await expect(execute(404)).resolves.toBe(404) + }) + + it("supports open number enums", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + enum: [200, 301, 404], + "x-enum-extensibility": "open", + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z.union([ + z.literal(200), + z.literal(301), + z.literal(404), + z.number().transform((it) => it as typeof it & UnknownEnumNumberValue), + ])" + `) + + await expect(execute(123)).resolves.toBe(123) + await expect(execute(404)).resolves.toBe(404) + await expect(execute("not a number")).rejects.toThrow( + "Invalid input: expected number, received string", + ) + }) + + it("supports minimum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 10, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().min(10)"', + ) + + await expect(execute(5)).rejects.toThrow( + "Too small: expected number to be >=10", + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports maximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maximum: 16, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().max(16)"', + ) + + await expect(execute(25)).rejects.toThrow( + "Too big: expected number to be <=16", + ) + await expect(execute(8)).resolves.toBe(8) + }) + + it("supports minimum/maximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 10, + maximum: 24, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().min(10).max(24)"', + ) + + await expect(execute(5)).rejects.toThrow( + "Too small: expected number to be >=10", + ) + await expect(execute(25)).rejects.toThrow( + "Too big: expected number to be <=24", + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports exclusiveMinimum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + exclusiveMinimum: 4, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number().gt(4)"') + + await expect(execute(4)).rejects.toThrow( + "Too small: expected number to be >4", + ) + await expect(execute(20)).resolves.toBe(20) + }) + + it("supports exclusiveMaximum", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + exclusiveMaximum: 4, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number().lt(4)"') + + await expect(execute(4)).rejects.toThrow( + "Too big: expected number to be <4", + ) + await expect(execute(3)).resolves.toBe(3) + }) + + it("supports multipleOf", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + multipleOf: 4, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().multipleOf(4)"', + ) + + await expect(execute(11)).rejects.toThrow( + "Invalid number: must be a multiple of 4", + ) + await expect(execute(16)).resolves.toBe(16) + }) + + it("supports combining multipleOf and min/max", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + multipleOf: 4, + minimum: 10, + maximum: 20, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().multipleOf(4).min(10).max(20)"', + ) + + await expect(execute(11)).rejects.toThrow( + "Invalid number: must be a multiple of 4", + ) + await expect(execute(8)).rejects.toThrow( + "Too small: expected number to be >=10", + ) + await expect(execute(24)).rejects.toThrow( + "Too big: expected number to be <=20", + ) + await expect(execute(16)).resolves.toBe(16) + }) + + it("supports 0", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minimum: 0, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.coerce.number().min(0)"') + + await expect(execute(-1)).rejects.toThrow( + "Too small: expected number to be >=0", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 42, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().default(42)"', + ) + + await expect(execute(undefined)).resolves.toBe(42) + }) + + it("supports default values of 0", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 0, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().default(0)"', + ) + + await expect(execute(undefined)).resolves.toBe(0) + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.coerce.number().nullable().default(null)"', + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + }) + + describe("strings", () => { + const base: SchemaString = { + nullable: false, + readOnly: false, + type: "string", + } + + it("supports plain string", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot('"const x = z.string()"') + + await expect(execute("a string")).resolves.toBe("a string") + await expect(execute(123)).rejects.toThrow( + "Invalid input: expected string, received number", + ) + }) + + it("supports closed string enums", async () => { + const enumValues = ["red", "blue", "green"] + const {code, execute} = await getActualFromModel({ + ...base, + enum: enumValues, + "x-enum-extensibility": "closed", + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.enum(["red", "blue", "green"])"`, + ) + + for (const value of enumValues) { + await expect(execute(value)).resolves.toBe(value) + } + + await expect(execute("orange")).rejects.toThrow( + `Invalid option: expected one of \\"red\\"|\\"blue\\"|\\"green\\"`, + ) + }) + + it("supports open string enums", async () => { + const enumValues = ["red", "blue", "green"] + const {code, execute} = await getActualFromModel({ + ...base, + enum: enumValues, + "x-enum-extensibility": "open", + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z.union([ + z.enum(["red", "blue", "green"]), + z.string().transform((it) => it as typeof it & UnknownEnumStringValue), + ])" + `) + + for (const value of enumValues) { + await expect(execute(value)).resolves.toBe(value) + } + await expect(execute("orange")).resolves.toBe("orange") + await expect(execute(404)).rejects.toThrow( + "Invalid input: expected string, received number", + ) + }) + + it("supports nullable string using allOf", async () => { + const {code, execute} = await getActualFromModel({ + type: "object", + anyOf: [ + {type: "string", nullable: false, readOnly: false}, + {type: "null", nullable: false, readOnly: false}, + ], + allOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + }) + + expect(code).toMatchInlineSnapshot('"const x = z.string().nullable()"') + + await expect(execute("a string")).resolves.toBe("a string") + await expect(execute(null)).resolves.toBe(null) + await expect(execute(123)).rejects.toThrow( + "Invalid input: expected string, received number", + ) + }) + + it("supports minLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minLength: 8, + }) + expect(code).toMatchInlineSnapshot('"const x = z.string().min(8)"') + + await expect(execute("12345678")).resolves.toBe("12345678") + await expect(execute("1234567")).rejects.toThrow( + "Too small: expected string to have >=8 characters", + ) + }) + + it("supports maxLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maxLength: 8, + }) + expect(code).toMatchInlineSnapshot('"const x = z.string().max(8)"') + + await expect(execute("12345678")).resolves.toBe("12345678") + await expect(execute("123456789")).rejects.toThrow( + "Too big: expected string to have <=8 characters", + ) + }) + + it("supports pattern", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + pattern: '"pk/\\d+"', + }) + expect(code).toMatchInlineSnapshot( + '"const x = z.string().regex(new RegExp(\'"pk/\\\\d+"\'))"', + ) + + await expect(execute('"pk/1234"')).resolves.toBe('"pk/1234"') + await expect(execute("pk/abcd")).rejects.toThrow( + `Invalid string: must match pattern /\\"pk\\\\/\\\\d+\\"/`, + ) + }) + + it("supports pattern with minLength / maxLength", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + pattern: "pk-\\d+", + minLength: 5, + maxLength: 8, + }) + expect(code).toMatchInlineSnapshot( + '"const x = z.string().min(5).max(8).regex(new RegExp("pk-\\\\d+"))"', + ) + + await expect(execute("pk-12")).resolves.toBe("pk-12") + await expect(execute("pk-ab")).rejects.toThrow( + `Invalid string: must match pattern /pk-\\\\d+/`, + ) + await expect(execute("pk-1")).rejects.toThrow( + "Too small: expected string to have >=5 characters", + ) + await expect(execute("pk-123456")).rejects.toThrow( + "Too big: expected string to have <=8 characters", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: "example", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().default("example")"', + ) + + await expect(execute(undefined)).resolves.toBe("example") + }) + + it("supports default values of null when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().nullable().default(null)"', + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + + it("supports empty string default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: "", + }) + + expect(code).toMatchInlineSnapshot('"const x = z.string().default("")"') + + await expect(execute(undefined)).resolves.toBe("") + }) + + it("supports default values with quotes", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: 'this is an "example", it\'s got lots of `quotes`', + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.string().default('this is an "example", it\\'s got lots of \`quotes\`')"`, + ) + + await expect(execute(undefined)).resolves.toBe( + 'this is an "example", it\'s got lots of `quotes`', + ) + }) + + it("coerces incorrectly typed default values to be strings", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: false, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.string().default("false")"', + ) + + await expect(execute(undefined)).resolves.toBe("false") + }) + + describe("formats", () => { + it("supports email", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + format: "email", + }) + + expect(code).toMatchInlineSnapshot('"const x = z.email()"') + + await expect(execute("test@example.com")).resolves.toBe( + "test@example.com", + ) + await expect(execute("some string")).rejects.toThrow("Invalid email") + }) + it("supports date-time", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + format: "date-time", + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.iso.datetime({ offset: true })"', + ) + + await expect(execute("2024-05-25T08:20:00.000Z")).resolves.toBe( + "2024-05-25T08:20:00.000Z", + ) + await expect(execute("some string")).rejects.toThrow( + "Invalid ISO datetime", + ) + }) + }) + }) + + describe("booleans", () => { + const executeBooleanTest = (code: string, input: unknown) => { + return executeParseSchema(`(async function () { + ${code} + return x.parse(${JSON.stringify(input)}) + })()`) + } + + const base: SchemaBoolean = { + nullable: false, + readOnly: false, + type: "boolean", + } + + function inlineStaticSchemas(code: string) { + const importRegex = + /import {([^}]+)} from "\.\/unit-test\.schemas(?:\.ts)?"\n/ + + const match = code.match(importRegex)?.[1] + + if (match) { + const definitions = match + .split(",") + .map((s) => s.trim()) + .map((it) => { + const definition = Reflect.get(staticSchemas, it) + + if (definition) { + return `const ${it} = ${definition}` + } + return undefined + }) + .filter(isDefined) + .join("\n") + + return `${definitions}\n${code.replace(importRegex, "")}` + } + + return code + } + + it("supports plain boolean", async () => { + const {code} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot(` + "import { PermissiveBoolean } from "./unit-test.schemas" + + const x = PermissiveBoolean" + `) + }) + + it("supports default values of false", async () => { + const {code} = await getActualFromModel({ + ...base, + default: false, + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + const x = PermissiveBoolean.default(false)" + `) + + await expect( + executeBooleanTest(codeWithoutImport, undefined), + ).resolves.toBe(false) + }) + + it("supports default values of true", async () => { + const {code} = await getActualFromModel({ + ...base, + default: true, + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + const x = PermissiveBoolean.default(true)" + `) + + await expect( + executeBooleanTest(codeWithoutImport, undefined), + ).resolves.toBe(true) + }) + + it("supports default values of null when nullable", async () => { + const {code} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + + const x = PermissiveBoolean.nullable().default(null)" + `) + + await expect( + executeBooleanTest(codeWithoutImport, undefined), + ).resolves.toBeNull() + }) + + it("support enum of 'true'", async () => { + const {code} = await getActualFromModel({ + ...base, + enum: ["true"], + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + const PermissiveLiteralTrue = z.preprocess((value) => { + return PermissiveBoolean.parse(value) + }, z.literal(true)) + + const x = PermissiveLiteralTrue" + `) + + await expect(executeBooleanTest(codeWithoutImport, true)).resolves.toBe( + true, + ) + await expect( + executeBooleanTest(codeWithoutImport, false), + ).rejects.toThrow("Invalid input: expected true") + }) + + it("support enum of 'false'", async () => { + const {code} = await getActualFromModel({ + ...base, + enum: ["false"], + }) + + const codeWithoutImport = inlineStaticSchemas(code) + + expect(codeWithoutImport).toMatchInlineSnapshot(` + "const PermissiveBoolean = z.preprocess((value) => { + if(typeof value === "string" && (value === "true" || value === "false")) { + return value === "true" + } else if(typeof value === "number" && (value === 1 || value === 0)) { + return value === 1 + } + return value + }, z.boolean()) + const PermissiveLiteralFalse = z.preprocess((value) => { + return PermissiveBoolean.parse(value) + }, z.literal(false)) + + const x = PermissiveLiteralFalse" + `) + + await expect(executeBooleanTest(codeWithoutImport, false)).resolves.toBe( + false, + ) + await expect(executeBooleanTest(codeWithoutImport, true)).rejects.toThrow( + "Invalid input: expected false", + ) + }) + + it("PermissiveBoolean works as expected", async () => { + const code = ` + const x = ${staticSchemas.PermissiveBoolean} + ` + + await expect(executeBooleanTest(code, true)).resolves.toBe(true) + await expect(executeBooleanTest(code, false)).resolves.toBe(false) + + await expect(executeBooleanTest(code, "false")).resolves.toBe(false) + await expect(executeBooleanTest(code, "true")).resolves.toBe(true) + + await expect(executeBooleanTest(code, 0)).resolves.toBe(false) + await expect(executeBooleanTest(code, 1)).resolves.toBe(true) + + await expect(executeBooleanTest(code, 12)).rejects.toThrow( + "Invalid input: expected boolean, received number", + ) + await expect(executeBooleanTest(code, "yup")).rejects.toThrow( + "Invalid input: expected boolean, received string", + ) + await expect(executeBooleanTest(code, [])).rejects.toThrow( + "Invalid input: expected boolean, received array", + ) + await expect(executeBooleanTest(code, {})).rejects.toThrow( + "Invalid input: expected boolean, received Object", + ) + }) + }) + + describe("arrays", () => { + const base: SchemaArray = { + nullable: false, + readOnly: false, + type: "array", + items: {nullable: false, readOnly: false, type: "string"}, + uniqueItems: false, + } + + it("supports arrays", async () => { + const {code, execute} = await getActualFromModel({...base}) + + expect(code).toMatchInlineSnapshot('"const x = z.array(z.string())"') + + await expect(execute([])).resolves.toStrictEqual([]) + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute([1, 2])).rejects.toThrow( + "Invalid input: expected string, received number", + ) + }) + + it("supports uniqueItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + uniqueItems: true, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z + .array(z.string()) + .refine((array) => new Set([...array]).size === array.length, { + message: "Array must contain unique element(s)", + })" + `) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo", "foo"])).rejects.toThrow( + "Array must contain unique element(s)", + ) + }) + + it("supports minItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + minItems: 2, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.array(z.string()).min(2)"', + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo"])).rejects.toThrow( + "Too small: expected array to have >=2 items", + ) + }) + + it("supports maxItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + maxItems: 2, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.array(z.string()).max(2)"', + ) + + await expect(execute(["foo", "bar"])).resolves.toStrictEqual([ + "foo", + "bar", + ]) + await expect(execute(["foo", "bar", "foobar"])).rejects.toThrow( + "Too big: expected array to have <=2 items", + ) + }) + + it("supports minItems / maxItems / uniqueItems", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + items: schemaNumber(), + minItems: 1, + maxItems: 3, + uniqueItems: true, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z + .array(z.coerce.number()) + .min(1) + .max(3) + .refine((array) => new Set([...array]).size === array.length, { + message: "Array must contain unique element(s)", + })" + `) + + await expect(execute([1, 2])).resolves.toStrictEqual([1, 2]) + await expect(execute([])).rejects.toThrow( + "Too small: expected array to have >=1 items", + ) + await expect(execute([1, 2, 3, 4])).rejects.toThrow( + "Too big: expected array to have <=3 items", + ) + await expect(execute([3, 3, 3])).rejects.toThrow( + "Array must contain unique element(s)", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: ["example"], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.string()).default(["example"])"`, + ) + + await expect(execute(undefined)).resolves.toStrictEqual(["example"]) + }) + + it("supports empty array default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + default: [], + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.string()).default([])"`, + ) + + await expect(execute(undefined)).resolves.toStrictEqual([]) + }) + }) + + describe("objects", () => { + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports general objects", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + properties: { + name: {type: "string", nullable: false, readOnly: false}, + age: {type: "number", nullable: false, readOnly: false}, + }, + required: ["name", "age"], + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.object({ name: z.string(), age: z.coerce.number() })"', + ) + + await expect(execute({name: "John", age: 35})).resolves.toEqual({ + name: "John", + age: 35, + }) + + await expect(execute({age: 35})).rejects.toThrow( + "Invalid input: expected string, received undefined", + ) + }) + + it("supports record objects", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + additionalProperties: { + type: "number", + nullable: false, + readOnly: false, + }, + }) + + expect(code).toMatchInlineSnapshot( + '"const x = z.record(z.string(), z.coerce.number())"', + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).rejects.toThrow( + // todo: the error here would be better if we avoided using coerce + "Invalid input: expected number, received NaN", + ) + }) + + it("supports default values", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + properties: { + name: {type: "string", nullable: false, readOnly: false}, + age: {type: "number", nullable: false, readOnly: false}, + }, + required: ["name", "age"], + default: {name: "example", age: 22}, + }) + + expect(code).toMatchInlineSnapshot(` + "const x = z + .object({ name: z.string(), age: z.coerce.number() }) + .default({ name: "example", age: 22 })" + `) + + await expect(execute(undefined)).resolves.toEqual({ + name: "example", + age: 22, + }) + }) + + it("supports null default when nullable", async () => { + const {code, execute} = await getActualFromModel({ + ...base, + nullable: true, + default: null, + }) + + expect(code).toMatchInlineSnapshot( + `"const x = z.record(z.string(), z.unknown()).nullable().default(null)"`, + ) + + await expect(execute(undefined)).resolves.toBeNull() + }) + }) + + describe("unions", () => { + it("can union a string and number", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + anyOf: [schemaString(), schemaNumber()], + }), + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.union([z.string(), z.coerce.number()])"`, + ) + + await expect(execute("some string")).resolves.toEqual("some string") + await expect(execute(1234)).resolves.toEqual(1234) + await expect(execute(undefined)).rejects.toThrow( + "Invalid input: expected string, received undefined", + ) + }) + + it("can union an intersected object and string", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + anyOf: [ + schemaString(), + schemaObject({ + allOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = z.union([ + z.string(), + z.object({ foo: z.string() }).merge(z.object({ bar: z.string() })), + ])" + `) + + await expect(execute("some string")).resolves.toEqual("some string") + await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({ + foo: "bla", + bar: "foobar", + }) + // todo: the error here is not great, zod doesn't mention that the received object doesn't match the possible object + await expect(execute({foo: "bla"})).rejects.toThrow( + "Invalid input: expected string, received undefined", + ) + }) + }) + + describe("intersections", () => { + it("can intersect objects", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + allOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.object({ foo: z.string() }).merge(z.object({ bar: z.string() }))"`, + ) + + await expect(execute({foo: "bla", bar: "foobar"})).resolves.toEqual({ + foo: "bla", + bar: "foobar", + }) + await expect(execute({foo: "bla"})).rejects.toThrow( + "Invalid input: expected string, received undefined", + ) + }) + + it("can intersect unions", async () => { + const {code, execute} = await getActualFromModel( + schemaObject({ + allOf: [ + schemaObject({ + oneOf: [ + schemaObject({ + properties: {foo: schemaString()}, + required: ["foo"], + }), + schemaObject({ + properties: {bar: schemaString()}, + required: ["bar"], + }), + ], + }), + schemaObject({ + properties: {id: schemaString()}, + required: ["id"], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = z.intersection( + z.union([z.object({ foo: z.string() }), z.object({ bar: z.string() })]), + z.object({ id: z.string() }), + )" + `) + + await expect(execute({id: "1234", foo: "bla"})).resolves.toEqual({ + id: "1234", + foo: "bla", + }) + await expect(execute({id: "1234", bar: "bla"})).resolves.toEqual({ + id: "1234", + bar: "bla", + }) + await expect(execute({foo: "bla"})).rejects.toThrow( + "Invalid input: expected string, received undefined", + ) + }) + }) + + describe("unspecified schemas when allowAny: true", () => { + const config: SchemaBuilderConfig = {allowAny: true} + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports any objects", async () => { + const {code, execute} = await getActualFromModel( + {...base, type: "any"}, + config, + ) + + expect(code).toMatchInlineSnapshot('"const x = z.any()"') + + await expect(execute({any: "object"})).resolves.toEqual({ + any: "object", + }) + await expect(execute(["foo", 12])).resolves.toEqual(["foo", 12]) + await expect(execute(null)).resolves.toBeNull() + await expect(execute(123)).resolves.toBe(123) + await expect(execute("some string")).resolves.toBe("some string") + }) + + it("supports any record objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: true, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + '"const x = z.record(z.string(), z.any())"', + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).resolves.toEqual({ + key: "string", + }) + await expect(execute(123)).rejects.toThrow( + "Invalid input: expected record, received number", + ) + }) + + it("supports any arrays", async () => { + const {code, execute} = await getActualFromModel( + { + type: "array", + nullable: false, + readOnly: false, + uniqueItems: false, + items: { + ...base, + additionalProperties: true, + }, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.record(z.string(), z.any()))"`, + ) + + await expect(execute([{key: 1}])).resolves.toEqual([ + { + key: 1, + }, + ]) + await expect(execute({key: "string"})).rejects.toThrow( + "Invalid input: expected array, received Object", + ) + }) + + it("supports empty objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: false, + }, + config, + ) + expect(code).toMatchInlineSnapshot('"const x = z.object({})"') + await expect(execute({any: "object"})).resolves.toEqual({}) + await expect(execute("some string")).rejects.toThrow( + "Invalid input: expected object, received string", + ) + }) + }) + + describe("unspecified schemas when allowAny: false", () => { + const config: SchemaBuilderConfig = {allowAny: false} + const base: SchemaObject = { + type: "object", + allOf: [], + anyOf: [], + oneOf: [], + properties: {}, + additionalProperties: undefined, + required: [], + nullable: false, + readOnly: false, + } + + it("supports any objects", async () => { + const {code, execute} = await getActualFromModel( + {...base, type: "any"}, + config, + ) + + expect(code).toMatchInlineSnapshot(`"const x = z.unknown()"`) + + await expect(execute({any: "object"})).resolves.toEqual({ + any: "object", + }) + await expect(execute(["foo", 12])).resolves.toEqual(["foo", 12]) + await expect(execute(null)).resolves.toBeNull() + await expect(execute(123)).resolves.toBe(123) + await expect(execute("some string")).resolves.toBe("some string") + }) + + it("supports any record objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: true, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.record(z.string(), z.unknown())"`, + ) + + await expect(execute({key: 1})).resolves.toEqual({ + key: 1, + }) + await expect(execute({key: "string"})).resolves.toEqual({ + key: "string", + }) + await expect(execute(123)).rejects.toThrow( + "Invalid input: expected record, received number", + ) + }) + + it("supports any arrays", async () => { + const {code, execute} = await getActualFromModel( + { + type: "array", + nullable: false, + readOnly: false, + uniqueItems: false, + items: { + ...base, + additionalProperties: true, + }, + }, + config, + ) + + expect(code).toMatchInlineSnapshot( + `"const x = z.array(z.record(z.string(), z.unknown()))"`, + ) + + await expect(execute([{key: 1}])).resolves.toEqual([ + { + key: 1, + }, + ]) + await expect(execute({key: "string"})).rejects.toThrow( + "Invalid input: expected array, received Object", + ) + }) + + it("supports empty objects", async () => { + const {code, execute} = await getActualFromModel( + { + ...base, + additionalProperties: false, + }, + config, + ) + expect(code).toMatchInlineSnapshot('"const x = z.object({})"') + await expect(execute({any: "object"})).resolves.toEqual({}) + await expect(execute("some string")).rejects.toThrow( + "Invalid input: expected object, received string", + ) + }) + }) +})