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

Issue:
Bug: Cannot use `dateIn` as an input for a middleware
Another small bug my team discovered:

Right now, it's not possible to define a middleware with a `dateIn` input. Calling an endpoint using such a middleware always results in a `<field_name>: Expected string, received date` error.

I didn't have time to look into the source code here, but it seems like inputs might be parsed/validated multiple times (once at the middleware and then again at the endpoint), and by the time they're checked for the second time the date string has already been parsed to a date (which is not the expected input type of `dateIn`). Could that be the case?

Pull request description:
Bug: transformations in middlewares
Fixes #673 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d50e7ce..b0dc9aaa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
 
 ## Version 8
 
+### v8.3.2
+
+- Fixed the bug #673 found and reported by [@shroudedcode](https://github.com/shroudedcode).
+  - Preventing double parsing of incoming data by input schemas of middlewares containing transformations.
+  - The bug caused inability of using any transforming schema in middlewares.
+  - In particular, but not limited with: using `z.dateIn()` in middlewares.
+    - Sample error message in this case: `Expected string, received date`.
+  - Using `.transform()` method in middlewares was also affected by this bug.
+
 ### v8.3.1
 
 - Clearer error message when using `z.date()` within I/O schema thrown by OpenAPI generator.
diff --git a/src/common-helpers.ts b/src/common-helpers.ts
index 79604218..6af954c9 100644
--- a/src/common-helpers.ts
+++ b/src/common-helpers.ts
@@ -82,7 +82,7 @@ const fallbackInputSource = defaultInputSources.delete;
 export const getActualMethod = (request: Request) =>
   request.method.toLowerCase() as Method | AuxMethod;
 
-export function getInitialInput(
+export function getInput(
   request: Request,
   inputAssignment: CommonConfig["inputSources"]
 ): any {
diff --git a/src/endpoint.ts b/src/endpoint.ts
index 92621fb7..6485189f 100644
--- a/src/endpoint.ts
+++ b/src/endpoint.ts
@@ -8,7 +8,7 @@ import {
   FlatObject,
   IOSchema,
   getActualMethod,
-  getInitialInput,
+  getInput,
   makeErrorFromAnything,
 } from "./common-helpers";
 import { LogicalContainer, combineContainers } from "./logical-container";
@@ -220,7 +220,7 @@ export class Endpoint<
     logger,
   }: {
     method: Method | AuxMethod;
-    input: any;
+    input: Readonly<any>; // Issue #673: input is immutable, since this.inputSchema is combined with ones of middlewares
     request: Request;
     response: Response;
     logger: Logger;
@@ -231,11 +231,10 @@ export class Endpoint<
       if (method === "options" && def.type === "proprietary") {
         continue;
       }
-      Object.assign(input, await def.input.parseAsync(input)); // middleware can transform the input types
       Object.assign(
         options,
         await def.middleware({
-          input,
+          input: await def.input.parseAsync(input),
           options,
           request,
           response,
@@ -251,7 +250,7 @@ export class Endpoint<
         break;
       }
     }
-    return { input, options, isStreamClosed };
+    return { options, isStreamClosed };
   }
 
   async #parseAndRunHandler({
@@ -259,7 +258,7 @@ export class Endpoint<
     options,
     logger,
   }: {
-    input: any;
+    input: Readonly<any>;
     options: any;
     logger: Logger;
   }) {
@@ -276,14 +275,14 @@ export class Endpoint<
     request,
     response,
     logger,
-    initialInput,
+    input,
     output,
   }: {
     error: Error | null;
     request: Request;
     response: Response;
     logger: Logger;
-    initialInput: any;
+    input: any;
     output: any;
   }) {
     try {
@@ -293,7 +292,7 @@ export class Endpoint<
         request,
         response,
         logger,
-        input: initialInput,
+        input,
       });
     } catch (e) {
       lastResortHandler({
@@ -332,11 +331,11 @@ export class Endpoint<
         response.set(key, headers[key]);
       }
     }
-    const initialInput = getInitialInput(request, config.inputSources);
+    const input = getInput(request, config.inputSources);
     try {
-      const { input, options, isStreamClosed } = await this.#runMiddlewares({
+      const { options, isStreamClosed } = await this.#runMiddlewares({
         method,
-        input: { ...initialInput }, // preserve the initial
+        input,
         request,
         response,
         logger,
@@ -355,7 +354,7 @@ export class Endpoint<
       error = makeErrorFromAnything(e);
     }
     await this.#handleResult({
-      initialInput,
+      input,
       output,
       request,
       response,


Test patch:
diff --git a/tests/unit/common-helpers.spec.ts b/tests/unit/common-helpers.spec.ts
index 311f132d..3a8c0b71 100644
--- a/tests/unit/common-helpers.spec.ts
+++ b/tests/unit/common-helpers.spec.ts
@@ -6,7 +6,7 @@ import {
   defaultInputSources,
   getExamples,
   getFinalEndpointInputSchema,
-  getInitialInput,
+  getInput,
   getMessageFromError,
   getRoutePathParams,
   getStatusCodeFromError,
@@ -286,10 +286,10 @@ describe("Common Helpers", () => {
     });
   });
 
-  describe("getInitialInput()", () => {
+  describe("getInput()", () => {
     test("should return body for POST, PUT and PATCH requests by default", () => {
       expect(
-        getInitialInput(
+        getInput(
           {
             body: {
               param: 123,
@@ -303,7 +303,7 @@ describe("Common Helpers", () => {
         param: 123,
       });
       expect(
-        getInitialInput(
+        getInput(
           {
             body: {
               param: 123,
@@ -316,7 +316,7 @@ describe("Common Helpers", () => {
         param: 123,
       });
       expect(
-        getInitialInput(
+        getInput(
           {
             body: {
               param: 123,
@@ -331,7 +331,7 @@ describe("Common Helpers", () => {
     });
     test("should return query for GET requests by default", () => {
       expect(
-        getInitialInput(
+        getInput(
           {
             query: {
               param: 123,
@@ -346,7 +346,7 @@ describe("Common Helpers", () => {
     });
     test("should return both body and query for DELETE and unknown requests by default", () => {
       expect(
-        getInitialInput(
+        getInput(
           {
             query: { a: "query" },
             body: { b: "body" },
@@ -361,7 +361,7 @@ describe("Common Helpers", () => {
     });
     test("should return body and files on demand for POST by default", () => {
       expect(
-        getInitialInput(
+        getInput(
           {
             body: {
               param: 123,
@@ -381,7 +381,7 @@ describe("Common Helpers", () => {
     });
     test("Issue 158: should return query and body for POST on demand", () => {
       expect(
-        getInitialInput(
+        getInput(
           {
             body: {
               a: "body",
@@ -403,7 +403,7 @@ describe("Common Helpers", () => {
     });
     test("URL params: should also be taken, with a higher priority by default", () => {
       expect(
-        getInitialInput(
+        getInput(
           {
             body: {
               a: "body",
@@ -427,7 +427,7 @@ describe("Common Helpers", () => {
     });
     test("Issue 514: should return empty object for OPTIONS", () => {
       expect(
-        getInitialInput({ method: "OPTIONS" } as unknown as Request, undefined)
+        getInput({ method: "OPTIONS" } as unknown as Request, undefined)
       ).toEqual({});
     });
   });
diff --git a/tests/unit/endpoint.spec.ts b/tests/unit/endpoint.spec.ts
index cb14d8dd..7be864f1 100644
--- a/tests/unit/endpoint.spec.ts
+++ b/tests/unit/endpoint.spec.ts
@@ -711,4 +711,48 @@ describe("Endpoint", () => {
       expect(responseMock.status).toHaveBeenCalledWith(400);
     });
   });
+
+  describe("Issue #673: transformations in middlewares", () => {
+    test("should avoid double parsing, should not mutate input", async () => {
+      const dateInputMiddleware = createMiddleware({
+        input: z.object({
+          middleware_date_input: z.dateIn().optional(),
+        }),
+        middleware: async ({ input: { middleware_date_input }, logger }) => {
+          logger.debug("date in mw handler", typeof middleware_date_input);
+          return {};
+        },
+      });
+
+      const endpoint = defaultEndpointsFactory
+        .addMiddleware(dateInputMiddleware)
+        .build({
+          method: "get",
+          input: z.object({}),
+          output: z.object({}),
+          handler: async ({ input: { middleware_date_input }, logger }) => {
+            logger.debug(
+              "date in endpoint handler",
+              typeof middleware_date_input
+            );
+            return {};
+          },
+        });
+
+      const { loggerMock, responseMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          query: {
+            middleware_date_input: "2022-09-28",
+          },
+        },
+      });
+
+      expect(loggerMock.debug.mock.calls).toEqual([
+        ["date in mw handler", "object"],
+        ["date in endpoint handler", "object"],
+      ]);
+      expect(responseMock.status).toHaveBeenCalledWith(200);
+    });
+  });
 });