Repository: RobinTail/express-zod-api (ts)

Issue:
`z.record(z.any())` generates invalid OpenAPI spec
Hi, I've run into some trouble with OpenAPI spec generation of input and output payloads. My goal is to accept an arbitrary object as input. The way I've found that I can accomplish this is with `z.record(z.any())` and that works just fine functionality wise. The issue I run into is that when I generate the docs, I get the following YAML in the OpenAPI spec that's generated:

```yaml
type: object
additionalProperties:
  format: any
  nullable: true
```

When I check the validity of this using a tool like Redocly's CLI, I get the following error:

```
The `type` field must be defined when the `nullable` field is used.

687 |   additionalProperties:
688 |     format: any
689 |     nullable: true
```

From looking at the OpenAPI spec, this is an accurate error. I was wondering if there was a known workaround/better way to handle a schema field for accepting an arbitrary object. Just using `z.any()` results in the same error as above.

If I manually edit the spec and add `type: object` to the `additionalProperties`, the spec stops throwing that error. Here's an example:

```
type: object
additionalProperties:
  format: any
  nullable: true
  type: object
```

I'm wondering if this is an issue with spec generation, or if I'm just trying to tackle this problem the wrong way. If you have any advice on how I should be using Zod to accept an arbitrary object that passes the OpenAPI spec, I'd love to hear.

Thank you!

Pull request description:
OpenAPI fix: avoid `nullable` when `type` is not present
Closes #900 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eae45e0e..5208a55f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
 
 ## Version 10
 
+### v10.1.1
+
+- Fixed issue #900, found and reported by [Max Cohn](https://github.com/maxcohn).
+  - Do not set `nullable` property to the depictions having no `type` property according to OpenAPI specification.
+  - Affected schemas: `z.any()` and `z.preprocess()`.
+
+```yaml
+# depiction of z.any() in the generated documentation
+before:
+  format: any
+  nullable: true
+after:
+  format: any
+```
+
 ### v10.1.0
 
 - Feature #876: Supporting `z.lazy()` (including circular schemas) for the client generator.
diff --git a/example/example.swagger.yaml b/example/example.swagger.yaml
index 982b95f2..fa225f2f 100644
--- a/example/example.swagger.yaml
+++ b/example/example.swagger.yaml
@@ -306,7 +306,6 @@ paths:
                         type: object
                         additionalProperties:
                           format: any
-                          nullable: true
                     required:
                       - name
                       - size
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index 5300ec4d..cc4fdfce 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -688,18 +688,27 @@ export const depicters: HandlingRules<
   ZodLazy: depictLazy,
 };
 
-export const onEach: Depicter<z.ZodTypeAny, "last"> = ({
+export const onEach: Depicter<z.ZodTypeAny, "each"> = ({
   schema,
   isResponse,
+  prev,
 }) => {
+  if (isReferenceObject(prev)) {
+    return {};
+  }
   const { description } = schema;
   const shouldAvoidParsing = schema instanceof z.ZodLazy;
+  const hasTypePropertyInDepiction = prev.type !== undefined;
+  const isResponseHavingCoercion = isResponse && hasCoercion(schema);
+  const isActuallyNullable =
+    !shouldAvoidParsing &&
+    hasTypePropertyInDepiction &&
+    !isResponseHavingCoercion &&
+    schema.isNullable();
   const examples = shouldAvoidParsing ? [] : getExamples(schema, isResponse);
   return {
     ...(description && { description }),
-    ...(!shouldAvoidParsing &&
-      schema.isNullable() &&
-      !(isResponse && hasCoercion(schema)) && { nullable: true }),
+    ...(isActuallyNullable && { nullable: true }),
     ...(examples.length > 0 && { example: examples[0] }),
   };
 };
diff --git a/src/schema-walker.ts b/src/schema-walker.ts
index 421019f8..be9fc73a 100644
--- a/src/schema-walker.ts
+++ b/src/schema-walker.ts
@@ -1,14 +1,15 @@
 import { z } from "zod";
 import { ProprietaryKinds } from "./ez-namespace";
 
-export type HandlingVariant = "last" | "regular";
+export type HandlingVariant = "last" | "regular" | "each";
+
 type VariantDependingProps<
   Variant extends HandlingVariant,
   U
 > = Variant extends "regular"
-  ? {
-      next: SchemaHandler<z.ZodTypeAny, U, {}, "last">;
-    }
+  ? { next: SchemaHandler<z.ZodTypeAny, U, {}, "last"> }
+  : Variant extends "each"
+  ? { prev: U }
   : {};
 
 type SchemaHandlingProps<
@@ -42,11 +43,11 @@ export const walkSchema = <U, Context extends object = {}>({
   onMissing,
   ...context
 }: SchemaHandlingProps<z.ZodTypeAny, U, Context, "last"> & {
-  onEach?: SchemaHandler<z.ZodTypeAny, U, Context, "last">;
+  /** @since 10.1.1 calling onEach _after_ handler and giving it its result */
+  onEach?: SchemaHandler<z.ZodTypeAny, U, Context, "each">;
   rules: HandlingRules<U, Context>;
   onMissing: (schema: z.ZodTypeAny) => U;
 }): U => {
-  const overrides = onEach && onEach({ schema, ...(context as Context) });
   const handler =
     "typeName" in schema._def
       ? rules[schema._def.typeName as keyof typeof rules]
@@ -66,5 +67,7 @@ export const walkSchema = <U, Context extends object = {}>({
         next,
       })
     : onMissing(schema);
+  const overrides =
+    onEach && onEach({ schema, prev: result, ...(context as Context) });
   return overrides ? { ...result, ...overrides } : result;
 };


Test patch:
diff --git a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
index baa29bc9..07a2018b 100644
--- a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
@@ -89,7 +89,6 @@ exports[`Open API helpers depictDiscriminatedUnion() should wrap next depicters
       "properties": {
         "data": {
           "format": "any",
-          "nullable": true,
         },
         "status": {
           "enum": [
@@ -168,14 +167,12 @@ exports[`Open API helpers depictEffect() should depict as string (preprocess) 1`
 exports[`Open API helpers depictEffect() should handle edge cases 1`] = `
 {
   "format": "any",
-  "nullable": true,
 }
 `;
 
 exports[`Open API helpers depictEffect() should handle edge cases 2`] = `
 {
   "format": "any",
-  "nullable": true,
 }
 `;
 
@@ -593,7 +590,7 @@ exports[`Open API helpers depictPipeline should depict as string (in) 1`] = `
 }
 `;
 
-exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 1`] = `
+exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 0 1`] = `
 {
   "additionalProperties": {
     "type": "boolean",
@@ -602,7 +599,7 @@ exports[`Open API helpers depictRecord() should set properties+required or addit
 }
 `;
 
-exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 2`] = `
+exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 1 1`] = `
 {
   "additionalProperties": {
     "type": "boolean",
@@ -611,7 +608,7 @@ exports[`Open API helpers depictRecord() should set properties+required or addit
 }
 `;
 
-exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 3`] = `
+exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 2 1`] = `
 {
   "properties": {
     "one": {
@@ -629,7 +626,7 @@ exports[`Open API helpers depictRecord() should set properties+required or addit
 }
 `;
 
-exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 4`] = `
+exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 3 1`] = `
 {
   "properties": {
     "testing": {
@@ -643,7 +640,7 @@ exports[`Open API helpers depictRecord() should set properties+required or addit
 }
 `;
 
-exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 5`] = `
+exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 4 1`] = `
 {
   "properties": {
     "one": {
@@ -661,6 +658,15 @@ exports[`Open API helpers depictRecord() should set properties+required or addit
 }
 `;
 
+exports[`Open API helpers depictRecord() should set properties+required or additionalProperties props 5 1`] = `
+{
+  "additionalProperties": {
+    "format": "any",
+  },
+  "type": "object",
+}
+`;
+
 exports[`Open API helpers depictRequestParams() should depict none if both query and params are disabled 1`] = `[]`;
 
 exports[`Open API helpers depictRequestParams() should depict only path params if query is disabled 1`] = `
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 54f62396..636b5cdd 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -1615,7 +1615,6 @@ paths:
                               - success
                           data:
                             format: any
-                            nullable: true
                         required:
                           - status
                       - type: object
@@ -2362,7 +2361,6 @@ paths:
                         type: object
                         additionalProperties:
                           format: any
-                          nullable: true
                     required:
                       - name
                       - size
@@ -2828,7 +2826,6 @@ components:
               type: object
               additionalProperties:
                 format: any
-                nullable: true
           required:
             - name
             - size
@@ -3918,7 +3915,6 @@ paths:
                     properties:
                       any:
                         format: any
-                        nullable: true
                 required:
                   - status
                   - data
@@ -3956,7 +3952,6 @@ paths:
           description: GET /v1/getSomething parameter
           schema:
             format: any
-            nullable: true
 components:
   schemas: {}
   responses: {}
@@ -4037,7 +4032,6 @@ paths:
           description: GET /v1/getSomething parameter
           schema:
             format: string (preprocessed)
-            nullable: true
         - name: number
           in: query
           required: true
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index 3ab58dc2..5220375c 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -496,8 +496,9 @@ describe("Open API helpers", () => {
       z.record(z.enum(["one", "two"]), z.boolean()),
       z.record(z.literal("testing"), z.boolean()),
       z.record(z.literal("one").or(z.literal("two")), z.boolean()),
+      z.record(z.any()), // Issue #900
     ])(
-      "should set properties+required or additionalProperties props",
+      "should set properties+required or additionalProperties props %#",
       (schema) => {
         expect(
           depictRecord({