diff --git a/.changeset/bright-suits-allow.md b/.changeset/bright-suits-allow.md new file mode 100644 index 00000000..0742e790 --- /dev/null +++ b/.changeset/bright-suits-allow.md @@ -0,0 +1,5 @@ +--- +"openapi-zod-client": minor +--- + +Add support for chained validation to composed types (oneOf, anyOf, allOf) diff --git a/lib/src/openApiToZod.ts b/lib/src/openApiToZod.ts index ae50e0da..dbc97f81 100644 --- a/lib/src/openApiToZod.ts +++ b/lib/src/openApiToZod.ts @@ -84,7 +84,12 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option if (schema.oneOf) { if (schema.oneOf.length === 1) { const type = getZodSchema({ schema: schema.oneOf[0]!, ctx, meta, options }); - return code.assign(type.toString()); + const chain = getZodChain({ + schema: type.schema as SchemaObject, + meta: { ...meta, isRequired: true }, + options + }); + return code.assign(`${type.toString()}${chain}`); } /* when there are multiple allOf we are unable to use a discriminatedUnion as this library adds an @@ -101,7 +106,15 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option } return code.assign( - `z.union([${schema.oneOf.map((prop) => getZodSchema({ schema: prop, ctx, meta, options })).join(", ")}])` + `z.union([${schema.oneOf.map((prop) => { + const type = getZodSchema({ schema: prop, ctx, meta, options }); + const chain = getZodChain({ + schema: prop as SchemaObject, + meta: { ...meta, isRequired: true }, + options + }); + return `${type.toString()}${chain}`; + }).join(", ")}])` ); } @@ -109,24 +122,23 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option if (schema.anyOf) { if (schema.anyOf.length === 1) { const type = getZodSchema({ schema: schema.anyOf[0]!, ctx, meta, options }); - return code.assign(type.toString()); + const chain = getZodChain({ + schema: type.schema as SchemaObject, + meta: { ...meta, isRequired: true }, + options + }); + return code.assign(`${type.toString()}${chain}`); } const types = schema.anyOf .map((prop) => getZodSchema({ schema: prop, ctx, meta, options })) .map((type) => { - let isObject = true; - - if ("type" in type.schema) { - if (Array.isArray(type.schema.type)) { - isObject = false; - } else { - const schemaType = type.schema.type.toLowerCase() as NonNullable; - isObject = !isPrimitiveType(schemaType); - } - } - - return type.toString(); + const chain = getZodChain({ + schema: type.schema as SchemaObject, + meta: { ...meta, isRequired: true }, + options + }); + return `${type.toString()}${chain}`; }) .join(", "); @@ -136,7 +148,12 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option if (schema.allOf) { if (schema.allOf.length === 1) { const type = getZodSchema({ schema: schema.allOf[0]!, ctx, meta, options }); - return code.assign(type.toString()); + const chain = getZodChain({ + schema: type.schema as SchemaObject, + meta: { ...meta, isRequired: true }, + options + }); + return code.assign(`${type.toString()}${chain}`); } const { patchRequiredSchemaInLoop, noRequiredOnlyAllof, composedRequiredSchema } = inferRequiredSchema(schema); @@ -159,10 +176,22 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option const first = types.at(0)!; const rest = types .slice(1) - .map((type) => `and(${type.toString()})`) + .map((type) => { + const chain = getZodChain({ + schema: type.schema as SchemaObject, + meta: { ...meta, isRequired: true }, + options, + }); + return `and(${type.toString()}${chain})`; + }) .join("."); - return code.assign(`${first.toString()}.${rest}`); + const firstChain = getZodChain({ + schema: first.schema as SchemaObject, + meta: { ...meta, isRequired: true }, + options, + }); + return code.assign(`${first.toString()}${firstChain}.${rest}`); } const schemaType = schema.type ? (schema.type.toLowerCase() as NonNullable) : undefined; diff --git a/lib/tests/chain-validations-for-composed-types.test.ts b/lib/tests/chain-validations-for-composed-types.test.ts new file mode 100644 index 00000000..fa21c20b --- /dev/null +++ b/lib/tests/chain-validations-for-composed-types.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, test } from "vitest"; +import type { SchemaObject } from "openapi3-ts"; + +import { type CodeMetaData, getZodSchema } from "../src"; + +const makeSchema = (schema: SchemaObject) => schema; +const getSchemaAsZodString = (schema: SchemaObject, meta?: CodeMetaData | undefined) => + getZodSchema({ schema: makeSchema(schema), meta }).toString(); + +describe("chain-validations-for-composed-types", () => { + // oneOf and anyOf generate identical zod schemas, with the exception + // of discriminated unions + describe.each([ + ['oneOf'], + ['anyOf'] + ])(`%s`, (keyword) => { + test('string validations', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { + type: "string", + minLength: 1, + maxLength: 50, + pattern: '[AB]*', + }, + { + type: "string", + format: "email", + default: "test@email.com", + }, + ] + }, + }, + }) + ).toMatchInlineSnapshot('"z.object({ union: z.union([z.string().min(1).max(50).regex(/[AB]*/), z.string().email().default("test@email.com")]) }).partial().passthrough()"'); + }); + + test('string enum', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { + type: "string", + minLength: 1, + maxLength: 50, + pattern: '[AB]*', + }, + { + type: "string", + enum: ["value1", "value2"] + }, + ] + }, + }, + }) + ).toMatchInlineSnapshot('"z.object({ union: z.union([z.string().min(1).max(50).regex(/[AB]*/), z.enum(["value1", "value2"])]) }).partial().passthrough()"'); + }); + + test('number validations', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { + type: "integer", + minimum: 1, + maximum: 5, + exclusiveMinimum: true, + exclusiveMaximum: true, + }, + { + type: "number", + minimum: 10, + maximum: 30, + multipleOf: 10, + default: 10 + }, + ] + }, + }, + }) + ).toMatchInlineSnapshot('"z.object({ union: z.union([z.number().int().gt(1).lt(5), z.number().gte(10).lte(30).multipleOf(10).default(10)]) }).partial().passthrough()"'); + }); + + test('nullable validation', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { type: "string", minLength: 1 }, + { type: "integer", nullable: true } + ] + }, + }, + required: ['union'] + }) + ).toMatchInlineSnapshot('"z.object({ union: z.union([z.string().min(1), z.number().int().nullable()]) }).passthrough()"'); + }); + + test('array validation', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + maxItems: 5 + }, + { type: "integer" } + ] + }, + }, + required: ['union'] + }) + ).toMatchInlineSnapshot('"z.object({ union: z.union([z.array(z.string().min(1)).min(1).max(5), z.number().int()]) }).passthrough()"'); + }); + + test('single union item', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { type: "string", minLength: 1, nullable: true }, + ] + }, + }, + required: ['union'] + }) + ).toMatchInlineSnapshot('"z.object({ union: z.string().min(1).nullable() }).passthrough()"'); + }); + + test('object union', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + [keyword]: [ + { + type: "object", + properties: { + nest1: { + type: "string", + minLength: 1, + nullable: true, + } + } + }, + { + type: "object", + properties: { + nest2: { + type: "integer", + nullable: true, + } + }, + required: ["nest2"], + } + ] + }, + }, + required: ['union'] + }) + ).toMatchInlineSnapshot('"z.object({ union: z.union([z.object({ nest1: z.string().min(1).nullable() }).partial().passthrough(), z.object({ nest2: z.number().int().nullable() }).passthrough()]) }).passthrough()"'); + }); + }); + + test('oneOf: discriminated union', () => { + console.log(getSchemaAsZodString({ + type: "object", + properties: { + union: { + oneOf: [ + { + type: "object", + properties: { + prop1: { + type: "string", + minLength: 1, + }, + discriminator: { + type: "string", + } + }, + required: ["prop1", "discriminator"], + }, + { + type: "object", + properties: { + prop2: { + type: "string", + minLength: 5, + }, + discriminator: { + type: "string", + } + }, + required: ["prop2", "discriminator"], + }, + ], + discriminator: { propertyName: "discriminator" } + }, + }, + })); + console.log(`z.object({ union: + z.discriminatedUnion("discriminator", [z.object({ prop1: z.string().min(1), discriminator: z.string() }).passthrough(), z.object({ prop2: z.string().min(5), discriminator: z.string() }).passthrough()]) + }).partial().passthrough()`); + expect( + getSchemaAsZodString({ + type: "object", + properties: { + union: { + oneOf: [ + { + type: "object", + properties: { + prop1: { + type: "string", + minLength: 1, + }, + discriminator: { + type: "string", + enum: ["prop1-discriminator"], + } + }, + required: ["prop1", "discriminator"], + }, + { + type: "object", + properties: { + prop2: { + type: "string", + minLength: 5, + }, + discriminator: { + type: "string", + enum: ["prop2-discriminator"], + } + }, + required: ["prop2", "discriminator"], + }, + ], + discriminator: { propertyName: "discriminator" } + }, + }, + }) + ).toMatchInlineSnapshot(` + "z.object({ union: + z.discriminatedUnion("discriminator", [z.object({ prop1: z.string().min(1), discriminator: z.literal("prop1-discriminator") }).passthrough(), z.object({ prop2: z.string().min(5), discriminator: z.literal("prop2-discriminator") }).passthrough()]) + }).partial().passthrough()" + `); + }); + + describe('allOf', () => { + test('string validations', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + prop: { + allOf: [ + { + type: "string", + minLength: 1, + maxLength: 50, + pattern: '[AB]*', + }, + { + type: "string", + format: "email", + default: "test@email.com", + }, + ] + }, + }, + }) + ).toMatchInlineSnapshot('"z.object({ prop: z.string().min(1).max(50).regex(/[AB]*/).and(z.string().email().default("test@email.com")) }).partial().passthrough()"'); + }); + + test('number validations', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + prop: { + allOf: [ + { + type: "integer", + minimum: 1, + maximum: 5, + exclusiveMinimum: true, + exclusiveMaximum: true, + }, + { + type: "number", + minimum: 10, + maximum: 30, + multipleOf: 10, + default: 10 + }, + ] + }, + }, + }) + ).toMatchInlineSnapshot('"z.object({ prop: z.number().int().gt(1).lt(5).and(z.number().gte(10).lte(30).multipleOf(10).default(10)) }).partial().passthrough()"'); + }); + + test('nullable validation', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + prop: { + allOf: [ + { type: "string", minLength: 1 }, + { type: "integer", nullable: true } + ] + }, + }, + required: ['prop'] + }) + ).toMatchInlineSnapshot('"z.object({ prop: z.string().min(1).and(z.number().int().nullable()) }).passthrough()"'); + }); + + test('array validation', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + prop: { + allOf: [ + { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + maxItems: 5 + }, + { type: "integer" } + ] + }, + }, + required: ['prop'] + }) + ).toMatchInlineSnapshot('"z.object({ prop: z.array(z.string().min(1)).min(1).max(5).and(z.number().int()) }).passthrough()"'); + }); + + test('single union item', () => { + expect( + getSchemaAsZodString({ + type: "object", + properties: { + prop: { + allOf: [ + { + type: "string", + minLength: 1, + nullable: true + }, + ] + }, + }, + required: ['prop'] + }) + ).toMatchInlineSnapshot('"z.object({ prop: z.string().min(1).nullable() }).passthrough()"'); + }); + }); +}); \ No newline at end of file diff --git a/lib/tests/export-all-types.test.ts b/lib/tests/export-all-types.test.ts index 6169d27f..226c1e8b 100644 --- a/lib/tests/export-all-types.test.ts +++ b/lib/tests/export-all-types.test.ts @@ -104,7 +104,7 @@ describe("export-all-types", () => { expect(data).toEqual({ schemas: { Settings: "z.object({ theme_color: z.string(), features: Features.min(1) }).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()", + Author: "z.object({ name: z.union([z.string().nullable(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), settings: Settings }).partial().passthrough()", Features: "z.array(z.string())", Song: "z.object({ name: z.string(), duration: z.number() }).partial().passthrough()", Playlist: @@ -207,7 +207,7 @@ describe("export-all-types", () => { .passthrough(); const Author: z.ZodType = z .object({ - name: z.union([z.string(), z.number()]).nullable(), + name: z.union([z.string().nullable(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), diff --git a/lib/tests/missing-zod-chains.test.ts b/lib/tests/missing-zod-chains.test.ts index 69ece46c..7dfbe001 100644 --- a/lib/tests/missing-zod-chains.test.ts +++ b/lib/tests/missing-zod-chains.test.ts @@ -66,7 +66,7 @@ test("missing-zod-chains", async () => { .passthrough(); const nulltype = z.object({}).partial().passthrough(); const anyOfType = z.union([ - z.object({}).partial().passthrough(), + z.object({}).partial().passthrough().nullable(), z.object({ foo: z.string() }).partial().passthrough(), ]);