Repository: RobinTail/express-zod-api
Pull request #204 (2021-11-27 14:01:21)

Detecting z.upload() usage automatically
removing optional parameter from `EndpointsFactory.build({...})`
```typescript
type?: "json" | "upload"
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b74ff643..0d828a1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
 
 ## Version 3
 
+### v3.2.0
+
+- Feature #204. Detecting usage of `z.upload()` within Endpoint's input schema automatically.
+  - There is no longer need to specify `type: "upload"` for `endpointsFactory.build({...})`.
+  - In case you are using `z.upload()` in endpoint's input schema, inputs will be parsed by the
+    `multipart/form-data` parser.
+  - The optional parameter `type?: "json" | "upload"` of `build({...})` is deprecated.
+
 ### v3.1.2
 
 - Fixed issue #202, originally reported in PR #201.
diff --git a/README.md b/README.md
index 6575cd64..e934e154 100644
--- a/README.md
+++ b/README.md
@@ -443,8 +443,8 @@ const fileStreamingEndpointsFactory = new EndpointsFactory(
 
 ## File uploads
 
-You can switch the `Endpoint` to handle requests with the `multipart/formdata` content type instead of JSON.
-Together with a corresponding configuration option, this makes it possible to handle file uploads.
+You can switch the `Endpoint` to handle requests with the `multipart/form-data` content type instead of JSON by using
+`z.upload()` schema. Together with a corresponding configuration option, this makes it possible to handle file uploads.
 Here is a simplified example:
 
 ```typescript
@@ -459,9 +459,8 @@ const config = createConfig({
 
 const fileUploadEndpoint = defaultEndpointsFactory.build({
   method: "post",
-  type: "upload", // <- required
   input: z.object({
-    avatar: z.upload(),
+    avatar: z.upload(), // <--
   }),
   output: z.object({
     /* ... */
@@ -469,9 +468,6 @@ const fileUploadEndpoint = defaultEndpointsFactory.build({
   handler: async ({ input: { avatar } }) => {
     // avatar: {name, mv(), mimetype, data, size, etc}
     // avatar.truncated is true on failure
-    return {
-      /* ... */
-    };
   },
 });
 ```
diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts
index 45f11a18..1aea585f 100644
--- a/example/endpoints/upload-avatar.ts
+++ b/example/endpoints/upload-avatar.ts
@@ -3,7 +3,6 @@ import crypto from "crypto";
 
 export const uploadAvatarEndpoint = defaultEndpointsFactory.build({
   method: "post",
-  type: "upload",
   input: z
     .object({
       avatar: z
diff --git a/src/common-helpers.ts b/src/common-helpers.ts
index bb6d3595..a3b54ae2 100644
--- a/src/common-helpers.ts
+++ b/src/common-helpers.ts
@@ -11,6 +11,7 @@ import { copyMeta, getMeta } from "./metadata";
 import { Method } from "./method";
 import { MiddlewareDefinition } from "./middleware";
 import { mimeMultipart } from "./mime";
+import { ZodUpload } from "./upload-schema";
 
 export type FlatObject = Record<string, any>;
 
@@ -227,6 +228,39 @@ export function getRoutePathParams(path: string): string[] {
   return match.map((param) => param.slice(1));
 }
 
+export function hasUpload(schema: z.ZodTypeAny): boolean {
+  if (schema instanceof ZodUpload) {
+    return true;
+  }
+  const reduceBool = (arr: boolean[]) =>
+    arr.reduce((carry, check) => carry || check, false);
+  if (schema instanceof z.ZodObject) {
+    return reduceBool(Object.values<z.ZodTypeAny>(schema.shape).map(hasUpload));
+  }
+  if (schema instanceof z.ZodUnion) {
+    return reduceBool(schema._def.options.map(hasUpload));
+  }
+  if (schema instanceof z.ZodIntersection) {
+    return reduceBool([schema._def.left, schema._def.right].map(hasUpload));
+  }
+  if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
+    return hasUpload(schema.unwrap());
+  }
+  if (schema instanceof z.ZodEffects || schema instanceof z.ZodTransformer) {
+    return hasUpload(schema._def.schema);
+  }
+  if (schema instanceof z.ZodRecord) {
+    return hasUpload(schema._def.valueType);
+  }
+  if (schema instanceof z.ZodArray) {
+    return hasUpload(schema._def.type);
+  }
+  if (schema instanceof z.ZodDefault) {
+    return hasUpload(schema._def.innerType);
+  }
+  return false;
+}
+
 // obtaining the private helper type from Zod
 export type ErrMessage = Exclude<
   Parameters<typeof z.ZodString.prototype.email>[0],
diff --git a/src/config-type.ts b/src/config-type.ts
index 2a1e40b2..8fb946b0 100644
--- a/src/config-type.ts
+++ b/src/config-type.ts
@@ -33,7 +33,7 @@ export interface ServerConfig {
     // server configuration
     listen: number | string; // port or socket
     jsonParser?: NextHandleFunction; // custom JSON parser, default: express.json()
-    upload?: boolean | UploadOptions;
+    upload?: boolean | UploadOptions; // enable or configure uploads handling
   };
 }
 
diff --git a/src/endpoints-factory.ts b/src/endpoints-factory.ts
index 9ca0663f..f7f728f0 100644
--- a/src/endpoints-factory.ts
+++ b/src/endpoints-factory.ts
@@ -1,7 +1,7 @@
 import { z } from "zod";
 import { ApiResponse } from "./api-response";
 import { Endpoint, Handler } from "./endpoint";
-import { FlatObject, IOSchema, Merge } from "./common-helpers";
+import { FlatObject, IOSchema, hasUpload, Merge } from "./common-helpers";
 import { Method, MethodsDefinition } from "./method";
 import { MiddlewareDefinition } from "./middleware";
 import { mimeJson, mimeMultipart } from "./mime";
@@ -21,7 +21,8 @@ type BuildProps<
   output: OUT;
   handler: Handler<z.output<Merge<IN, MwIN>>, z.input<OUT>, MwOUT>;
   description?: string;
-  type?: "json" | "upload"; // @todo can we detect the usage of z.upload() within input?
+  /** @deprecated the factory automatically detects the usage of z.upload() within the input schema */
+  type?: "json" | "upload"; // @todo remove in v4
 } & MethodsDefinition<M>;
 
 export class EndpointsFactory<
@@ -66,7 +67,7 @@ export class EndpointsFactory<
     output,
     handler,
     description,
-    type,
+    type, // @todo remove in v4
     ...rest
   }: BuildProps<IN, OUT, MwIN, MwOUT, M>) {
     return new Endpoint<IN, OUT, MwIN, MwOUT, M, POS, NEG>({
@@ -76,7 +77,7 @@ export class EndpointsFactory<
       inputSchema: input,
       outputSchema: output,
       resultHandler: this.resultHandler,
-      mimeTypes: type === "upload" ? [mimeMultipart] : [mimeJson],
+      mimeTypes: hasUpload(input) ? [mimeMultipart] : [mimeJson],
       ...rest,
     });
   }


Test patch:
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 8f2a6d28..e4f04a8e 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -65,6 +65,11 @@ export const serializeSchemaForTest = (
       ? {
           value: schema._def.value,
         }
+      : schema instanceof z.ZodDefault
+      ? {
+          value: schema._def.innerType,
+          default: schema._def.defaultValue(),
+        }
       : {}),
   };
 };
diff --git a/tests/unit/common-helpers.spec.ts b/tests/unit/common-helpers.spec.ts
index 765b1082..ea12f5cb 100644
--- a/tests/unit/common-helpers.spec.ts
+++ b/tests/unit/common-helpers.spec.ts
@@ -1,3 +1,4 @@
+import { UploadedFile } from "express-fileupload";
 import { expectType } from "tsd";
 import {
   combinations,
@@ -8,6 +9,7 @@ import {
   getMessageFromError,
   getRoutePathParams,
   getStatusCodeFromError,
+  hasUpload,
   isLoggerConfig,
   OutputMarker,
 } from "../../src/common-helpers";
@@ -697,4 +699,32 @@ describe("Common Helpers", () => {
       expect(getRoutePathParams("\n")).toEqual([]);
     });
   });
+
+  describe("hasUpload()", () => {
+    test("should return true for z.upload()", () => {
+      expect(hasUpload(z.upload())).toBeTruthy();
+    });
+    test("should return true for wrapped z.upload()", () => {
+      expect(hasUpload(z.object({ test: z.upload() }))).toBeTruthy();
+      expect(hasUpload(z.upload().or(z.boolean()))).toBeTruthy();
+      expect(
+        hasUpload(
+          z.object({ test: z.boolean() }).and(z.object({ test2: z.upload() }))
+        )
+      ).toBeTruthy();
+      expect(hasUpload(z.optional(z.upload()))).toBeTruthy();
+      expect(hasUpload(z.upload().nullable())).toBeTruthy();
+      expect(hasUpload(z.upload().default({} as UploadedFile))).toBeTruthy();
+      expect(hasUpload(z.record(z.upload()))).toBeTruthy();
+      expect(hasUpload(z.upload().refine(() => true))).toBeTruthy();
+      expect(hasUpload(z.array(z.upload()))).toBeTruthy();
+    });
+    test("should return false in other cases", () => {
+      expect(hasUpload(z.object({}))).toBeFalsy();
+      expect(hasUpload(z.any())).toBeFalsy();
+      expect(hasUpload(z.literal("test"))).toBeFalsy();
+      expect(hasUpload(z.boolean().and(z.literal(true)))).toBeFalsy();
+      expect(hasUpload(z.number().or(z.string()))).toBeFalsy();
+    });
+  });
 });