From d10b1215e3bd27055038489db26d219674e9d8c3 Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Thu, 5 Nov 2026 12:48:08 +0100 Subject: [PATCH 1/2] fix: support JSON Schema tuple items in code generation --- native/src/zod.rs | 48 ++++++++++++++++++++++- src/generators/core/zod/getZodChain.ts | 5 ++- src/generators/core/zod/getZodSchema.ts | 19 +++++++++ src/generators/core/zod/tuple.test.ts | 51 +++++++++++++++++++++++++ src/native/renderer-parity.test.ts | 41 ++++++++++++++++++++ 5 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 src/generators/core/zod/tuple.test.ts diff --git a/native/src/zod.rs b/native/src/zod.rs index 4f1d673..63eb888 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -286,6 +286,42 @@ impl<'a> ZodCompiler<'a> { .and_then(Value::as_str) .map(str::to_ascii_lowercase); if schema_type.as_deref() == Some("array") { + if let Some(items) = object.get("items").and_then(Value::as_array) { + let required = object + .get("minItems") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + let item_meta = Meta { + required: true, + parent_partial: false, + }; + let members = items + .iter() + .enumerate() + .map(|(index, item)| { + let code = self.compile(item, item_meta, tag, root_ref, stack)?; + Ok(format!( + "{code}{}{}", + chain(item, item_meta, self.options), + if index >= required { ".optional()" } else { "" } + )) + }) + .collect::, String>>()?; + let rest = match object.get("additionalItems") { + Some(Value::Bool(false)) => String::new(), + Some(value @ Value::Object(_)) => format!( + ".rest({}{})", + self.compile(value, item_meta, tag, root_ref, stack)?, + chain(value, item_meta, self.options) + ), + _ => ".rest(z.any())".into(), + }; + return Ok(format!( + "z.tuple([{}]){rest}{}", + members.join(", "), + if self.options.all_readonly { ".readonly()" } else { "" } + )); + } let item = if let Some(items) = object.get("items") { let code = self.compile( items, @@ -685,14 +721,22 @@ fn chain(schema: &Value, meta: Meta, options: &GenerateOptions) -> String { .and_then(Value::as_u64) .filter(|value| *value > 0) { - chains.push(format!("min({value})")); + chains.push(if schema.get("items").is_some_and(Value::is_array) { + format!("refine((value) => value.length >= {value})") + } else { + format!("min({value})") + }); } if let Some(value) = schema .get("maxItems") .and_then(Value::as_u64) .filter(|value| *value > 0) { - chains.push(format!("max({value})")); + chains.push(if schema.get("items").is_some_and(Value::is_array) { + format!("refine((value) => value.length <= {value})") + } else { + format!("max({value})") + }); } } _ => {} diff --git a/src/generators/core/zod/getZodChain.ts b/src/generators/core/zod/getZodChain.ts index 8ee50ea..43b5fac 100644 --- a/src/generators/core/zod/getZodChain.ts +++ b/src/generators/core/zod/getZodChain.ts @@ -162,13 +162,14 @@ function getZodChainableNumberValidations(schema: OpenAPIV3.SchemaObject) { function getZodChainableArrayValidations(schema: OpenAPIV3.SchemaObject) { const validations: string[] = []; + const tuple = Array.isArray((schema as OpenAPIV3.ArraySchemaObject).items); if (schema.minItems) { - validations.push(`min(${schema.minItems})`); + validations.push(tuple ? `refine((value) => value.length >= ${schema.minItems})` : `min(${schema.minItems})`); } if (schema.maxItems) { - validations.push(`max(${schema.maxItems})`); + validations.push(tuple ? `refine((value) => value.length <= ${schema.maxItems})` : `max(${schema.maxItems})`); } return validations.join("."); diff --git a/src/generators/core/zod/getZodSchema.ts b/src/generators/core/zod/getZodSchema.ts index b44df28..0100962 100644 --- a/src/generators/core/zod/getZodSchema.ts +++ b/src/generators/core/zod/getZodSchema.ts @@ -78,6 +78,25 @@ export function getZodSchema({ schema, resolver, meta: inheritedMeta, tag }: Get const readonly = resolver.options.allReadonly ? ".readonly()" : ""; if (isArraySchemaObject(schema)) { + if (Array.isArray(schema.items)) { + const tuple = schema as OpenAPIV3.ArraySchemaObject & { additionalItems?: boolean | OpenAPIV3.SchemaObject }; + const items = schema.items.map((item, index) => { + const code = getZodSchema({ ...params, schema: item }).getCodeString(tag, resolver.options); + const chain = getZodChain({ schema: item, meta: { ...meta, isRequired: true }, options: resolver.options }); + return code + chain + (index >= (schema.minItems ?? 0) ? ".optional()" : ""); + }); + const restSchema = + typeof tuple.additionalItems === "object" + ? getZodSchema({ ...params, schema: tuple.additionalItems }).getCodeString(tag, resolver.options) + + getZodChain({ + schema: tuple.additionalItems, + meta: { ...meta, isRequired: true }, + options: resolver.options, + }) + : ANY_SCHEMA; + const rest = tuple.additionalItems === false ? "" : `.rest(${restSchema})`; + return zodSchema.assign(`z.tuple([${items.join(", ")}])${rest}${readonly}`); + } if (schema.items) { return zodSchema.assign( `z.array(${getZodSchema({ ...params, schema: schema.items }).getCodeString(tag, resolver.options)}${getZodChain( diff --git a/src/generators/core/zod/tuple.test.ts b/src/generators/core/zod/tuple.test.ts new file mode 100644 index 0000000..1b4def4 --- /dev/null +++ b/src/generators/core/zod/tuple.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { OpenAPIV3 } from "openapi-types"; +import { z } from "zod"; + +import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; +import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; +import { getZodSchema } from "./getZodSchema"; +import { getZodChain } from "./getZodChain"; + +const compile = (schema: object) => { + const typed = schema as OpenAPIV3.SchemaObject; + const options = DEFAULT_GENERATE_OPTIONS; + const resolver = new SchemaResolver({} as OpenAPIV3.Document, options); + const code = getZodSchema({ schema: typed, resolver, tag: "" }).getCodeString(); + const chain = getZodChain({ schema: typed, options, meta: { isRequired: true } }); + return { code: code + chain, schema: new Function("z", `return ${code}${chain}`)(z) as z.ZodType }; +}; + +describe("JSON Schema tuple items", () => { + test("validates nested fixed-length ACL tuples", () => { + const { schema } = compile({ + type: "array", + items: { + type: "array", + minItems: 2, + maxItems: 2, + items: [{ type: "string" }, { type: "string" }], + }, + }); + expect(schema.safeParse([["read", "Firm"]]).success).toBe(true); + for (const value of [[["read"]], [["read", 2]], [["read", "Firm", "extra"]]]) { + expect(schema.safeParse(value).success).toBe(false); + } + }); + test("allows omitted positions and extra items unless constrained", () => { + const { schema } = compile({ type: "array", items: [{ type: "string" }, { type: "number" }] }); + for (const value of [[], ["read"], ["read", 2], ["read", 2, true]]) { + expect(schema.safeParse(value).success).toBe(true); + } + expect(schema.safeParse([2]).success).toBe(false); + }); + test("honors additionalItems schemas and false", () => { + const base = { type: "array", items: [{ type: "string" }], minItems: 1 }; + const closed = compile({ ...base, additionalItems: false }).schema; + expect(closed.safeParse(["read"]).success).toBe(true); + expect(closed.safeParse(["read", 2]).success).toBe(false); + const typed = compile({ ...base, additionalItems: { type: "number" } }).schema; + expect(typed.safeParse(["read", 2]).success).toBe(true); + expect(typed.safeParse(["read", "extra"]).success).toBe(false); + }); +}); diff --git a/src/native/renderer-parity.test.ts b/src/native/renderer-parity.test.ts index b42c68f..b32945d 100644 --- a/src/native/renderer-parity.test.ts +++ b/src/native/renderer-parity.test.ts @@ -8,6 +8,47 @@ import { resolveConfig } from "@/generators/core/resolveConfig"; import { generateFilesFromNativeOpenAPI } from "./generateFilesFromNativeOpenAPI"; describe("renderer naming parity", () => { + test("supports draft-07 tuple arrays in response schemas", () => { + const document = { + openapi: "3.0.3", + info: { title: "Tuple response", version: "1" }, + paths: { + "/rules": { + get: { + operationId: "getRules", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + required: ["aclRules"], + properties: { + aclRules: { + type: "array", + items: { + type: "array", + minItems: 2, + maxItems: 2, + items: [{ type: "string" }, { type: "string" }], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } as unknown as OpenAPIV3.Document; + const options = resolveConfig({ params: {}, fileConfig: { modelsInCommon: true } }); + const expected = generateCodeFromOpenAPIDoc(document, options); + expect(expected.some(({ content }) => content.includes("z.tuple([z.string(), z.string()])"))).toBe(true); + expect(generateFilesFromNativeOpenAPI(JSON.stringify(document), false, options)).toEqual(expected); + }); test("keeps controller names when multiple operations reduce to a reserved word", () => { const document: OpenAPIV3.Document = { openapi: "3.0.3", From bc9db41e8aedbda69acfb7110a01ffa982bf8979 Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Thu, 5 Nov 2026 12:50:08 +0100 Subject: [PATCH 2/2] test: disable ACL rendering in tuple parity fixture --- src/native/renderer-parity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/renderer-parity.test.ts b/src/native/renderer-parity.test.ts index b32945d..3802daa 100644 --- a/src/native/renderer-parity.test.ts +++ b/src/native/renderer-parity.test.ts @@ -44,7 +44,7 @@ describe("renderer naming parity", () => { }, }, } as unknown as OpenAPIV3.Document; - const options = resolveConfig({ params: {}, fileConfig: { modelsInCommon: true } }); + const options = resolveConfig({ params: {}, fileConfig: { modelsInCommon: true, acl: false, checkAcl: false } }); const expected = generateCodeFromOpenAPIDoc(document, options); expect(expected.some(({ content }) => content.includes("z.tuple([z.string(), z.string()])"))).toBe(true); expect(generateFilesFromNativeOpenAPI(JSON.stringify(document), false, options)).toEqual(expected);