diff --git a/lib/package.json b/lib/package.json index 7b65d4fe..d5f1f9ef 100644 --- a/lib/package.json +++ b/lib/package.json @@ -26,6 +26,7 @@ "@zodios/core": "^10.3.1", "axios": "^1.6.0", "cac": "^6.7.14", + "fast-deep-equal": "^3.1.3", "handlebars": "^4.7.7", "openapi-types": "^12.0.2", "openapi3-ts": "3.1.0", diff --git a/lib/src/openApiToZod.ts b/lib/src/openApiToZod.ts index 73fb424e..bb514579 100644 --- a/lib/src/openApiToZod.ts +++ b/lib/src/openApiToZod.ts @@ -212,16 +212,17 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option if (schemaType === "array") { if (schema.items) { + const arrayValidations = getZodChainableArrayValidations(schema); + const arrayChain = arrayValidations ? `.${arrayValidations}` : ''; + return code.assign( - `z.array(${ - getZodSchema({ schema: schema.items, ctx, meta, options }).toString() - }${ - getZodChain({ - schema: schema.items as SchemaObject, - meta: { ...meta, isRequired: true }, - options, - }) - })${readonly}` + `z.array(${getZodSchema({ schema: schema.items, ctx, meta, options }).toString() + }${getZodChain({ + schema: schema.items as SchemaObject, + meta: { ...meta, isRequired: true }, + options, + }) + })${arrayChain}${readonly}` ); } @@ -308,7 +309,6 @@ export const getZodChain = ({ schema, meta, options }: ZodChainArgs) => { match(schema.type) .with("string", () => chains.push(getZodChainableStringValidations(schema))) .with("number", "integer", () => chains.push(getZodChainableNumberValidations(schema))) - .with("array", () => chains.push(getZodChainableArrayValidations(schema))) .otherwise(() => void 0); if (typeof schema.description === "string" && schema.description !== "" && options?.withDescription) { @@ -459,5 +459,9 @@ const getZodChainableArrayValidations = (schema: SchemaObject) => { validations.push(`max(${schema.maxItems})`); } + + if (schema.uniqueItems === true) { + validations.push(`refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" })`); + } return validations.join("."); }; diff --git a/lib/tests/array-body-with-chains-tag-group-strategy.test.ts b/lib/tests/array-body-with-chains-tag-group-strategy.test.ts index aa0d3676..11b56cae 100644 --- a/lib/tests/array-body-with-chains-tag-group-strategy.test.ts +++ b/lib/tests/array-body-with-chains-tag-group-strategy.test.ts @@ -56,7 +56,10 @@ test("array-body-with-chains-tag-group-strategy", async () => { "Test": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; import { z } from "zod"; - const putTest_Body = z.array(z.object({ testItem: z.string() }).partial()); + const putTest_Body = z + .array(z.object({ testItem: z.string() }).partial()) + .min(1) + .max(10); export const schemas = { putTest_Body, @@ -72,7 +75,7 @@ test("array-body-with-chains-tag-group-strategy", async () => { { name: "body", type: "Body", - schema: putTest_Body.min(1).max(10), + schema: putTest_Body, }, ], response: z.void(), diff --git a/lib/tests/export-all-types.test.ts b/lib/tests/export-all-types.test.ts index 6169d27f..55d74897 100644 --- a/lib/tests/export-all-types.test.ts +++ b/lib/tests/export-all-types.test.ts @@ -103,9 +103,9 @@ describe("export-all-types", () => { expect(data).toEqual({ schemas: { - Settings: "z.object({ theme_color: z.string(), features: Features.min(1) }).partial().passthrough()", + Settings: "z.object({ theme_color: z.string(), features: Features }).partial().passthrough()", Author: "z.object({ name: z.union([z.string(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), settings: Settings }).partial().passthrough()", - Features: "z.array(z.string())", + Features: "z.array(z.string()).min(1)", Song: "z.object({ name: z.string(), duration: z.number() }).partial().passthrough()", Playlist: "z.object({ name: z.string(), author: Author, songs: z.array(Song) }).partial().passthrough().and(Settings)", @@ -200,9 +200,9 @@ describe("export-all-types", () => { const Title = z.string(); const Id = z.number(); - const Features = z.array(z.string()); + const Features = z.array(z.string()).min(1); const Settings: z.ZodType = z - .object({ theme_color: z.string(), features: Features.min(1) }) + .object({ theme_color: z.string(), features: Features }) .partial() .passthrough(); const Author: z.ZodType = z diff --git a/lib/tests/unique-items.test.ts b/lib/tests/unique-items.test.ts new file mode 100644 index 00000000..1c9d3b50 --- /dev/null +++ b/lib/tests/unique-items.test.ts @@ -0,0 +1,57 @@ +import { getZodSchema } from "../src/openApiToZod"; +import { test, expect } from "vitest"; + +test("uniqueItems validation", () => { + // Test basic uniqueItems on string array + expect( + getZodSchema({ + schema: { + type: "array", + items: { type: "string" }, + uniqueItems: true + } + }).toString() + ).toMatchInlineSnapshot( + '"z.array(z.string()).refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" })"' + ); + + // Test array without uniqueItems (should not have refine) + expect( + getZodSchema({ + schema: { + type: "array", + items: { type: "string" } + } + }).toString() + ).toMatchInlineSnapshot( + '"z.array(z.string())"' + ); + + // Test uniqueItems: false (should not have refine) + expect( + getZodSchema({ + schema: { + type: "array", + items: { type: "string" }, + uniqueItems: false + } + }).toString() + ).toMatchInlineSnapshot( + '"z.array(z.string())"' + ); + + // Test uniqueItems with minItems and maxItems (proper order) + expect( + getZodSchema({ + schema: { + type: "array", + items: { type: "string" }, + minItems: 2, + maxItems: 5, + uniqueItems: true + } + }).toString() + ).toMatchInlineSnapshot( + '"z.array(z.string()).min(2).max(5).refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" })"' + ); +}); diff --git a/lib/tests/validations.test.ts b/lib/tests/validations.test.ts index ed15db15..e6554954 100644 --- a/lib/tests/validations.test.ts +++ b/lib/tests/validations.test.ts @@ -37,8 +37,7 @@ test("validations", () => { arrayWithMin: { type: "array", items: { type: "string" }, minItems: 3 }, arrayWithMax: { type: "array", items: { type: "string" }, maxItems: 3 }, arrayWithFormat: { type: "array", items: { type: "string", format: "uuid" } }, - // TODO ? - // arrayWithUnique: { type: "array", items: { type: "string" }, uniqueItems: true }, + arrayWithUnique: { type: "array", items: { type: "string" }, uniqueItems: true }, // object: { type: "object", properties: { str: { type: "string" } } }, objectWithRequired: { type: "object", properties: { str: { type: "string" } }, required: ["str"] }, @@ -62,6 +61,6 @@ test("validations", () => { }, }) ).toMatchInlineSnapshot( - '"z.object({ str: z.string(), strWithLength: z.string().min(3).max(3), strWithMin: z.string().min(3), strWithMax: z.string().max(3), strWithPattern: z.string().regex(/^[a-z]+$/), strWithPatternWithSlash: z.string().regex(/abc\\/def\\/ghi/), email: z.string().email(), hostname: z.string().url(), url: z.string().url(), uuid: z.string().uuid(), number: z.number(), int: z.number().int(), intWithMin: z.number().int().gte(3), intWithMax: z.number().int().lte(3), intWithMinAndMax: z.number().int().gte(3).lte(3), intWithExclusiveMinTrue: z.number().int().gt(3), intWithExclusiveMinFalse: z.number().int().gte(3), intWithExclusiveMin: z.number().int().gt(3), intWithExclusiveMaxTrue: z.number().int().lt(3), intWithExclusiveMaxFalse: z.number().int().lte(3), intWithExclusiveMax: z.number().int().lt(3), intWithMultipleOf: z.number().int().multipleOf(3), bool: z.boolean(), array: z.array(z.string()), arrayWithMin: z.array(z.string()).min(3), arrayWithMax: z.array(z.string()).max(3), arrayWithFormat: z.array(z.string().uuid()), object: z.object({ str: z.string() }).passthrough(), objectWithRequired: z.object({ str: z.string() }).passthrough(), oneOf: z.union([z.string(), z.number()]), anyOf: z.union([z.string(), z.number()]), allOf: z.string().and(z.number()), nested: z.record(z.number()), nestedNullable: z.record(z.number().nullable()) }).passthrough()"' + '"z.object({ str: z.string(), strWithLength: z.string().min(3).max(3), strWithMin: z.string().min(3), strWithMax: z.string().max(3), strWithPattern: z.string().regex(/^[a-z]+$/), strWithPatternWithSlash: z.string().regex(/abc\\/def\\/ghi/), email: z.string().email(), hostname: z.string().url(), url: z.string().url(), uuid: z.string().uuid(), number: z.number(), int: z.number().int(), intWithMin: z.number().int().gte(3), intWithMax: z.number().int().lte(3), intWithMinAndMax: z.number().int().gte(3).lte(3), intWithExclusiveMinTrue: z.number().int().gt(3), intWithExclusiveMinFalse: z.number().int().gte(3), intWithExclusiveMin: z.number().int().gt(3), intWithExclusiveMaxTrue: z.number().int().lt(3), intWithExclusiveMaxFalse: z.number().int().lte(3), intWithExclusiveMax: z.number().int().lt(3), intWithMultipleOf: z.number().int().multipleOf(3), bool: z.boolean(), array: z.array(z.string()), arrayWithMin: z.array(z.string()).min(3), arrayWithMax: z.array(z.string()).max(3), arrayWithFormat: z.array(z.string().uuid()), arrayWithUnique: z.array(z.string()).refine((arr) => { const unique: any[] = []; for (const item of arr) { if (unique.some(u => isEqual(u, item))) { return false; } unique.push(item); } return true; }, { message: "Items must be unique" }), object: z.object({ str: z.string() }).passthrough(), objectWithRequired: z.object({ str: z.string() }).passthrough(), oneOf: z.union([z.string(), z.number()]), anyOf: z.union([z.string(), z.number()]), allOf: z.string().and(z.number()), nested: z.record(z.number()), nestedNullable: z.record(z.number().nullable()) }).passthrough()"' ); });