Skip to content
Merged
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
48 changes: 46 additions & 2 deletions native/src/zod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Result<Vec<_>, 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,
Expand Down Expand Up @@ -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})")
});
}
}
_ => {}
Expand Down
5 changes: 3 additions & 2 deletions src/generators/core/zod/getZodChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(".");
Expand Down
19 changes: 19 additions & 0 deletions src/generators/core/zod/getZodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions src/generators/core/zod/tuple.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions src/native/renderer-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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);
});
test("keeps controller names when multiple operations reduce to a reserved word", () => {
const document: OpenAPIV3.Document = {
openapi: "3.0.3",
Expand Down
Loading