Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/feat-unique-items-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"openapi-zod-client": minor
---

Add uniqueItems validation support for OpenAPI array schemas

- Arrays with `uniqueItems: true` now generate proper Zod validation with deep equality checking
- Uses fast-deep-equal library for efficient duplicate detection
- Array validations (min, max, uniqueItems) are now applied at the array schema definition level - previously, e.g. when used as a schema for an object property, the array schema did not contain these validations, only the object property.
1 change: 1 addition & 0 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 14 additions & 10 deletions lib/src/openApiToZod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
);
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(".");
};
7 changes: 5 additions & 2 deletions lib/tests/array-body-with-chains-tag-group-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
8 changes: 4 additions & 4 deletions lib/tests/export-all-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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<Settings> = z
.object({ theme_color: z.string(), features: Features.min(1) })
.object({ theme_color: z.string(), features: Features })
.partial()
.passthrough();
const Author: z.ZodType<Author> = z
Expand Down
57 changes: 57 additions & 0 deletions lib/tests/unique-items.test.ts
Original file line number Diff line number Diff line change
@@ -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" })"'
);
});
5 changes: 2 additions & 3 deletions lib/tests/validations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand All @@ -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()"'
);
});