Repository: RobinTail/express-zod-api
Pull request #973 (2023-04-30 21:04:16)

More informative `OpenAPIError`
For #966 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ad80cf7..8215dd98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
 
 ## Version 10
 
+### v10.5.0
+
+- Errors that may occur when generating documentation are now more informative.
+  - Changes made to the message of `OpenAPIError` class.
+
+```yaml
+# example of additional details in the second line of the error message
+before: >-
+  Using transformations on the top level of input schema is not allowed.
+after: |-
+  Using transformations on the top level of input schema is not allowed.
+  Caused by input schema of an Endpoint assigned to POST method of /v1/user/:id path.
+```
+
 ### v10.4.0
 
 - For the future features and improvements the following entities are renamed:
diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts
index 3d9e2000..c632eae1 100644
--- a/src/documentation-helpers.ts
+++ b/src/documentation-helpers.ts
@@ -60,6 +60,8 @@ export interface OpenAPIContext {
     name: string,
     schema: SchemaObject | ReferenceObject
   ) => ReferenceObject;
+  path: string;
+  method: Method;
 }
 
 type Depicter<
@@ -68,9 +70,10 @@ type Depicter<
 > = SchemaHandler<T, SchemaObject | ReferenceObject, OpenAPIContext, Variant>;
 
 interface ReqResDepictHelperCommonProps
-  extends Pick<OpenAPIContext, "serializer" | "getRef" | "makeRef"> {
-  method: Method;
-  path: string;
+  extends Pick<
+    OpenAPIContext,
+    "serializer" | "getRef" | "makeRef" | "path" | "method"
+  > {
   endpoint: AbstractEndpoint;
   composition: "inline" | "components";
   clue?: string;
@@ -119,9 +122,12 @@ export const depictAny: Depicter<z.ZodAny> = () => ({
   format: "any",
 });
 
-export const depictUpload: Depicter<ZodUpload> = ({ isResponse }) => {
-  if (isResponse) {
-    throw new OpenAPIError("Please use z.upload() only for input.");
+export const depictUpload: Depicter<ZodUpload> = (ctx) => {
+  if (ctx.isResponse) {
+    throw new OpenAPIError({
+      message: "Please use z.upload() only for input.",
+      ...ctx,
+    });
   }
   return {
     type: "string",
@@ -131,10 +137,13 @@ export const depictUpload: Depicter<ZodUpload> = ({ isResponse }) => {
 
 export const depictFile: Depicter<ZodFile> = ({
   schema: { isBinary, isBase64 },
-  isResponse,
+  ...ctx
 }) => {
-  if (!isResponse) {
-    throw new OpenAPIError("Please use z.file() only within ResultHandler.");
+  if (!ctx.isResponse) {
+    throw new OpenAPIError({
+      message: "Please use z.file() only within ResultHandler.",
+      ...ctx,
+    });
   }
   return {
     type: "string",
@@ -227,9 +236,12 @@ export const depictNull: Depicter<z.ZodNull> = () => ({
   format: "null",
 });
 
-export const depictDateIn: Depicter<ZodDateIn> = ({ isResponse }) => {
-  if (isResponse) {
-    throw new OpenAPIError("Please use z.dateOut() for output.");
+export const depictDateIn: Depicter<ZodDateIn> = (ctx) => {
+  if (ctx.isResponse) {
+    throw new OpenAPIError({
+      message: "Please use z.dateOut() for output.",
+      ...ctx,
+    });
   }
   return {
     description: "YYYY-MM-DDTHH:mm:ss.sssZ",
@@ -242,9 +254,12 @@ export const depictDateIn: Depicter<ZodDateIn> = ({ isResponse }) => {
   };
 };
 
-export const depictDateOut: Depicter<ZodDateOut> = ({ isResponse }) => {
-  if (!isResponse) {
-    throw new OpenAPIError("Please use z.dateIn() for input.");
+export const depictDateOut: Depicter<ZodDateOut> = (ctx) => {
+  if (!ctx.isResponse) {
+    throw new OpenAPIError({
+      message: "Please use z.dateIn() for input.",
+      ...ctx,
+    });
   }
   return {
     description: "YYYY-MM-DDTHH:mm:ss.sssZ",
@@ -257,14 +272,15 @@ export const depictDateOut: Depicter<ZodDateOut> = ({ isResponse }) => {
 };
 
 /** @throws OpenAPIError */
-export const depictDate: Depicter<z.ZodDate> = ({ isResponse }) => {
-  throw new OpenAPIError(
-    `Using z.date() within ${
-      isResponse ? "output" : "input"
+export const depictDate: Depicter<z.ZodDate> = (ctx) => {
+  throw new OpenAPIError({
+    message: `Using z.date() within ${
+      ctx.isResponse ? "output" : "input"
     } schema is forbidden. Please use z.date${
-      isResponse ? "Out" : "In"
-    }() instead. Check out the documentation for details.`
-  );
+      ctx.isResponse ? "Out" : "In"
+    }() instead. Check out the documentation for details.`,
+    ...ctx,
+  });
 };
 
 export const depictBoolean: Depicter<z.ZodBoolean> = () => ({
@@ -577,7 +593,10 @@ export const depictParamExamples = (
   };
 };
 
-export function extractObjectSchema(subject: IOSchema) {
+export function extractObjectSchema(
+  subject: IOSchema,
+  ctx: Pick<OpenAPIContext, "path" | "method" | "isResponse">
+) {
   if (subject instanceof z.ZodObject) {
     return subject;
   }
@@ -587,19 +606,22 @@ export function extractObjectSchema(subject: IOSchema) {
     subject instanceof z.ZodDiscriminatedUnion
   ) {
     objectSchema = Array.from(subject.options.values())
-      .map((option) => extractObjectSchema(option))
+      .map((option) => extractObjectSchema(option, ctx))
       .reduce((acc, option) => acc.merge(option.partial()), z.object({}));
   } else if (subject instanceof z.ZodEffects) {
     if (hasTopLevelTransformingEffect(subject)) {
-      throw new OpenAPIError(
-        "Using transformations on the top level of input schema is not allowed."
-      );
+      throw new OpenAPIError({
+        message: `Using transformations on the top level of ${
+          ctx.isResponse ? "response" : "input"
+        } schema is not allowed.`,
+        ...ctx,
+      });
     }
-    objectSchema = extractObjectSchema(subject._def.schema); // object refinement
+    objectSchema = extractObjectSchema(subject._def.schema, ctx); // object refinement
   } else {
     // intersection
-    objectSchema = extractObjectSchema(subject._def.left).merge(
-      extractObjectSchema(subject._def.right)
+    objectSchema = extractObjectSchema(subject._def.left, ctx).merge(
+      extractObjectSchema(subject._def.right, ctx)
     );
   }
   return copyMeta(subject, objectSchema);
@@ -619,7 +641,11 @@ export const depictRequestParams = ({
   inputSources: InputSource[];
 }): ParameterObject[] => {
   const schema = endpoint.getSchema("input");
-  const shape = extractObjectSchema(schema).shape;
+  const shape = extractObjectSchema(schema, {
+    path,
+    method,
+    isResponse: false,
+  }).shape;
   const pathParams = getRoutePathParams(path);
   const isQueryEnabled = inputSources.includes("query");
   const isParamsEnabled = inputSources.includes("params");
@@ -637,6 +663,8 @@ export const depictRequestParams = ({
         serializer,
         getRef,
         makeRef,
+        path,
+        method,
       });
       const result =
         composition === "components"
@@ -715,8 +743,14 @@ export const onEach: Depicter<z.ZodTypeAny, "each"> = ({
   };
 };
 
-export const onMissing = (schema: z.ZodTypeAny) => {
-  throw new OpenAPIError(`Zod type ${schema.constructor.name} is unsupported`);
+export const onMissing: Depicter<z.ZodTypeAny, "last"> = ({
+  schema,
+  ...ctx
+}) => {
+  throw new OpenAPIError({
+    message: `Zod type ${schema.constructor.name} is unsupported.`,
+    ...ctx,
+  });
 };
 
 export const excludeParamsFromDepiction = (
@@ -791,6 +825,8 @@ export const depictResponse = ({
       serializer,
       getRef,
       makeRef,
+      path,
+      method,
     })
   );
   const examples = depictExamples(schema, true);
@@ -926,6 +962,8 @@ export const depictRequest = ({
         serializer,
         getRef,
         makeRef,
+        path,
+        method,
       }),
       pathParams
     )
diff --git a/src/errors.ts b/src/errors.ts
index 5ef58129..94202706 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -1,5 +1,6 @@
 import { ZodError } from "zod";
 import { getMessageFromError } from "./common-helpers";
+import { OpenAPIContext } from "./documentation-helpers";
 
 /** @desc An error related to the wrong Routing declaration */
 export class RoutingError extends Error {
@@ -14,9 +15,29 @@ export class DependsOnMethodError extends RoutingError {
 /**
  * @desc An error related to the generating of the documentation
  * @todo rename to DocumentationError in v11
+ * @todo remove params:string variation in v11
  * */
 export class OpenAPIError extends Error {
   public override name = "OpenAPIError";
+
+  constructor(
+    params:
+      | string // backward compatibility
+      | ({ message: string } & Pick<
+          OpenAPIContext,
+          "path" | "method" | "isResponse"
+        >)
+  ) {
+    const finalMessage =
+      typeof params === "string"
+        ? params
+        : `${params.message}\nCaused by ${
+            params.isResponse ? "response" : "input"
+          } schema of an Endpoint assigned to ${params.method.toUpperCase()} method of ${
+            params.path
+          } path.`;
+    super(finalMessage);
+  }
 }
 
 /** @desc An error related to the input and output schemas declaration */
diff --git a/src/schema-walker.ts b/src/schema-walker.ts
index df276c55..f0c21e5b 100644
--- a/src/schema-walker.ts
+++ b/src/schema-walker.ts
@@ -55,7 +55,7 @@ export const walkSchema = <U, Context extends object = {}>({
   /** @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;
+  onMissing: SchemaHandler<z.ZodTypeAny, U, Context, "last">;
 }): U => {
   const handler =
     "typeName" in schema._def
@@ -75,7 +75,7 @@ export const walkSchema = <U, Context extends object = {}>({
         ...(context as Context),
         next,
       })
-    : onMissing(schema);
+    : onMissing({ schema, ...(context as Context) });
   const overrides =
     onEach && onEach({ schema, prev: result, ...(context as Context) });
   return overrides ? { ...result, ...overrides } : result;


Test patch:
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 3bc3dac9..60e84111 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -86,7 +86,7 @@ export const serializeSchemaForTest = (
       }),
     },
     onEach: ({ schema: subject }) => ({ _type: subject._def.typeName }),
-    onMissing: (subject) => {
+    onMissing: ({ schema: subject }) => {
       console.warn(`There is no serializer for ${subject._def.typeName}`);
       return {};
     },
diff --git a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap
index 854e5b5a..17e50b09 100644
--- a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap
@@ -41,9 +41,15 @@ exports[`Documentation helpers depictCatch() should pass next depicter 1`] = `
 }
 `;
 
-exports[`Documentation helpers depictDate should throw clear error 0 1`] = `[OpenAPIError: Using z.date() within output schema is forbidden. Please use z.dateOut() instead. Check out the documentation for details.]`;
+exports[`Documentation helpers depictDate should throw clear error 0 1`] = `
+[OpenAPIError: Using z.date() within output schema is forbidden. Please use z.dateOut() instead. Check out the documentation for details.
+Caused by response schema of an Endpoint assigned to GET method of /v1/user/:id path.]
+`;
 
-exports[`Documentation helpers depictDate should throw clear error 1 1`] = `[OpenAPIError: Using z.date() within input schema is forbidden. Please use z.dateIn() instead. Check out the documentation for details.]`;
+exports[`Documentation helpers depictDate should throw clear error 1 1`] = `
+[OpenAPIError: Using z.date() within input schema is forbidden. Please use z.dateIn() instead. Check out the documentation for details.
+Caused by input schema of an Endpoint assigned to GET method of /v1/user/:id path.]
+`;
 
 exports[`Documentation helpers depictDateIn should set type:string, pattern and format 1`] = `
 {
@@ -57,7 +63,10 @@ exports[`Documentation helpers depictDateIn should set type:string, pattern and
 }
 `;
 
-exports[`Documentation helpers depictDateIn should throw when ZodDateIn in response 1`] = `[OpenAPIError: Please use z.dateOut() for output.]`;
+exports[`Documentation helpers depictDateIn should throw when ZodDateIn in response 1`] = `
+[OpenAPIError: Please use z.dateOut() for output.
+Caused by response schema of an Endpoint assigned to GET method of /v1/user/:id path.]
+`;
 
 exports[`Documentation helpers depictDateOut should set type:string, description and format 1`] = `
 {
@@ -70,7 +79,10 @@ exports[`Documentation helpers depictDateOut should set type:string, description
 }
 `;
 
-exports[`Documentation helpers depictDateOut should throw when ZodDateOut in request 1`] = `[OpenAPIError: Please use z.dateIn() for input.]`;
+exports[`Documentation helpers depictDateOut should throw when ZodDateOut in request 1`] = `
+[OpenAPIError: Please use z.dateIn() for input.
+Caused by input schema of an Endpoint assigned to GET method of /v1/user/:id path.]
+`;
 
 exports[`Documentation helpers depictDefault() should set default property 1`] = `
 {
@@ -255,7 +267,10 @@ exports[`Documentation helpers depictFile() should set type:string and format ac
 }
 `;
 
-exports[`Documentation helpers depictFile() should throw when using in input 1`] = `[OpenAPIError: Please use z.file() only within ResultHandler.]`;
+exports[`Documentation helpers depictFile() should throw when using in input 1`] = `
+[OpenAPIError: Please use z.file() only within ResultHandler.
+Caused by input schema of an Endpoint assigned to GET method of /v1/user/:id path.]
+`;
 
 exports[`Documentation helpers depictIntersection() should wrap next depicters in allOf property 1`] = `
 {
@@ -1031,7 +1046,10 @@ exports[`Documentation helpers depictUpload() should set format:binary and type:
 }
 `;
 
-exports[`Documentation helpers depictUpload() should throw when using in response 1`] = `[OpenAPIError: Please use z.upload() only for input.]`;
+exports[`Documentation helpers depictUpload() should throw when using in response 1`] = `
+[OpenAPIError: Please use z.upload() only for input.
+Caused by response schema of an Endpoint assigned to GET method of /v1/user/:id path.]
+`;
 
 exports[`Documentation helpers excludeExampleFromDepiction() should remove example property of supplied object 1`] = `
 {
diff --git a/tests/unit/__snapshots__/errors.spec.ts.snap b/tests/unit/__snapshots__/errors.spec.ts.snap
new file mode 100644
index 00000000..d05004d3
--- /dev/null
+++ b/tests/unit/__snapshots__/errors.spec.ts.snap
@@ -0,0 +1,6 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Errors OpenAPIError should include more details into the message 1`] = `
+"test
+Caused by response schema of an Endpoint assigned to GET method of /v1/testPath path."
+`;
diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts
index acc16b5f..bba586b0 100644
--- a/tests/unit/documentation-helpers.spec.ts
+++ b/tests/unit/documentation-helpers.spec.ts
@@ -61,12 +61,16 @@ describe("Documentation helpers", () => {
     })
   );
   const requestContext: OpenAPIContext = {
+    path: "/v1/user/:id",
+    method: "get",
     isResponse: false,
     getRef: getRefMock,
     makeRef: makeRefMock,
     serializer: defaultSerializer,
   };
   const responseContext: OpenAPIContext = {
+    path: "/v1/user/:id",
+    method: "get",
     isResponse: true,
     getRef: getRefMock,
     makeRef: makeRefMock,
@@ -97,14 +101,18 @@ describe("Documentation helpers", () => {
 
   describe("extractObjectSchema()", () => {
     test("should pass the object schema through", () => {
-      const subject = extractObjectSchema(z.object({ one: z.string() }));
+      const subject = extractObjectSchema(
+        z.object({ one: z.string() }),
+        requestContext
+      );
       expect(subject).toBeInstanceOf(z.ZodObject);
       expect(serializeSchemaForTest(subject)).toMatchSnapshot();
     });
 
     test("should return object schema for the union of object schemas", () => {
       const subject = extractObjectSchema(
-        z.object({ one: z.string() }).or(z.object({ two: z.number() }))
+        z.object({ one: z.string() }).or(z.object({ two: z.number() })),
+        requestContext
       );
       expect(subject).toBeInstanceOf(z.ZodObject);
       expect(serializeSchemaForTest(subject)).toMatchSnapshot();
@@ -112,7 +120,8 @@ describe("Documentation helpers", () => {
 
     test("should return object schema for the intersection of object schemas", () => {
       const subject = extractObjectSchema(
-        z.object({ one: z.string() }).and(z.object({ two: z.number() }))
+        z.object({ one: z.string() }).and(z.object({ two: z.number() })),
+        requestContext
       );
       expect(subject).toBeInstanceOf(z.ZodObject);
       expect(serializeSchemaForTest(subject)).toMatchSnapshot();
@@ -122,15 +131,18 @@ describe("Documentation helpers", () => {
       const objectSchema = withMeta(z.object({ one: z.string() })).example({
         one: "test",
       });
-      expect(getMeta(extractObjectSchema(objectSchema), "examples")).toEqual([
-        { one: "test" },
-      ]);
+      expect(
+        getMeta(extractObjectSchema(objectSchema, requestContext), "examples")
+      ).toEqual([{ one: "test" }]);
 
       const refinedObjSchema = withMeta(
         z.object({ one: z.string() }).refine(() => true)
       ).example({ one: "test" });
       expect(
-        getMeta(extractObjectSchema(refinedObjSchema), "examples")
+        getMeta(
+          extractObjectSchema(refinedObjSchema, requestContext),
+          "examples"
+        )
       ).toEqual([{ one: "test" }]);
 
       const unionSchema = withMeta(
@@ -138,23 +150,26 @@ describe("Documentation helpers", () => {
       )
         .example({ one: "test1" })
         .example({ two: 123 });
-      expect(getMeta(extractObjectSchema(unionSchema), "examples")).toEqual([
-        { one: "test1" },
-        { two: 123 },
-      ]);
+      expect(
+        getMeta(extractObjectSchema(unionSchema, requestContext), "examples")
+      ).toEqual([{ one: "test1" }, { two: 123 }]);
 
       const intersectionSchema = withMeta(
         z.object({ one: z.string() }).and(z.object({ two: z.number() }))
       ).example({ one: "test1", two: 123 });
       expect(
-        getMeta(extractObjectSchema(intersectionSchema), "examples")
+        getMeta(
+          extractObjectSchema(intersectionSchema, requestContext),
+          "examples"
+        )
       ).toEqual([{ one: "test1", two: 123 }]);
     });
 
     describe("Feature #600: Top level refinements", () => {
       test("should handle refined object schema", () => {
         const subject = extractObjectSchema(
-          z.object({ one: z.string() }).refine(() => true)
+          z.object({ one: z.string() }).refine(() => true),
+          requestContext
         );
         expect(subject).toBeInstanceOf(z.ZodObject);
         expect(serializeSchemaForTest(subject)).toMatchSnapshot();
@@ -162,10 +177,14 @@ describe("Documentation helpers", () => {
 
       test("should throw when using transformation", () => {
         expect(() =>
-          extractObjectSchema(z.object({ one: z.string() }).transform(() => []))
+          extractObjectSchema(
+            z.object({ one: z.string() }).transform(() => []),
+            requestContext
+          )
         ).toThrowError(
           new IOSchemaError(
-            "Using transformations on the top level of input schema is not allowed."
+            "Using transformations on the top level of input schema is not allowed.\n" +
+              "Caused by input schema of an Endpoint assigned to GET method of /v1/user/:id path."
           )
         );
       });
@@ -730,8 +749,6 @@ describe("Documentation helpers", () => {
     test("should depict query and path params", () => {
       expect(
         depictRequestParams({
-          path: "/v1/user/:id",
-          method: "get",
           endpoint: defaultEndpointsFactory.build({
             methods: ["get", "put", "delete"],
             input: z.object({
@@ -743,9 +760,7 @@ describe("Documentation helpers", () => {
           }),
           inputSources: ["query", "params"],
           composition: "inline",
-          getRef: getRefMock,
-          makeRef: makeRefMock,
-          serializer: defaultSerializer,
+          ...requestContext,
         })
       ).toMatchSnapshot();
     });
@@ -753,8 +768,6 @@ describe("Documentation helpers", () => {
     test("should depict only path params if query is disabled", () => {
       expect(
         depictRequestParams({
-          path: "/v1/user/:id",
-          method: "get",
           endpoint: defaultEndpointsFactory.build({
             methods: ["get", "put", "delete"],
             input: z.object({
@@ -766,9 +779,7 @@ describe("Documentation helpers", () => {
           }),
           inputSources: ["body", "params"],
           composition: "inline",
-          getRef: getRefMock,
-          makeRef: makeRefMock,
-          serializer: defaultSerializer,
+          ...requestContext,
         })
       ).toMatchSnapshot();
     });
@@ -776,8 +787,6 @@ describe("Documentation helpers", () => {
     test("should depict none if both query and params are disabled", () => {
       expect(
         depictRequestParams({
-          path: "/v1/user/:id",
-          method: "get",
           endpoint: defaultEndpointsFactory.build({
             methods: ["get", "put", "delete"],
             input: z.object({
@@ -789,9 +798,7 @@ describe("Documentation helpers", () => {
           }),
           inputSources: ["body"],
           composition: "inline",
-          getRef: getRefMock,
-          makeRef: makeRefMock,
-          serializer: defaultSerializer,
+          ...requestContext,
         })
       ).toMatchSnapshot();
     });
diff --git a/tests/unit/errors.spec.ts b/tests/unit/errors.spec.ts
index 23f6406f..84945dfd 100644
--- a/tests/unit/errors.spec.ts
+++ b/tests/unit/errors.spec.ts
@@ -19,12 +19,27 @@ describe("Errors", () => {
   });
 
   describe("OpenAPIError", () => {
+    const params = {
+      message: "test",
+      path: "/v1/testPath",
+      method: "get" as const,
+      isResponse: true,
+    };
+
     test("should be an instance of Error", () => {
-      expect(new OpenAPIError("test")).toBeInstanceOf(Error);
+      expect(new OpenAPIError(params)).toBeInstanceOf(Error);
+    });
+
+    test("should include more details into the message", () => {
+      expect(new OpenAPIError(params).message).toMatchSnapshot();
     });
 
     test("should have the name matching its class", () => {
-      expect(new OpenAPIError("test").name).toBe("OpenAPIError");
+      expect(new OpenAPIError(params).name).toBe("OpenAPIError");
+    });
+
+    test("should be backward compatible", () => {
+      expect(new OpenAPIError(params.message).message).toBe(params.message);
     });
   });