Repository: RobinTail/express-zod-api
Pull request #856 (2023-03-06 20:28:48)

Feat: Supporting recursive types and `z.lazy()`
Discussion #801 

RobinTail: Actually I forget one more thing: zts — need to support ZodLazy in client generator

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5bb1e84..c7e7c84a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,35 @@
 
 ## Version 9
 
+### v9.3.0
+
+- Feature #856, proposed by [@TheWisestOne](https://github.com/TheWisestOne) in discussion #801.
+  - Supporting `z.lazy()` in the documentation generator (OpenAPI), including circular schemas.
+  - The feature is only available for the OpenAPI generator, it's not available for the client generator yet.
+  - OpenAPI references are utilized in order to limit the possible recursion.
+  - A new optional property added to the constructor of the OpenAPI class:
+    - `serializer` is the function that accepts a schema and returns its unique identifier in order to compare them.
+    - When omitted, the default one used, which is `JSON.stringify()` + `SHA1` hash as a `hex` digest.
+    - If/when it's not enough precise, consider specifying your own implementation.
+
+```yaml
+# having z.lazy() within your IO schema
+before:
+  error: Zod type ZodLazy is unsupported
+after:
+  schema:
+    type: object
+    properties:
+      lazyProperty:
+        $ref: 2048581c137c5b2130eb860e3ae37da196dfc25b # sample reference
+  components:
+    schemas:
+      2048581c137c5b2130eb860e3ae37da196dfc25b:
+        type: array
+        items:
+          $ref: 2048581c137c5b2130eb860e3ae37da196dfc25b # circular reference
+```
+
 ### v9.2.1
 
 - `zod` version is 3.21.4.
diff --git a/src/client.ts b/src/client.ts
index 55a9b36c..e7e41c23 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -39,6 +39,7 @@ export class Client {
   protected registry: Registry = {};
   protected paths: string[] = [];
 
+  // @todo for v10: change to an object, similar to OpenAPI
   constructor(routing: Routing) {
     walkRouting({
       routing,
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index 2d838f60..c337e5a5 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -1,3 +1,4 @@
+import { createHash } from "crypto";
 import {
   ContentObject,
   ExampleObject,
@@ -5,6 +6,7 @@ import {
   MediaTypeObject,
   OAuthFlowsObject,
   ParameterObject,
+  ReferenceObject,
   RequestBodyObject,
   ResponseObject,
   SchemaObject,
@@ -12,6 +14,8 @@ import {
   SecurityRequirementObject,
   SecuritySchemeObject,
   TagObject,
+  isReferenceObject,
+  isSchemaObject,
 } from "openapi3-ts";
 import { omit } from "ramda";
 import { z } from "zod";
@@ -50,14 +54,21 @@ type MediaExamples = Pick<MediaTypeObject, "examples">;
 
 export interface OpenAPIContext {
   isResponse: boolean;
+  serializer: (schema: z.ZodTypeAny) => string;
+  hasRef: (name: string) => boolean;
+  makeRef: (
+    name: string,
+    schema: SchemaObject | ReferenceObject
+  ) => ReferenceObject;
 }
 
 type Depicter<
   T extends z.ZodTypeAny,
   Variant extends HandlingVariant = "regular"
-> = SchemaHandler<T, SchemaObject, OpenAPIContext, Variant>;
+> = SchemaHandler<T, SchemaObject | ReferenceObject, OpenAPIContext, Variant>;
 
-interface ReqResDepictHelperCommonProps {
+interface ReqResDepictHelperCommonProps
+  extends Pick<OpenAPIContext, "serializer" | "hasRef" | "makeRef"> {
   method: Method;
   path: string;
   endpoint: AbstractEndpoint;
@@ -187,7 +198,7 @@ export const depictLiteral: Depicter<z.ZodLiteral<any>> = ({
 export const depictObject: Depicter<z.AnyZodObject> = ({
   schema,
   isResponse,
-  next,
+  ...rest
 }) => {
   const required = Object.keys(schema.shape).filter((key) => {
     const prop = schema.shape[key];
@@ -199,7 +210,7 @@ export const depictObject: Depicter<z.AnyZodObject> = ({
   });
   return {
     type: "object",
-    properties: depictObjectProperties({ schema, isResponse, next }),
+    properties: depictObjectProperties({ schema, isResponse, ...rest }),
     ...(required.length ? { required } : {}),
   };
 };
@@ -265,8 +276,7 @@ export const depictBigInt: Depicter<z.ZodBigInt> = () => ({
 
 export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
   schema: { keySchema, valueSchema },
-  isResponse,
-  next,
+  ...rest
 }) => {
   if (keySchema instanceof z.ZodEnum || keySchema instanceof z.ZodNativeEnum) {
     const keys = Object.values(keySchema.enum) as string[];
@@ -281,8 +291,7 @@ export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
       type: "object",
       properties: depictObjectProperties({
         schema: z.object(shape),
-        isResponse,
-        next,
+        ...rest,
       }),
       ...(keys.length ? { required: keys } : {}),
     };
@@ -294,8 +303,7 @@ export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
         schema: z.object({
           [keySchema.value]: valueSchema,
         }),
-        isResponse,
-        next,
+        ...rest,
       }),
       required: [keySchema.value],
     };
@@ -318,8 +326,7 @@ export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
         type: "object",
         properties: depictObjectProperties({
           schema: z.object(shape),
-          isResponse,
-          next,
+          ...rest,
         }),
         required: keySchema.options.map(
           (option: z.ZodLiteral<any>) => option.value
@@ -329,7 +336,7 @@ export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
   }
   return {
     type: "object",
-    additionalProperties: next({ schema: valueSchema }),
+    additionalProperties: rest.next({ schema: valueSchema }),
   };
 };
 
@@ -358,7 +365,10 @@ export const depictTuple: Depicter<z.ZodTuple> = ({
       format: "tuple",
       ...(types.length > 0 && {
         description: types
-          .map((item, index) => `${index}: ${item.type}`)
+          .map(
+            (item, index) =>
+              `${index}: ${isSchemaObject(item) ? item.type : item.$ref}`
+          )
           .join(", "),
       }),
     },
@@ -454,7 +464,7 @@ export const depictObjectProperties = ({
       ...carry,
       [key]: next({ schema: shape[key] }),
     }),
-    {} as Record<string, SchemaObject>
+    {} as Record<string, SchemaObject | ReferenceObject>
   );
 };
 
@@ -472,7 +482,7 @@ export const depictEffect: Depicter<z.ZodEffects<z.ZodTypeAny>> = ({
 }) => {
   const input = next({ schema: schema.innerType() });
   const { effect } = schema._def;
-  if (isResponse && effect.type === "transform") {
+  if (isResponse && effect.type === "transform" && isSchemaObject(input)) {
     const outputType = tryToTransform({ effect, sample: makeSample(input) });
     if (outputType && ["number", "string", "boolean"].includes(outputType)) {
       return { type: outputType as "number" | "string" | "boolean" };
@@ -480,7 +490,7 @@ export const depictEffect: Depicter<z.ZodEffects<z.ZodTypeAny>> = ({
       return next({ schema: z.any() });
     }
   }
-  if (!isResponse && effect.type === "preprocess") {
+  if (!isResponse && effect.type === "preprocess" && isSchemaObject(input)) {
     const { type: inputType, ...rest } = input;
     return {
       ...rest,
@@ -501,6 +511,25 @@ export const depictBranded: Depicter<z.ZodBranded<z.ZodTypeAny, any>> = ({
   next,
 }) => next({ schema: schema.unwrap() });
 
+export const defaultSerializer = (schema: z.ZodTypeAny): string =>
+  createHash("sha1").update(JSON.stringify(schema), "utf8").digest("hex");
+
+export const depictLazy: Depicter<z.ZodLazy<z.ZodTypeAny>> = ({
+  next,
+  schema: lazy,
+  serializer: serialize,
+  hasRef,
+  makeRef,
+}): ReferenceObject => {
+  const hash = serialize(lazy.schema);
+  if (hasRef(hash)) {
+    return { $ref: hash };
+  }
+  const ref = makeRef!(hash, {}); // make empty ref first
+  makeRef(hash, next({ schema: lazy.schema })); // update
+  return ref;
+};
+
 export const depictExamples = (
   schema: z.ZodTypeAny,
   isResponse: boolean,
@@ -581,6 +610,9 @@ export const depictRequestParams = ({
   method,
   endpoint,
   inputSources,
+  serializer,
+  hasRef,
+  makeRef,
 }: ReqResDepictHelperCommonProps & {
   inputSources: InputSource[];
 }): ParameterObject[] => {
@@ -605,13 +637,19 @@ export const depictRequestParams = ({
           rules: depicters,
           onEach,
           onMissing,
+          serializer,
+          hasRef,
+          makeRef,
         }),
       },
       ...depictParamExamples(schema, false, name),
     }));
 };
 
-export const depicters: HandlingRules<SchemaObject, OpenAPIContext> = {
+export const depicters: HandlingRules<
+  SchemaObject | ReferenceObject,
+  OpenAPIContext
+> = {
   ZodString: depictString,
   ZodNumber: depictNumber,
   ZodBigInt: depictBigInt,
@@ -640,6 +678,7 @@ export const depicters: HandlingRules<SchemaObject, OpenAPIContext> = {
   ZodDate: depictDate,
   ZodCatch: depictCatch,
   ZodPipeline: depictPipeline,
+  ZodLazy: depictLazy,
 };
 
 export const onEach: Depicter<z.ZodTypeAny, "last"> = ({
@@ -647,10 +686,12 @@ export const onEach: Depicter<z.ZodTypeAny, "last"> = ({
   isResponse,
 }) => {
   const { description } = schema;
-  const examples = getExamples(schema, isResponse);
+  const shouldAvoidParsing = schema instanceof z.ZodLazy;
+  const examples = shouldAvoidParsing ? [] : getExamples(schema, isResponse);
   return {
     ...(description && { description }),
-    ...(schema.isNullable() &&
+    ...(!shouldAvoidParsing &&
+      schema.isNullable() &&
       !(isResponse && hasCoercion(schema)) && { nullable: true }),
     ...(examples.length > 0 && { example: examples[0] }),
   };
@@ -661,9 +702,12 @@ export const onMissing = (schema: z.ZodTypeAny) => {
 };
 
 export const excludeParamsFromDepiction = (
-  depicted: SchemaObject,
+  depicted: SchemaObject | ReferenceObject,
   pathParams: string[]
-): SchemaObject => {
+): SchemaObject | ReferenceObject => {
+  if (isReferenceObject(depicted)) {
+    return depicted;
+  }
   const properties = depicted.properties
     ? omit(pathParams, depicted.properties)
     : undefined;
@@ -700,8 +744,9 @@ export const excludeParamsFromDepiction = (
 };
 
 export const excludeExampleFromDepiction = (
-  depicted: SchemaObject
-): SchemaObject => omit(["example"], depicted);
+  depicted: SchemaObject | ReferenceObject
+): SchemaObject | ReferenceObject =>
+  isSchemaObject(depicted) ? omit(["example"], depicted) : depicted;
 
 export const depictResponse = ({
   method,
@@ -709,16 +754,15 @@ export const depictResponse = ({
   description,
   endpoint,
   isPositive,
+  serializer,
+  hasRef,
+  makeRef,
 }: ReqResDepictHelperCommonProps & {
   description: string;
   isPositive: boolean;
 }): ResponseObject => {
-  const schema = isPositive
-    ? endpoint.getSchema("positive")
-    : endpoint.getSchema("negative");
-  const mimeTypes = isPositive
-    ? endpoint.getMimeTypes("positive")
-    : endpoint.getMimeTypes("negative");
+  const schema = endpoint.getSchema(isPositive ? "positive" : "negative");
+  const mimeTypes = endpoint.getMimeTypes(isPositive ? "positive" : "negative");
   const depictedSchema = excludeExampleFromDepiction(
     walkSchema({
       schema,
@@ -726,6 +770,9 @@ export const depictResponse = ({
       rules: depicters,
       onEach,
       onMissing,
+      serializer,
+      hasRef,
+      makeRef,
     })
   );
   const examples = depictExamples(schema, true);
@@ -842,6 +889,9 @@ export const depictRequest = ({
   method,
   path,
   endpoint,
+  serializer,
+  hasRef,
+  makeRef,
 }: ReqResDepictHelperCommonProps): RequestBodyObject => {
   const pathParams = getRoutePathParams(path);
   const bodyDepiction = excludeExampleFromDepiction(
@@ -852,6 +902,9 @@ export const depictRequest = ({
         rules: depicters,
         onEach,
         onMissing,
+        serializer,
+        hasRef,
+        makeRef,
       }),
       pathParams
     )
diff --git a/src/open-api.ts b/src/open-api.ts
index 24d9b68f..7346e8b5 100644
--- a/src/open-api.ts
+++ b/src/open-api.ts
@@ -1,14 +1,18 @@
 import {
   OpenApiBuilder,
   OperationObject,
+  ReferenceObject,
+  SchemaObject,
   SecuritySchemeObject,
   SecuritySchemeType,
 } from "openapi3-ts";
+import { z } from "zod";
 import { defaultInputSources, makeCleanId } from "./common-helpers";
 import { CommonConfig } from "./config-type";
 import { mapLogicalContainer } from "./logical-container";
 import { Method } from "./method";
 import {
+  defaultSerializer,
   depictRequest,
   depictRequestParams,
   depictResponse,
@@ -33,6 +37,11 @@ interface GeneratorParams {
   errorResponseDescription?: string;
   /** @default true */
   hasSummaryFromDescription?: boolean;
+  /**
+   * @desc Used for comparing schemas wrapped into z.lazy() to limit the recursion
+   * @default JSON.stringify() + SHA1 hash as a hex digest
+   * */
+  serializer?: (schema: z.ZodTypeAny) => string;
 }
 
 export class OpenAPI extends OpenApiBuilder {
@@ -40,6 +49,18 @@ export class OpenAPI extends OpenApiBuilder {
     {};
   protected lastOperationIdSuffixes: Record<string, number> = {};
 
+  protected makeRef(
+    name: string,
+    schema: SchemaObject | ReferenceObject
+  ): ReferenceObject {
+    this.addSchema(name, schema);
+    return { $ref: name };
+  }
+
+  protected hasRef(name: string): boolean {
+    return name in (this.rootDoc.components?.schemas || {});
+  }
+
   protected ensureUniqOperationId(path: string, method: Method) {
     const operationId = makeCleanId(path, method);
     if (operationId in this.lastOperationIdSuffixes) {
@@ -51,9 +72,10 @@ export class OpenAPI extends OpenApiBuilder {
   }
 
   protected ensureUniqSecuritySchemaName(subject: SecuritySchemeObject) {
+    const serializedSubject = JSON.stringify(subject);
     for (const name in this.rootDoc.components?.securitySchemes || {}) {
       if (
-        JSON.stringify(subject) ===
+        serializedSubject ===
         JSON.stringify(this.rootDoc.components?.securitySchemes?.[name])
       ) {
         return name;
@@ -75,6 +97,7 @@ export class OpenAPI extends OpenApiBuilder {
     successfulResponseDescription = "Successful response",
     errorResponseDescription = "Error response",
     hasSummaryFromDescription = true,
+    serializer = defaultSerializer,
   }: GeneratorParams) {
     super();
     this.addInfo({ title, version }).addServer({ url: serverUrl });
@@ -84,7 +107,14 @@ export class OpenAPI extends OpenApiBuilder {
       _method
     ) => {
       const method = _method as Method;
-      const commonParams = { path, method, endpoint };
+      const commonParams = {
+        path,
+        method,
+        endpoint,
+        serializer,
+        hasRef: this.hasRef.bind(this),
+        makeRef: this.makeRef.bind(this),
+      };
       const [shortDesc, longDesc] = (["short", "long"] as const).map(
         endpoint.getDescription.bind(endpoint)
       );


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 76d30cb4..e5cd2b22 100644
--- a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
@@ -299,6 +299,53 @@ exports[`Open API helpers depictIntersection() should wrap next depicters in all
 }
 `;
 
+exports[`Open API helpers depictLazy should handle circular references 0 1`] = `
+{
+  "$ref": "6cbbd837811754902ea1e68d3e5c75e36250b880",
+}
+`;
+
+exports[`Open API helpers depictLazy should handle circular references 0 2`] = `
+{
+  "items": {
+    "$ref": "6cbbd837811754902ea1e68d3e5c75e36250b880",
+  },
+  "type": "array",
+}
+`;
+
+exports[`Open API helpers depictLazy should handle circular references 1 1`] = `
+{
+  "$ref": "7a225c55e65ab4a2fd3ce390265b255ee6747049",
+}
+`;
+
+exports[`Open API helpers depictLazy should handle circular references 1 2`] = `
+{
+  "$ref": "7a225c55e65ab4a2fd3ce390265b255ee6747049",
+}
+`;
+
+exports[`Open API helpers depictLazy should handle circular references 2 1`] = `
+{
+  "$ref": "118cb3b11b8a1f3b6b1e60a89f96a8be9da32a0f",
+}
+`;
+
+exports[`Open API helpers depictLazy should handle circular references 2 2`] = `
+{
+  "properties": {
+    "prop": {
+      "$ref": "118cb3b11b8a1f3b6b1e60a89f96a8be9da32a0f",
+    },
+  },
+  "required": [
+    "prop",
+  ],
+  "type": "object",
+}
+`;
+
 exports[`Open API helpers depictLiteral() should set type and involve enum property 1`] = `
 {
   "enum": [
@@ -987,6 +1034,12 @@ exports[`Open API helpers excludeExampleFromDepiction() should remove example pr
 }
 `;
 
+exports[`Open API helpers excludeParamsFromDepiction() should handle the ReferenceObject 1`] = `
+{
+  "$ref": "test",
+}
+`;
+
 exports[`Open API helpers excludeParamsFromDepiction() should omit specified path params 0 1`] = `
 {
   "properties": {
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 00743293..469285de 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -2426,6 +2426,111 @@ servers:
 "
 `;
 
+exports[`Open API generator generateOpenApi() should handle circular schemas via z.lazy() 1`] = `
+"openapi: 3.0.0
+info:
+  title: Testing Lazy
+  version: 3.4.5
+paths:
+  /v1/getSomething:
+    post:
+      operationId: PostV1Getsomething
+      responses:
+        "200":
+          description: POST /v1/getSomething Successful response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - success
+                  data:
+                    type: object
+                    properties:
+                      zodExample:
+                        type: object
+                        properties:
+                          name:
+                            type: string
+                          subcategories:
+                            $ref: 2048581c137c5b2130eb860e3ae37da196dfc25b
+                        required:
+                          - name
+                          - subcategories
+                    required:
+                      - zodExample
+                required:
+                  - status
+                  - data
+        "400":
+          description: POST /v1/getSomething Error response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - error
+                  error:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+                    required:
+                      - message
+                required:
+                  - status
+                  - error
+              examples:
+                example1:
+                  value:
+                    status: error
+                    error:
+                      message: Sample error message
+      requestBody:
+        content:
+          application/json:
+            schema:
+              description: POST /v1/getSomething request body
+              type: object
+              properties:
+                name:
+                  type: string
+              required:
+                - name
+components:
+  schemas:
+    2048581c137c5b2130eb860e3ae37da196dfc25b:
+      type: array
+      items:
+        type: object
+        properties:
+          name:
+            type: string
+          subcategories:
+            $ref: 2048581c137c5b2130eb860e3ae37da196dfc25b
+        required:
+          - name
+          - subcategories
+  responses: {}
+  parameters: {}
+  examples: {}
+  requestBodies: {}
+  headers: {}
+  securitySchemes: {}
+  links: {}
+  callbacks: {}
+tags: []
+servers:
+  - url: http://example.com
+"
+`;
+
 exports[`Open API generator generateOpenApi() should handle custom mime types and status codes 1`] = `
 "openapi: 3.0.0
 info:
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index 29050142..b03a59e7 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -1,4 +1,4 @@
-import { SchemaObject } from "openapi3-ts";
+import { ReferenceObject, SchemaObject } from "openapi3-ts";
 import { IOSchemaError } from "../../src/errors";
 import {
   OpenAPIError,
@@ -9,6 +9,7 @@ import {
 import { getMeta } from "../../src/metadata";
 import {
   OpenAPIContext,
+  defaultSerializer,
   depictAny,
   depictArray,
   depictBigInt,
@@ -25,6 +26,7 @@ import {
   depictExamples,
   depictFile,
   depictIntersection,
+  depictLazy,
   depictLiteral,
   depictNull,
   depictNullable,
@@ -56,12 +58,33 @@ import { SchemaHandler, walkSchema } from "../../src/schema-walker";
 import { serializeSchemaForTest } from "../helpers";
 
 describe("Open API helpers", () => {
-  const requestContext: OpenAPIContext = { isResponse: false };
-  const responseContext: OpenAPIContext = { isResponse: true };
+  const hasRefMock = jest.fn();
+  const makeRefMock = jest.fn(
+    (name: string, {}: SchemaObject | ReferenceObject): ReferenceObject => ({
+      $ref: name,
+    })
+  );
+  const requestContext: OpenAPIContext = {
+    isResponse: false,
+    hasRef: hasRefMock,
+    makeRef: makeRefMock,
+    serializer: defaultSerializer,
+  };
+  const responseContext: OpenAPIContext = {
+    isResponse: true,
+    hasRef: hasRefMock,
+    makeRef: makeRefMock,
+    serializer: defaultSerializer,
+  };
   const makeNext =
     (
       context: OpenAPIContext
-    ): SchemaHandler<z.ZodTypeAny, SchemaObject, {}, "last"> =>
+    ): SchemaHandler<
+      z.ZodTypeAny,
+      SchemaObject | ReferenceObject,
+      {},
+      "last"
+    > =>
     ({ schema }) =>
       walkSchema({
         schema,
@@ -71,6 +94,11 @@ describe("Open API helpers", () => {
         onMissing,
       });
 
+  beforeEach(() => {
+    hasRefMock.mockClear();
+    makeRefMock.mockClear();
+  });
+
   describe("extractObjectSchema()", () => {
     test("should pass the object schema through", () => {
       const subject = extractObjectSchema(z.object({ one: z.string() }));
@@ -163,6 +191,12 @@ describe("Open API helpers", () => {
       });
       expect(excludeParamsFromDepiction(depicted, ["a"])).toMatchSnapshot();
     });
+
+    test("should handle the ReferenceObject", () => {
+      expect(
+        excludeParamsFromDepiction({ $ref: "test" }, ["a"])
+      ).toMatchSnapshot();
+    });
   });
 
   describe("reformatParamsInPath()", () => {
@@ -312,7 +346,7 @@ describe("Open API helpers", () => {
   });
 
   describe("depictOptional()", () => {
-    test.each<OpenAPIContext>([{ isResponse: false }, { isResponse: true }])(
+    test.each<OpenAPIContext>([requestContext, responseContext])(
       "should pass the next depicter %#",
       (context) => {
         expect(
@@ -327,7 +361,7 @@ describe("Open API helpers", () => {
   });
 
   describe("depictNullable()", () => {
-    test.each<OpenAPIContext>([{ isResponse: false }, { isResponse: true }])(
+    test.each<OpenAPIContext>([requestContext, responseContext])(
       "should set nullable:true %#",
       (context) => {
         expect(
@@ -373,30 +407,30 @@ describe("Open API helpers", () => {
   });
 
   describe("depictObject()", () => {
-    test.each<OpenAPIContext & { shape: z.ZodRawShape }>([
+    test.each<{ context: OpenAPIContext; shape: z.ZodRawShape }>([
       {
-        isResponse: false,
+        context: requestContext,
         shape: { a: z.number(), b: z.string() },
       },
       {
-        isResponse: true,
+        context: responseContext,
         shape: { a: z.number(), b: z.string() },
       },
       {
-        isResponse: true,
+        context: responseContext,
         shape: { a: z.coerce.number(), b: z.string({ coerce: true }) },
       },
       {
-        isResponse: true,
+        context: responseContext,
         shape: { a: z.number(), b: z.string().optional() },
       },
       {
-        isResponse: false,
+        context: requestContext,
         shape: { a: z.number().optional(), b: z.coerce.string() },
       },
     ])(
       "should type:object, properties and required props %#",
-      ({ shape, ...context }) => {
+      ({ shape, context }) => {
         expect(
           depictObject({
             schema: z.object(shape),
@@ -565,32 +599,34 @@ describe("Open API helpers", () => {
   });
 
   describe("depictEffect()", () => {
-    test.each<OpenAPIContext & { schema: z.ZodEffects<any>; expected: string }>(
-      [
-        {
-          schema: z.string().transform((v) => parseInt(v, 10)),
-          isResponse: true,
-          expected: "number (out)",
-        },
-        {
-          schema: z.string().transform((v) => parseInt(v, 10)),
-          isResponse: false,
-          expected: "string (in)",
-        },
-        {
-          schema: z.preprocess((v) => parseInt(`${v}`, 10), z.string()),
-          isResponse: false,
-          expected: "string (preprocess)",
-        },
-        {
-          schema: z
-            .object({ s: z.string() })
-            .refine(() => false, { message: "test" }),
-          isResponse: false,
-          expected: "object (refinement)",
-        },
-      ]
-    )("should depict as $expected", ({ schema, ...context }) => {
+    test.each<{
+      context: OpenAPIContext;
+      schema: z.ZodEffects<any>;
+      expected: string;
+    }>([
+      {
+        schema: z.string().transform((v) => parseInt(v, 10)),
+        context: responseContext,
+        expected: "number (out)",
+      },
+      {
+        schema: z.string().transform((v) => parseInt(v, 10)),
+        context: requestContext,
+        expected: "string (in)",
+      },
+      {
+        schema: z.preprocess((v) => parseInt(`${v}`, 10), z.string()),
+        context: requestContext,
+        expected: "string (preprocess)",
+      },
+      {
+        schema: z
+          .object({ s: z.string() })
+          .refine(() => false, { message: "test" }),
+        context: requestContext,
+        expected: "object (refinement)",
+      },
+    ])("should depict as $expected", ({ schema, context }) => {
       expect(
         depictEffect({
           schema,
@@ -617,10 +653,10 @@ describe("Open API helpers", () => {
   });
 
   describe("depictPipeline", () => {
-    test.each<OpenAPIContext & { expected: string }>([
-      { isResponse: true, expected: "boolean (out)" },
-      { isResponse: false, expected: "string (in)" },
-    ])("should depict as $expected", (context) => {
+    test.each<{ context: OpenAPIContext; expected: string }>([
+      { context: responseContext, expected: "boolean (out)" },
+      { context: requestContext, expected: "string (in)" },
+    ])("should depict as $expected", ({ context }) => {
       expect(
         depictPipeline({
           schema: z.string().pipe(z.coerce.boolean()),
@@ -632,7 +668,7 @@ describe("Open API helpers", () => {
   });
 
   describe("depictExamples()", () => {
-    test.each<OpenAPIContext & Record<"case" | "action", string>>([
+    test.each<{ isResponse: boolean } & Record<"case" | "action", string>>([
       { isResponse: false, case: "request", action: "pass" },
       { isResponse: true, case: "response", action: "transform" },
     ])("should $action examples in case of $case", ({ isResponse }) => {
@@ -663,7 +699,7 @@ describe("Open API helpers", () => {
   });
 
   describe("depictParamExamples()", () => {
-    test.each<OpenAPIContext & Record<"case" | "action", string>>([
+    test.each<{ isResponse: boolean } & Record<"case" | "action", string>>([
       { isResponse: false, case: "request", action: "pass" },
       { isResponse: true, case: "response", action: "transform" },
     ])("should $action examples in case of $case", ({ isResponse }) => {
@@ -709,6 +745,9 @@ describe("Open API helpers", () => {
             handler: jest.fn(),
           }),
           inputSources: ["query", "params"],
+          hasRef: hasRefMock,
+          makeRef: makeRefMock,
+          serializer: defaultSerializer,
         })
       ).toMatchSnapshot();
     });
@@ -728,6 +767,9 @@ describe("Open API helpers", () => {
             handler: jest.fn(),
           }),
           inputSources: ["body", "params"],
+          hasRef: hasRefMock,
+          makeRef: makeRefMock,
+          serializer: defaultSerializer,
         })
       ).toMatchSnapshot();
     });
@@ -747,6 +789,9 @@ describe("Open API helpers", () => {
             handler: jest.fn(),
           }),
           inputSources: ["body"],
+          hasRef: hasRefMock,
+          makeRef: makeRefMock,
+          serializer: defaultSerializer,
         })
       ).toMatchSnapshot();
     });
@@ -815,7 +860,7 @@ describe("Open API helpers", () => {
   });
 
   describe("depictDate", () => {
-    test.each<OpenAPIContext>([{ isResponse: true }, { isResponse: false }])(
+    test.each<OpenAPIContext>([responseContext, requestContext])(
       "should throw clear error %#",
       (context) => {
         try {
@@ -845,6 +890,51 @@ describe("Open API helpers", () => {
     });
   });
 
+  describe("depictLazy", () => {
+    const recursiveArray: z.ZodLazy<z.ZodArray<any>> = z.lazy(() =>
+      recursiveArray.array()
+    );
+    const directlyRecursive: z.ZodLazy<any> = z.lazy(() => directlyRecursive);
+    const recursiveObject: z.ZodLazy<z.ZodObject<any>> = z.lazy(() =>
+      z.object({ prop: recursiveObject })
+    );
+
+    test.each([
+      {
+        schema: recursiveArray,
+        hash: "6cbbd837811754902ea1e68d3e5c75e36250b880",
+      },
+      {
+        schema: directlyRecursive,
+        hash: "7a225c55e65ab4a2fd3ce390265b255ee6747049",
+      },
+      {
+        schema: recursiveObject,
+        hash: "118cb3b11b8a1f3b6b1e60a89f96a8be9da32a0f",
+      },
+    ])("should handle circular references %#", ({ schema, hash }) => {
+      hasRefMock
+        .mockImplementationOnce(() => false)
+        .mockImplementationOnce(() => true);
+      expect(hasRefMock.mock.calls.length).toBe(0);
+      expect(
+        depictLazy({
+          schema,
+          ...responseContext,
+          next: makeNext(responseContext),
+        })
+      ).toMatchSnapshot();
+      expect(hasRefMock).toHaveBeenCalledTimes(2);
+      for (const call of hasRefMock.mock.calls) {
+        expect(call[0]).toBe(hash);
+      }
+      expect(makeRefMock).toHaveBeenCalledTimes(2);
+      expect(makeRefMock.mock.calls[0]).toEqual([hash, {}]);
+      expect(makeRefMock.mock.calls[1][0]).toBe(hash);
+      expect(makeRefMock.mock.calls[1][1]).toMatchSnapshot();
+    });
+  });
+
   describe("depictSecurity()", () => {
     test("should handle Basic, Bearer and CustomHeader Securities", () => {
       expect(
diff --git a/tests/unit/open-api.spec.ts b/tests/unit/open-api.spec.ts
index 3ec7c31b..a1b5323f 100644
--- a/tests/unit/open-api.spec.ts
+++ b/tests/unit/open-api.spec.ts
@@ -503,12 +503,54 @@ describe("Open API generator", () => {
       expect(boolean.parse(null)).toBe(false);
     });
 
+    test("should handle circular schemas via z.lazy()", () => {
+      const baseCategorySchema = z.object({
+        name: z.string(),
+      });
+      type Category = z.infer<typeof baseCategorySchema> & {
+        subcategories: Category[];
+      };
+      const categorySchema: z.ZodType<Category> = baseCategorySchema.extend({
+        subcategories: z.lazy(() => categorySchema.array()),
+      });
+      const spec = new OpenAPI({
+        config: sampleConfig,
+        routing: {
+          v1: {
+            getSomething: defaultEndpointsFactory.build({
+              method: "post",
+              input: baseCategorySchema,
+              output: z.object({
+                zodExample: categorySchema,
+              }),
+              handler: async () => ({
+                zodExample: {
+                  name: "People",
+                  subcategories: [
+                    {
+                      name: "Politicians",
+                      subcategories: [
+                        { name: "Presidents", subcategories: [] },
+                      ],
+                    },
+                  ],
+                },
+              }),
+            }),
+          },
+        },
+        version: "3.4.5",
+        title: "Testing Lazy",
+        serverUrl: "http://example.com",
+      }).getSpecAsYaml();
+      expect(spec).toMatchSnapshot();
+    });
+
     test("should throw on unsupported types", () => {
       [
         z.undefined(),
         z.map(z.any(), z.any()),
         z.function(),
-        z.lazy(() => z.any()),
         z.promise(z.any()),
         z.unknown(),
         z.never(),