Repository: RobinTail/express-zod-api
Pull request #4 (2021-04-16 13:12:31)

Reducing complexity of Endpoint::execute() method


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..ef6c66a8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,8 @@
+# Changelog
+
+## v0.2.4
+- Refactoring of Endpoint::execute() method
+
+## v0.2.3 & v0.2.2
+- First published release
+- Zod version is v3.0.0-alpha4
diff --git a/README.md b/README.md
index 3574a80d..e45cd776 100644
--- a/README.md
+++ b/README.md
@@ -230,7 +230,7 @@ const output = z.object({
 });
 endpointsFactory.build({
   methods, input, output,
-  handler: async (): Promise<z.infer<typeof handlerOutput>> => ({
+  handler: async (): Promise<z.infer<typeof output>> => ({
     anything: 123,
     excessive: 'something' // error TS2322, ok!
   })
diff --git a/src/endpoint.ts b/src/endpoint.ts
index 12f77c92..d6ad20a3 100644
--- a/src/endpoint.ts
+++ b/src/endpoint.ts
@@ -2,7 +2,7 @@ import {Logger} from 'winston';
 import {ZodError} from 'zod';
 import * as z from 'zod';
 import {ConfigType} from './config-type';
-import {combineEndpointAndMiddlewareInputSchemas, Merge, ObjectSchema} from './helpers';
+import {combineEndpointAndMiddlewareInputSchemas, getInitialInput, Merge, ObjectSchema} from './helpers';
 import {Request, Response} from 'express';
 import {MiddlewareDefinition} from './middleware';
 import {defaultResultHandler, ResultHandler} from './result-handler';
@@ -70,75 +70,70 @@ export class Endpoint<IN extends ObjectSchema, OUT extends ObjectSchema, mIN, OP
     return this.outputSchema;
   }
 
-  public async execute({request, response, logger, config}: {
+  private setupCorsHeaders(response: Response) {
+    const accessMethods = this.methods.map((method) => method.toUpperCase()).concat('OPTIONS').join(', ');
+    response.set('Access-Control-Allow-Origin', '*');
+    response.set('Access-Control-Allow-Methods', accessMethods);
+    response.set('Access-Control-Allow-Headers', 'content-type');
+  }
+
+  private parseOutput(output: any) {
+    try {
+      return this.outputSchema.parse(output);
+    } catch (e) {
+      if (e instanceof ZodError) {
+        throw new ZodError([
+          {
+            message: 'Invalid format',
+            code: 'custom',
+            path: ['output'],
+          },
+          ...e.issues.map((issue) => ({
+            ...issue,
+            path: issue.path.length === 0 ? ['output'] : issue.path
+          }))
+        ]);
+      }
+      throw e;
+    }
+  }
+
+  private async runMiddlewares({input, request, response, logger}: {
+    input: any,
     request: Request,
     response: Response,
-    logger: Logger,
-    config: ConfigType
+    logger: Logger
   }) {
-    if (config.server.cors) {
-      const accessMethods = this.methods.map((method) => method.toUpperCase()).concat('OPTIONS').join(', ');
-      response.set('Access-Control-Allow-Origin', '*');
-      response.set('Access-Control-Allow-Methods', accessMethods);
-      response.set('Access-Control-Allow-Headers', 'content-type');
+    const options: any = {};
+    for (const def of this.middlewares) {
+      input = {...input, ...def.input.parse(input)}; // middleware can transform the input types
+      Object.assign(options, await def.middleware({
+        input, options, request,
+        response, logger
+      }));
+      if (response.writableEnded) {
+        break;
+      }
     }
+    return {input, options, isStreamClosed: response.writableEnded};
+  }
 
-    if (request.method === 'OPTIONS') {
-      response.end();
-      return;
-    }
+  private async parseAndRunHandler({input, options, logger}: {input: any, options: any, logger: Logger}) {
+    return await this.handler({
+      input: this.inputSchema.parse(input), // final input types transformations for handler,
+      options, logger
+    });
+  }
 
-    let error;
-    let output;
-    let initialInput: any = null;
-    try {
-      if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
-        initialInput = request.body;
-      }
-      if (request.method === 'GET') {
-        initialInput = request.query;
-      }
-      if (request.method === 'DELETE') { // _may_ have body
-        initialInput = {...request.query, ...request.body};
-      }
-      let input = {...initialInput};
-      const options: any = {};
-      for (const def of this.middlewares) {
-        input = {...input, ...def.input.parse(input)}; // middleware can transform the input types
-        Object.assign(options, await def.middleware({
-          input, options, request,
-          response, logger
-        }));
-        if (response.writableEnded) {
-          return;
-        }
-      }
-      input = this.inputSchema.parse(input); // final input types transformations for handler
-      output = await this.handler({input, options, logger});
-      try {
-        output = this.outputSchema.parse(output);
-      } catch (e) {
-        if (e instanceof ZodError) {
-          // noinspection ExceptionCaughtLocallyJS
-          throw new ZodError([
-            {
-              message: 'Invalid format',
-              code: 'custom',
-              path: ['output'],
-            },
-            ...e.issues.map((issue) => ({
-              ...issue,
-              path: issue.path.length === 0 ? ['output'] : issue.path
-            }))
-          ]);
-        } else {
-          // noinspection ExceptionCaughtLocallyJS
-          throw e;
-        }
-      }
-    } catch (e) {
-      error = e;
-    }
+  private async handleResult({config, error, request, response, logger, initialInput, output}: {
+    config: ConfigType,
+    error: Error | null,
+    request: Request,
+    response: Response,
+    logger: Logger,
+    initialInput: any,
+    output: any
+  }) {
     const resultHandler = this.resultHandler || config.server.resultHandler || defaultResultHandler;
     try {
       await resultHandler({
@@ -150,5 +145,40 @@ export class Endpoint<IN extends ObjectSchema, OUT extends ObjectSchema, mIN, OP
       // throw e;
     }
   }
+
+  public async execute({request, response, logger, config}: {
+    request: Request,
+    response: Response,
+    logger: Logger,
+    config: ConfigType
+  }) {
+    let output: any;
+    let error: Error | null = null;
+    if (config.server.cors) {
+      this.setupCorsHeaders(response);
+    }
+    if (request.method === 'OPTIONS') {
+      return response.end();
+    }
+    const initialInput = getInitialInput(request);
+    try {
+      const {input, options, isStreamClosed} = await this.runMiddlewares({
+        input: {...initialInput}, // preserve the initial
+        request, response, logger
+      });
+      if (isStreamClosed) {
+        return;
+      }
+      output = this.parseOutput(
+        await this.parseAndRunHandler({input, options, logger})
+      );
+    } catch (e) {
+      error = e;
+    }
+    await this.handleResult({
+      initialInput, output, request,
+      response, error, logger, config
+    });
+  }
 }
 
diff --git a/src/helpers.ts b/src/helpers.ts
index f2ecee72..2c59649c 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -1,3 +1,4 @@
+import {Request} from 'express';
 import * as z from 'zod';
 import {AnyZodObject} from 'zod/lib/cjs/types/object';
 import {MiddlewareDefinition} from './middleware';
@@ -24,3 +25,17 @@ export function combineEndpointAndMiddlewareInputSchemas<IN extends ObjectSchema
     .reduce((carry: ObjectSchema, schema) => carry.merge(schema))
     .merge(input) as Merge<IN, mIN>;
 }
+
+export function getInitialInput(request: Request): any {
+  switch (request.method) {
+    case 'POST':
+    case 'PUT':
+    case 'PATCH':
+      return request.body;
+    case 'GET':
+      return request.query;
+    case 'DELETE': // _may_ have body
+    default:
+      return {...request.query, ...request.body};
+  }
+}


Test patch:
diff --git a/tests/unit/helpers.spec.ts b/tests/unit/helpers.spec.ts
index 18a60b93..f00d5c7e 100644
--- a/tests/unit/helpers.spec.ts
+++ b/tests/unit/helpers.spec.ts
@@ -1,6 +1,7 @@
 import {ZodObject} from 'zod';
-import {combineEndpointAndMiddlewareInputSchemas} from '../../src/helpers';
+import {combineEndpointAndMiddlewareInputSchemas, getInitialInput} from '../../src/helpers';
 import {createMiddleware, z} from '../../src';
+import {Request} from 'express';
 
 describe('Helpers', () => {
   describe('combineEndpointAndMiddlewareInputSchemas()', () => {
@@ -46,4 +47,36 @@ describe('Helpers', () => {
       */
     });
   });
+  describe('getInitialInput()', () => {
+    test('should return body for POST, PUT and PATCH requests', () => {
+      expect(getInitialInput({
+        body: 'body',
+        method: 'POST'
+      } as Request)).toEqual('body');
+      expect(getInitialInput({
+        body: 'body',
+        method: 'PUT'
+      } as Request)).toEqual('body');
+      expect(getInitialInput({
+        body: 'body',
+        method: 'PATCH'
+      } as Request)).toEqual('body');
+    });
+    test('should return query for GET requests', () => {
+      expect(getInitialInput({
+        query: 'query',
+        method: 'GET'
+      } as unknown as Request)).toEqual('query');
+    });
+    test('should return both body and query for DELETE and unknown requests', () => {
+      expect(getInitialInput({
+        query: { a: 'query' },
+        body: {b: 'body'},
+        method: 'DELETE'
+      } as unknown as Request)).toEqual({
+        a: 'query',
+        b: 'body'
+      });
+    });
+  });
 });