데코레이터로 @Get() @Post() 구현하기 (w/ express)

Jin-hyeok Bang·2023년 3월 3일
0
post-thumbnail

프로젝트 전체 코드는 https://github.com/JinhyeokFang/router-with-decorator 에서 확인하실 수 있습니다.

폴더 구조는 다음과 같습니다.

/src
  /decorators
    get.ts - @Get()이 들어갈 파일입니다.
    post.ts - @Post()가 들어갈 파일입니다.
  /hello
    hello.router.test.ts
    hello.router.ts - 데코레이터로 구현한 라우터가 들어갑니다.
  root.router.test.ts
  root.router.ts - 데코레이터로 구현한 라우터를 Express 라우터로 변환하여
서버 인스턴스에 넣어줍니다.
  server.ts - 서버 인스턴스를 생성합니다.
  jest.json - 단위 테스트용 설정 파일입니다.
/test
  server.e2e-spec.ts
  jest-e2e.json - E2E 테스트용 설정 파일입니다.

HelloRouter 구현

HelloRouter의 테스트 코드를 구현하고 yarn test로 실행합니다.

// src/hello/hello.router.test.ts
import { Response } from 'express';
import { HelloRouter } from './hello.router';

describe('HelloRouter', () => {
  let helloRouter: HelloRouter;

  beforeEach(() => {
    helloRouter = new HelloRouter();
  });

  it('helloGet()', () => {
    const mockedSend = jest.fn();
    const res: Response = {
      send: mockedSend,
    } as unknown as Response;
    helloRouter.helloPost(null, res);
    expect(mockedSend).toBeCalledWith({
      success: true,
      message: 'Hello World!',
    });
  });

  it('helloPost()', () => {
    const mockedSend = jest.fn();
    const res: Response = {
      send: mockedSend,
    } as unknown as Response;
    helloRouter.helloPost(null, res);
    expect(mockedSend).toBeCalledWith({
      success: true,
      message: 'Hello World!',
    });
  });
});


테스트 코드가 실패합니다. 테스트가 통과되도록 HelloRouter를 구현합니다.

// src/hello/hello.router.ts
import { Request, Response } from 'express';

export class HelloRouter {
  helloGet(req: Request, res: Response) {
    res.send({
      success: true,
      message: 'Hello World!',
    });
  }

  helloPost(req: Request, res: Response) {
    res.send({
      success: true,
      message: 'Hello World!',
    });
  }
}

RootRouter 구현

테스트를 통과했으니 HelloRouter를 Express의 Router로 변환할 RootRouter를 구현해보겠습니다.
RootRouter 테스트 코드를 작성합니다.

// src/root.router.test.ts
import { Router } from 'express';
import { HelloRouter } from './hello/hello.router';
import { RootRouter } from './root.router';

describe('RootRouter', () => {
  let rootRouter: RootRouter;

  beforeEach(() => {
    rootRouter = new RootRouter();
  });

  it('transformToExpressRouter()', () => {
    const expressRouter = rootRouter.transformToExpressRouter(HelloRouter);

    // 결과 검증용 Router
    const helloRouter = new HelloRouter();
    const router = Router();
    router.get('/hello', helloRouter.helloGet);
    router.post('/hello', helloRouter.helloPost);

    expect(JSON.stringify(expressRouter.stack)).toStrictEqual(
      JSON.stringify(router.stack),
    );
  });
});

테스트 코드를 통과하도록 RootRouter를 구현합니다

// src/root.router.ts
import { Router } from 'express';
import { HelloRouter } from './hello/hello.router';

type Constructor = new (...args: unknown[]) => unknown;

export class RootRouter {
  transformToExpressRouter(router: Constructor) {
    const expressRouter = Router();
    const routerInstance: HelloRouter = new router() as HelloRouter;
    expressRouter.get('/hello', routerInstance.helloGet);
    expressRouter.post('/hello', routerInstance.helloPost);
    return expressRouter;
  }
}

Server 구현

HelloRouter를 Express()에 넣어 실행할 수 있도록 Server를 구현하겠습니다.
Server E2E 테스트 코드를 작성합니다.

// test/server.e2e-spec.ts
import { Application } from 'express';
import { Server } from '../src/server';
import { RootRouter } from '../src/root.router';
import { HelloRouter } from '../src/hello/hello.router';
import request from 'supertest';

describe('Server E2E Test', () => {
  let app: Application;

  beforeEach(() => {
    const server = new Server();
    const rootRouter = new RootRouter();
    const helloExpressRouter = rootRouter.transformToExpressRouter(HelloRouter);
    server.addRouter(helloExpressRouter);
    app = server.app;
  });

  it('/hello (GET)', () => {
    return request(app).get('/hello').expect(200).expect({
      success: true,
      message: 'Hello World!',
    });
  });

  it('/hello (POST)', () => {
    return request(app).post('/hello').expect(200).expect({
      success: true,
      message: 'Hello World!',
    });
  });
});

Server를 구현합니다.

// src/server.ts
import Express, { Router } from 'express';

export class Server {
  private readonly appInstance = Express();

  constructor() {
    this.appInstance.use(Express.json());
    this.appInstance.use(Express.urlencoded({ extended: true }));
  }

  addRouter(router: Router) {
    this.appInstance.use(router);
  }

  get app() {
    return this.appInstance;
  }
}

테스트 코드를 통과했습니다.

Router가 데코레이터와 메타데이터를 사용하도록 수정하기

현재 RootRouter의 Router 변환 함수는 HelloRouter의 helloGet과 helloPost만 변환할 수 있습니다.

// src/root.router.ts
...
transformToExpressRouter(router: Constructor) {
  const expressRouter = Router();
  const routerInstance: HelloRouter = new router() as HelloRouter;
  expressRouter.get('/hello', routerInstance.helloGet);
  expressRouter.post('/hello', routerInstance.helloPost);
  return expressRouter;
}
...

HelloRouter가 변경되거나 HelloRouter가 아닌 것을 넣어도 동작하도록 수정할 필요가 있습니다.

이를 위해서는 Router의 함수들이 어떤 메소드(ex: GET, POST, PUT)를 사용해 어느 경로(ex: /hello)로 라우팅되는 지 RootRouter가 알 수 있어야 합니다.

메소드와 경로를 메타데이터에 기록하는 @Get() 데코레이터를 구현하겠습니다.

// src/decorators/get.ts
export const Get =
  (route: string) =>
  (target: any, key: string, descriptor: PropertyDescriptor) => {
    Reflect.defineMetadata(
      key,
      {
        method: 'get',
        route,
      },
      target.constructor,
    );
  };

메타데이터를 사용하도록 RootRouter를 수정합니다.

// src/root.router.ts
import { Router } from 'express';
import { HelloRouter } from './hello/hello.router';

type Constructor = new (...args: unknown[]) => unknown;

export class RootRouter {
  transformToExpressRouter(router: Constructor) {
    const expressRouter = Router();
    const routerInstance: HelloRouter = new router() as HelloRouter;

    Object.getOwnPropertyNames(router.prototype)
      .map((key: string) => {
        return {
          route: Reflect.getMetadata(key, router),
          key,
        }
      })
      .filter((data) => data.route !== undefined)
      .forEach((data) => {
        expressRouter[data.route.method](
          data.route.route,
          routerInstance[data.key],
        );
      });

    expressRouter.post('/hello', routerInstance.helloPost);
    return expressRouter;
  }
}

HelloRouter에 @Get() 데코레이터를 넣어줍니다.

// src/hello/hello.router.ts
import 'reflect-metadata';
import { Request, Response } from 'express';
import { Get } from '../decorators/get';

export class HelloRouter {
  @Get('/hello')
  helloGet(req: Request, res: Response) {
    res.send({
      success: true,
      message: 'Hello World!',
    });
  }

  helloPost(req: Request, res: Response) {
    res.send({
      success: true,
      message: 'Hello World!',
    });
  }
}

@Post()@Get()와 똑같이 구현하고 테스트 코드를 실행합니다.

정상적으로 동작하는 것을 확인할 수 있습니다.

RootRouter 수정

현재 RootRouter는 Router를 변환하는 역할만 수행하고 있습니다.

// test/server.e2e-spec.ts
...
describe('Server E2E Test', () => {
  let app: Application;

  beforeEach(() => {
    const server = new Server();
    const rootRouter = new RootRouter();
    const helloExpressRouter = rootRouter.transformToExpressRouter(HelloRouter);
    server.addRouter(helloExpressRouter);
    app = server.app;
  });
...

RootRouter를 Server에 집어넣을 수 있도록 코드를 수정하겠습니다.
E2E 테스트 코드를 수정합니다.

// test/server.e2e-spec.ts
...
describe('Server E2E Test', () => {
  let app: Application;

  beforeEach(() => {
    const server = new Server();
    const rootRouter = new RootRouter();
    rootRouter.addRouter(HelloRouter);
    server.addRouter(rootRouter.router);
    app = server.app;
  });
...

테스트 코드가 성공하도록 RootRouter를 수정합니다.

// src/root.router.ts
import { Router } from 'express';

type Constructor = new (...args: unknown[]) => unknown;

export class RootRouter {
  private readonly routerInstance = Router();

  addRouter(router: Constructor) {
    const expressRouter = this.transformToExpressRouter(router);
    this.routerInstance.use(expressRouter);
  }

  transformToExpressRouter(router: Constructor) {
    const expressRouter = Router();
    const routerInstance = new router();

    Object.getOwnPropertyNames(router.prototype)
      .map((key: string) => {
        return {
          route: Reflect.getMetadata(key, router),
          key,
        };
      })
      .filter((data) => data.route !== undefined)
      .forEach((data) => {
        expressRouter[data.route.method](
          data.route.route,
          routerInstance[data.key],
        );
      });

    return expressRouter;
  }

  get router() {
    return this.routerInstance;
  }
}
profile
https://jinhy.uk

0개의 댓글