diff --git a/apps/next-app-sandbox/public/openapi.json b/apps/next-app-sandbox/public/openapi.json index da35e67..ca4d04e 100644 --- a/apps/next-app-sandbox/public/openapi.json +++ b/apps/next-app-sandbox/public/openapi.json @@ -486,20 +486,14 @@ "description": "The profile image" }, "avatar": { - "allOf": [ - { - "$ref": "#/components/schemas/Image" - } - ], - "description": "The avatar image" + "$ref": "#/components/schemas/Image", + "description": "The avatar image", + "nullable": true }, "banner": { - "allOf": [ - { - "$ref": "#/components/schemas/Image" - } - ], - "description": "The banner image" + "$ref": "#/components/schemas/Image", + "description": "The banner image", + "nullable": true } }, "required": [ diff --git a/docs/zod4-support-matrix.md b/docs/zod4-support-matrix.md index f079672..faf2070 100644 --- a/docs/zod4-support-matrix.md +++ b/docs/zod4-support-matrix.md @@ -6,25 +6,25 @@ The generator still relies primarily on static AST analysis, but it now uses a s ## Verified Coverage -| Zod 4 construct | Expected emitted shape | OpenAPI targets | Regression coverage | Notes | -| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `z.email()` | `type: "string", format: "email"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Same parity as `z.string().email()` | -| `z.url()` | `type: "string", format: "uri"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Same parity as `z.string().url()` | -| `z.uuid()` | `type: "string", format: "uuid"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#92` | -| `z.iso.datetime()` | `type: "string", format: "date-time"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Nested namespace helper support | -| `z.guid()` / `z.ipv4()` / `z.ipv6()` / `z.iso.duration()` | String formats are preserved in emitted schemas | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/node-helpers.test.ts`, `tests/unit/schema/zod/zod-converter.test.ts` | Added to the AST converter parity path | -| `nullable()` / `nullish()` on supported base schemas | `nullable: true` in `3.0`; `type: [..., "null"]` in `3.1+` | `3.0`, `3.1`, `3.2` | `tests/integration/generator/zod4-support.test.ts`, `tests/integration/validation/openapi-validation.test.ts` | Version finalization rewrites nullable semantics | -| `pipe()` into a stronger schema | Preserves strongest representable base schema | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts` | Used for patterns like `z.string().pipe(z.email())` | -| Runtime-assisted `coerce` / `pipe` variants | Request-side schemas can keep input shapes while responses keep output constraints when the runtime export can prove the difference | `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts` | Falls back to AST behavior when runtime export is unavailable | -| `transform()` / `refine()` / `superRefine()` / `brand()` | Preserve the underlying JSON-schema-compatible base shape | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Runtime-only semantics are not serialized | -| `.describe("text")` / `.meta({ description })` — OpenAPI description | Maps to `description` in emitted schema. `.describe()` and `.meta({ description })` are equivalent. `@deprecated` prefix sets `deprecated: true`. | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/features/modifiers.test.ts` | Idiomatic Zod-native alternative to `@description` JSDoc | -| `.meta({...})` — OpenAPI annotations (Zod v4) | Copies all representable keys into emitted schema: `description`, `examples`, `example`, `deprecated`, `title`, custom `x-*` extensions. The `id` field is treated specially: it overrides the generated component name instead of being emitted as a schema property (see [Component Naming](./jsdoc-reference.md#component-naming)). Example: `z.number().int().positive().meta({ description: "PIM ID", examples: [42, 1337] })` → `{ type: "integer", exclusiveMinimum: 0, description: "PIM ID", examples: [42, 1337] }` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/features/modifiers.test.ts`, `tests/unit/schema/zod/zod-converter.test.ts`, `tests/unit/schema/zod/runtime-exporter.test.ts` | Runtime-assisted path via `z.toJSONSchema()` when `.meta()` is outermost call; AST path for mid-chain usage | -| `z.literal()` with numeric values | Integer values emit `type: "integer"`; float values emit `type: "number"`. A `z.union([z.literal(1), z.literal(2)])` composed entirely of integer literals collapses to `{ type: "integer", enum: [...] }`. Mixed integer/float unions fall through to separate `anyOf` items. | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/features/unions-and-intersections.test.ts`, `tests/unit/schema/zod/features/primitives.test.ts`, `tests/unit/schema/zod/node-helpers.test.ts` | Closes issue `#143` | -| `z.tuple([...])` | Emits tuple-aware arrays with `prefixItems`, `items: false`, and fixed item counts | `3.1`, `3.2` | `tests/unit/schema/zod/node-helpers.test.ts`, `tests/unit/schema/zod/zod-converter-helpers.test.ts` | `3.0` downgrades happen in version finalization | -| Shared imported query schemas | Per-parameter schemas retain `$ref` / `allOf` detail | `3.0`, `3.1`, `3.2` | `tests/unit/schema/typescript/schema-content.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#93` | -| Required fields in `@queryParams` object schemas | Per-parameter `required: true` matches parent schema `required` list | `3.0`, `3.1`, `3.2` | `tests/unit/schema/typescript/schema-content.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#94` | -| Exported `z.infer` aliases in pure-Zod mode | No duplicate component unless alias is explicitly referenced | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#96` | -| `import { z } from "zod/v4"` | Parsed the same as `zod` import path when the local binding is `z` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Also covered in a Pages Router fixture | +| Zod 4 construct | Expected emitted shape | OpenAPI targets | Regression coverage | Notes | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `z.email()` | `type: "string", format: "email"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Same parity as `z.string().email()` | +| `z.url()` | `type: "string", format: "uri"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Same parity as `z.string().url()` | +| `z.uuid()` | `type: "string", format: "uuid"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#92` | +| `z.iso.datetime()` | `type: "string", format: "date-time"` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Nested namespace helper support | +| `z.guid()` / `z.ipv4()` / `z.ipv6()` / `z.iso.duration()` | String formats are preserved in emitted schemas | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/node-helpers.test.ts`, `tests/unit/schema/zod/zod-converter.test.ts` | Added to the AST converter parity path | +| `nullable()` / `nullish()` on base schemas and named schema references | `nullable: true` in `3.0`; `type: [..., "null"]` in `3.1+` for primitive types. For named schema references (producing a `$ref`), emits `anyOf: [{ $ref }, { type: "null" }]`; the version processor downgrades to `{ allOf: [{ $ref }], nullable: true }` for `3.0`. | `3.0`, `3.1`, `3.2` | `tests/integration/generator/zod4-support.test.ts`, `tests/integration/validation/openapi-validation.test.ts`, `tests/integration/regressions/zod-nullability.test.ts` | Closes issue `#142`; version finalization rewrites nullable semantics | +| `pipe()` into a stronger schema | Preserves strongest representable base schema | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts` | Used for patterns like `z.string().pipe(z.email())` | +| Runtime-assisted `coerce` / `pipe` variants | Request-side schemas can keep input shapes while responses keep output constraints when the runtime export can prove the difference | `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts` | Falls back to AST behavior when runtime export is unavailable | +| `transform()` / `refine()` / `superRefine()` / `brand()` | Preserve the underlying JSON-schema-compatible base shape | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Runtime-only semantics are not serialized | +| `.describe("text")` / `.meta({ description })` — OpenAPI description | Maps to `description` in emitted schema. `.describe()` and `.meta({ description })` are equivalent. `@deprecated` prefix sets `deprecated: true`. | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/features/modifiers.test.ts` | Idiomatic Zod-native alternative to `@description` JSDoc | +| `.meta({...})` — OpenAPI annotations (Zod v4) | Copies all representable keys into emitted schema: `description`, `examples`, `example`, `deprecated`, `title`, custom `x-*` extensions. The `id` field is treated specially: it overrides the generated component name instead of being emitted as a schema property (see [Component Naming](./jsdoc-reference.md#component-naming)). Example: `z.number().int().positive().meta({ description: "PIM ID", examples: [42, 1337] })` → `{ type: "integer", exclusiveMinimum: 0, description: "PIM ID", examples: [42, 1337] }` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/features/modifiers.test.ts`, `tests/unit/schema/zod/zod-converter.test.ts`, `tests/unit/schema/zod/runtime-exporter.test.ts` | Runtime-assisted path via `z.toJSONSchema()` when `.meta()` is outermost call; AST path for mid-chain usage | +| `z.literal()` with numeric values | Integer values emit `type: "integer"`; float values emit `type: "number"`. A `z.union([z.literal(1), z.literal(2)])` composed entirely of integer literals collapses to `{ type: "integer", enum: [...] }`. Mixed integer/float unions fall through to separate `anyOf` items. | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/features/unions-and-intersections.test.ts`, `tests/unit/schema/zod/features/primitives.test.ts`, `tests/unit/schema/zod/node-helpers.test.ts` | Closes issue `#143` | +| `z.tuple([...])` | Emits tuple-aware arrays with `prefixItems`, `items: false`, and fixed item counts | `3.1`, `3.2` | `tests/unit/schema/zod/node-helpers.test.ts`, `tests/unit/schema/zod/zod-converter-helpers.test.ts` | `3.0` downgrades happen in version finalization | +| Shared imported query schemas | Per-parameter schemas retain `$ref` / `allOf` detail | `3.0`, `3.1`, `3.2` | `tests/unit/schema/typescript/schema-content.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#93` | +| Required fields in `@queryParams` object schemas | Per-parameter `required: true` matches parent schema `required` list | `3.0`, `3.1`, `3.2` | `tests/unit/schema/typescript/schema-content.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#94` | +| Exported `z.infer` aliases in pure-Zod mode | No duplicate component unless alias is explicitly referenced | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Closes issue `#96` | +| `import { z } from "zod/v4"` | Parsed the same as `zod` import path when the local binding is `z` | `3.0`, `3.1`, `3.2` | `tests/unit/schema/zod/zod-converter.test.ts`, `tests/integration/generator/zod4-support.test.ts`, `tests/integration/generator/zod4-support.test.ts` | Also covered in a Pages Router fixture | ## Checked-In Fixtures diff --git a/packages/openapi-core/src/openapi/version-processor.ts b/packages/openapi-core/src/openapi/version-processor.ts index dee69ad..f4b9313 100644 --- a/packages/openapi-core/src/openapi/version-processor.ts +++ b/packages/openapi-core/src/openapi/version-processor.ts @@ -682,8 +682,10 @@ function downgradeSchemaForOpenApi30(schema: OpenApiSchema, mediaTypeName?: stri const nullableBranch = nextSchema.anyOf.find((item) => item.type === "null"); const baseBranch = nextSchema.anyOf.find((item) => item.type !== "null"); if (nullableBranch && baseBranch) { + const { anyOf: _anyOf, ...outerMeta } = nextSchema; nextSchema = { ...structuredClone(baseBranch), + ...outerMeta, nullable: true, }; } diff --git a/packages/openapi-core/src/schema/zod/zod-converter.ts b/packages/openapi-core/src/schema/zod/zod-converter.ts index 8d4f82f..88b9b5b 100644 --- a/packages/openapi-core/src/schema/zod/zod-converter.ts +++ b/packages/openapi-core/src/schema/zod/zod-converter.ts @@ -1071,10 +1071,17 @@ export class ZodSchemaConverter { // Apply method-specific transformations switch (methodName) { case "optional": + // optional means T | undefined — not in required array, no nullable flag + break; case "nullable": case "nullish": - // Don't add nullable flag here as it would be at the wrong level - // The fact that it's optional is handled by not including it in required array + // Transform allOf to anyOf with null branch to preserve null type + schema = { + anyOf: [ + { $ref: `#/components/schemas/${this.getSchemaReferenceName(schemaName)}` }, + { type: "null" }, + ], + }; break; case "describe": if (node.arguments.length > 0 && t.isStringLiteral(node.arguments[0])) { @@ -1762,13 +1769,19 @@ export class ZodSchemaConverter { break; case "nullable": // nullable means T | null — field stays required but can be null - if (!schema.allOf) { + if (schema.allOf) { + // Transform allOf to anyOf with null branch to preserve null type + schema = { anyOf: [...schema.allOf, { type: "null" }] }; + } else { schema.nullable = true; } break; case "nullish": // T | null | undefined // Not in required array (handled by hasOptionalMethod) AND can be null - if (!schema.allOf) { + if (schema.allOf) { + // Transform allOf to anyOf with null branch to preserve null type + schema = { anyOf: [...schema.allOf, { type: "null" }] }; + } else { schema.nullable = true; } break; diff --git a/tests/integration/regressions/zod-nullability.test.ts b/tests/integration/regressions/zod-nullability.test.ts index 44c1093..8929ed2 100644 --- a/tests/integration/regressions/zod-nullability.test.ts +++ b/tests/integration/regressions/zod-nullability.test.ts @@ -40,6 +40,66 @@ describe("Zod nullability regressions", () => { } }); + it("preserves null type when .nullish() is applied to a named schema reference (issue #142)", () => { + const testDir = setup(` + import { z } from "zod"; + + export const addressSchema = z.object({ street: z.string(), city: z.string() }).meta({ id: 'Address' }); + export const personSchema = z.object({ + name: z.string(), + address: addressSchema.nullish(), + }).meta({ id: 'Person' }); + `); + + try { + const converter = new ZodSchemaConverter(testDir); + const schema = converter.convertZodSchemaToOpenApi("personSchema"); + + const addressProp = schema?.properties?.address; + expect(addressProp).toBeDefined(); + // Must use anyOf with a null branch — NOT allOf without null + expect(addressProp?.anyOf).toBeDefined(); + expect(addressProp?.anyOf).toHaveLength(2); + expect(addressProp?.anyOf).toContainEqual({ $ref: "#/components/schemas/Address" }); + expect(addressProp?.anyOf).toContainEqual({ type: "null" }); + // Must NOT have allOf without null branch + expect(addressProp?.allOf).toBeUndefined(); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("preserves null type when .nullable() is applied to a named schema reference (issue #142)", () => { + const testDir = setup(` + import { z } from "zod"; + + export const addressSchema = z.object({ street: z.string(), city: z.string() }).meta({ id: 'Address' }); + export const personSchema = z.object({ + name: z.string(), + address: addressSchema.nullable(), + }).meta({ id: 'Person' }); + `); + + try { + const converter = new ZodSchemaConverter(testDir); + const schema = converter.convertZodSchemaToOpenApi("personSchema"); + + const addressProp = schema?.properties?.address; + expect(addressProp).toBeDefined(); + // Must use anyOf with a null branch — NOT allOf without null + expect(addressProp?.anyOf).toBeDefined(); + expect(addressProp?.anyOf).toHaveLength(2); + expect(addressProp?.anyOf).toContainEqual({ $ref: "#/components/schemas/Address" }); + expect(addressProp?.anyOf).toContainEqual({ type: "null" }); + // Must NOT have allOf without null branch + expect(addressProp?.allOf).toBeUndefined(); + // nullable field stays required + expect(schema?.required).toContain("address"); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + it("preserves optionality and nullability through validation chains", () => { const testDir = setup(` import { z } from "zod";