본문 바로가기
JavaScript│Node js

Typescript-rest 로 만든 프로젝트 tsoa로 개편

by 자유코딩 2019. 12. 9.

Typescript-rest 로 만든 api 를 tsoa로 개편했다.

바꾸게 된 이유는 아래와 같다.

1. 점점 잠잠해져가는 Typescript-rest 커뮤니티

2. tsoa는 swagger 3.0도 지원된다.

3. 점점 살아나는 tsoa 커뮤니티

 

Typescript-rest 저장소 링크

https://www.npmjs.com/package/typescript-rest

 

typescript-rest

A Library to create RESTFul APIs with Typescript

www.npmjs.com

tsoa 저장소 링크

https://www.npmjs.com/package/tsoa

 

tsoa

Build swagger-compliant REST APIs using TypeScript and Node

www.npmjs.com

last publish 부분을 보면 Typescript-rest 는 5달 이전이다.

tsoa는 25일전이다.

 

Typescript-rest 모듈의 앞날이 어떨지 모르겠지만 tsoa가 더 활성화 될 것으로 예상되어서 개편했다.

 

그럼 지금부터 Typescript-rest 모듈과의 차이점을 살펴본다.

 

1. server 부분 코드의 차이

Typescript-rest 는 예제 코드가 클래스로 되어 있다.

아래 코드는 Typescript-rest boilerplate에서 가져왔다.

https://github.com/vrudikov/typescript-rest-boilerplate/blob/master/src/api-server.ts

 

vrudikov/typescript-rest-boilerplate

Boilerplate project for awesome typescript-rest(https://github.com/thiagobustamante/typescript-rest) library - vrudikov/typescript-rest-boilerplate

github.com

import * as cors from 'cors';
import * as express from 'express';
import * as http from 'http';
import * as morgan from 'morgan';
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
import * as path from 'path';
import { PassportAuthenticator, Server } from 'typescript-rest';

export class ApiServer {
    public PORT: number = +process.env.PORT || 3000;

    private readonly app: express.Application;
    private server: http.Server = null;

    constructor() {
        this.app = express();
        this.config();

        Server.useIoC();

        Server.loadServices(this.app, 'controller/*', __dirname);
        Server.swagger(this.app, { filePath: './dist/swagger.json' });
    }

    /**
     * Start the server
     */
    public async start() {
        return new Promise<any>((resolve, reject) => {
            this.server = this.app.listen(this.PORT, (err: any) => {
                if (err) {
                    return reject(err);
                }

                // TODO: replace with Morgan call
                // tslint:disable-next-line:no-console
                console.log(`Listening to http://127.0.0.1:${this.PORT}`);

                return resolve();
            });
        });

    }

    /**
     * Stop the server (if running).
     * @returns {Promise<boolean>}
     */
    public async stop(): Promise<boolean> {
        return new Promise<boolean>((resolve) => {
            if (this.server) {
                this.server.close(() => {
                    return resolve(true);
                });
            } else {
                return resolve(true);
            }
        });
    }

    /**
     * Configure the express app.
     */
    private config(): void {
        // Native Express configuration
        this.app.use(express.static(path.join(__dirname, 'public'), { maxAge: 31557600000 }));
        this.app.use(cors());
        this.app.use(morgan('combined'));
        this.configureAuthenticator();
    }

    private configureAuthenticator() {
        const JWT_SECRET: string = 'some-jwt-secret';
        const jwtConfig: StrategyOptions = {
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
            secretOrKey: Buffer.from(JWT_SECRET)
        };
        const strategy = new Strategy(jwtConfig, (payload: any, done: (err: any, user: any) => void) => {
            done(null, payload);
        });
        const authenticator = new PassportAuthenticator(strategy, {
            deserializeUser: (user: string) => JSON.parse(user),
            serializeUser: (user: any) => {
                return JSON.stringify(user);
            }
        });
        Server.registerAuthenticator(authenticator);
        Server.registerAuthenticator(authenticator, 'secondAuthenticator');
    }
}

 

tsoa는 함수로 되어있다.

https://github.com/lukeautry/ts-app/blob/master/api/server.ts

 

lukeautry/ts-app

Boilerplate project for a TypeScript API (Express, tsoa) + UI (React/TSX) - lukeautry/ts-app

github.com

import bodyParser from "body-parser";
import chalk from "chalk";
import express from "express";
import http from "http";
import methodOverride from "method-override";
import "./controllers/widgets-controller";
import { RegisterRoutes } from "./routes";
import { log } from "./utils/log";

export const server = () => {
  const app = express()
    .use(bodyParser.urlencoded({ extended: true }))
    .use(bodyParser.json())
    .use(methodOverride())
    .use((_req, res, next) => {
      res.header("Access-Control-Allow-Origin", "*");
      res.header(
        "Access-Control-Allow-Headers",
        `Origin, X-Requested-With, Content-Type, Accept, Authorization`,
      );
      next();
    });

  RegisterRoutes(app);

  interface IError {
    status?: number;
    fields?: string[];
    message?: string;
    name?: string;
  }

  app.use(
    (
      err: IError,
      _req: express.Request,
      res: express.Response,
      next: express.NextFunction,
    ) => {
      const status = err.status || 500;
      const body = {
        fields: err.fields || undefined,
        message: err.message || "An error occurred during the request.",
        name: err.name,
        status,
      };
      res.status(status).json(body);
      next();
    },
  );

  const port = 3000;

  return new Promise<http.Server>((resolve) => {
    const s = app.listen(port, () => {
      log(chalk.blueBright(`✓ Started API server at http://localhost:${port}`));
      resolve(s);
    });
  });
};

아마도 tsoa 는 express.js 생성기로 프로젝트를 만든 상태에서 타입만 추가되는 방향으로 개발 된 것 같다.

여기서 제공하는 tsoa 예제코드를 클래스 형태로 바꿨다.

바꾸면서 start, stop, config 함수도 만들었다.

 

대략 아래 코드처럼 만들었다.

export class ApiServer {
  public PORT: number = +process.env.PORT || 3000;
  private readonly app: express.Application;
  private server: http.Server = null;
  constructor() {
    this.app = express();
    this.config();
    // auth
    // swagger
    this.app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
      if (err) {
        const status = err.status || 500;
        const body = {
          fields: err.fields || undefined,
          message: err.message || "An error occurred during the request.",
          name: err.name,
          status,
        };
        res.set("Content-Type", "application/json");
        res.status(status).json(body);
        return next(err);
      } else {
        next(err);
      }
    });
    RegisterRoutes(this.app);
  }
  /**
   * config
   */
  public config(): void {
    if (process.env.NODE_ENV === 'testing') {
      dotenv.config({ path: '.env.testing' });
    } else {
      dotenv.config({ path: '.env' });
    }
    this.app.use(express.urlencoded({ extended: false }));
    this.app.use(express.json());
    this.app.use(express.static(path.join(__dirname, 'public'), { maxAge: 31557600000 }));
    this.app.use(cors());
    this.app.use(helmet());
    MongoDbService.initProjectDb();
  }
  /**
   * Start the server
   * @returns {Promise<any>}
   */
  public start(): Promise<any> {
    return new Promise<any>((resolve, reject) => {
      this.server = this.app.listen(this.PORT, (err: any) => {
        if (err) {
          return reject(err);
        }
        console.log(chalk.blueBright(`✓ Started API server at http://127.0.0.1:${this.PORT}`));
        return resolve();
      });
    });
  }
  /**
   * Stop the server (if running).
   * @returns {Promise<boolean>}
   */
  public stop(): Promise<boolean> {
    // stop server
    return new Promise<boolean>((resolve, reject) => {
      if (this.server) {
        MongoDbService.closeDbConnection();
        this.server.close(() => {
          console.warn('closing...');
          return resolve(true);
        });
      } else {
        return resolve(true);
      }
    });
  }
}

여기서 한가지 문제가 있다.

아직 controller 를 server 코드에 추가해주지 않았다.

다시 말해서 router 를 추가해주지 않았다.

 

router를 추가하는 방법은 문서를 살펴보면 2가지가 있다.

하나는 route.get 또는 app.get하면서 직접 추가하는 방식이다.

아래 코드처럼.

export function RegisterRoutes(app: express.Express) {
  app.get('/api/widgets',
    function(request: any, response: any, next: any) {
      const args = {
      };

      let validatedArgs: any[] = [];
      try {
        validatedArgs = getValidatedArgs(args, request);
      } catch (err) {
        return next(err);
      }

      const controller = new WidgetsController();


      const promise = controller.GetWidgets.apply(controller, validatedArgs as any);
      promiseHandler(controller, promise, response, next);
    });
  app.get('/api/widgets/:widgetId',
    function(request: any, response: any, next: any) {
      const args = {
        widgetId: { "in": "path", "name": "widgetId", "required": true, "dataType": "double" },
      };

      let validatedArgs: any[] = [];
      try {
        validatedArgs = getValidatedArgs(args, request);
      } catch (err) {
        return next(err);
      }

      const controller = new WidgetsController();


      const promise = controller.GetWidget.apply(controller, validatedArgs as any);
      promiseHandler(controller, promise, response, next);
    });

url의 개수가 20개 30개가 되면 app.get 하는 부분을 20개 30개 작성해야한다.

굉장히 좋지 않은 방법인 것 같다.

그래서 문서에서는 router를 자동으로 만드는 2번째 방법을 제공한다.

 

2번째 방법을 하려면 프로젝트의 루트경로에 tsoa.json 파일을 만든다.

{
    "swagger": {
        "outputDirectory": "./dist",
        "entryFile": "./src/controllers",
        "name": "api 이름",
        "description": "api 설명",
        "version": "0.0.1",
        "produces": [
            "application/json"
        ]
    },
    "routes": {
        "entryFile": "./src/controllers",
        "routesDir": "./src/routes"
    }
}

package.json에 routes 라는 명령어를 추가하고 명령어를 통해서 routes.ts 파일을 만들 것이다.

tsoa.json에는 만들어낸 routes.ts 파일이 어디에 있으면 되는지, 그리고 컨트롤러는 어디에 있는 것을 읽으면 되는지 명시하는 파일이다.

swagger 에는 swagger와 관련된 내용을 적는다.

src 밑에 controllers 라는 디렉토리가 있어야 한다.

그리고 ./src 밑에 routes 라는 디렉토리도 있어야 한다.

 

 

package.json에는 명령어를 추가한다.

"scripts": {
    ......
    "routes": "tsoa routes"
    ......
  },

여기 쯤에서 에러가 발생하는 사람들도 있을 것 같다.

도움이 될만한 글의 링크를 첨부한다.

https://www.rajram.net/node-101-part-4-auto-generate-and-register-routes-in-node-for-web-apis-2/

 

Node 101 - Part 4 - Auto generate and register routes in node for web APIs

This blog post is part of the Node 101 series. So far, we have covered the following Part 1 - Overview of web api that you will develop in node Part 2 - Created your first web api in node using typescript and express Part 3 - Use Test Driven

www.rajram.net

이렇게 swagger도 할 수 있고 routes도 생성할 수 있다면 api 를 만들 수는 있다.

그런데 여기서 한가지 이슈를 겪었다. tsoa 프로젝트에서는 외부 node_module을 타입으로 쓸 수 없다.

@Post()
  @Tags('Recruit')
  @SuccessResponse(200, '성공 시')
  public async postRecruit(body: Recruit) {
    return '';
  }

이런 경우에 body의 타입인 Recruit 를 외부 모듈로 쓰면 안된다는 말이다.

 

이건 조금 큰 단점이 될 수도 있다.

 

왜 그러나면..

지금 나는 프론트엔드와 백엔드에서 타입스크립트를 사용하고 있다.

그러면서 프론트엔드 프로젝트와 백엔드에서 공통으로 사용할 타입스크립트 모듈을 만들었다.

이 모듈에는 서버로 보내고 받는 데이터의 타입이 정의되어 있는데 이걸 컨트롤러에서 못 쓴다는 말이 된다.

 

자세한 내용은 아래 링크에 나와있다.

https://github.com/lukeautry/tsoa/blob/master/docs/ExternalInterfacesExplanation.MD

 

lukeautry/tsoa

Build swagger-compliant REST APIs using TypeScript and Node - lukeautry/tsoa

github.com

링크에서는 해결 방법을 외부 모듈이랑 똑같이 생긴 interface 를 프로젝트에 하나 더 만들으라고 한다.

이 방법을 썼을때 성능이슈는 없다고 한다.

 

외부 모듈에서 받아와서 어떻게 편하게 쓰는 꼼수가 있을까 고민했다.

결국 컨트롤러에 타입을 모두 적어서 코드를 복잡하게 만들지는 않았다.

src 아래 types 라는 폴더를 만들고 typesCopy.ts 파일을 만들었다.

그리고 이 파일 안에 해당 api 에서 쓰는 모든 타입의 복사본을 정의했다.

 

여기서 한가지 궁금증이 생길 수 있다.

type의 복사본을 매번 작성하는 것이 번거로우니 extends 해서 쓰면 어떨까하는 생각이 들 수 있다.

extends 해서 사용해도 안된다. 똑같은 에러가 발생한다.

그냥 사본을 정의해야만 한다.

 

 

그렇게 사본을 작성하니 잘 동작한다.

결과적으로 개편한 서버 코드는 아래 코드처럼 작성했다.

import * as cors from 'cors';
import * as dotenv from 'dotenv';
import * as express from 'express';
import * as helmet from 'helmet';
import * as http from 'http';
import * as path from 'path';
import { RegisterRoutes } from './routes/routes';
import * as swaggerUI from 'swagger-ui-express';
import MongoDbService from './services/mongoDb';

const swaggerJson = require('../dist/swagger.json');
export class ApiServer {
  public PORT: number = +process.env.PORT || 3000;

  private readonly app: any;
  private server: http.Server = null;

  constructor() {
    this.app = express();
    this.config();
	
    // 서버에 컨트롤러 등록
    RegisterRoutes(this.app);
    // swagger 페이지 생성
    this.app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(swaggerJson));

    this.app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
      if (err) {
        const status = err.status || 500;
        const body = {
          fields: err.fields || undefined,
          message: err.message || "An error occurred during the request.",
          name: err.name,
          status,
        };
        res.set("Content-Type", "application/json");
        res.status(status).json(body);
        return next(err);
      } else {
        next(err);
      }
    });
  }

  public getApp(): express.Application {
    return this.app;
  }

  /**
   * Start the server
   * @returns {Promise<any>}
   */
  public start(): Promise<any> {
    return new Promise<any>((resolve, reject) => {
      this.server = this.app.listen(this.PORT, (err: any) => {
        if (err) {
          return reject(err);
        }

        // tslint:disable-next-line:no-console
        console.log(`Listening to http://127.0.0.1:${this.PORT}`);

        return resolve();
      });
    });

  }

  /**
   * Stop the server (if running).
   * @returns {Promise<boolean>}
   */
  public stop(): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {
      if (this.server) {
        this.server.close(() => {
          return resolve(true);
        });
      } else {
        return resolve(true);
      }
    });
  }

  /**
   * Configure the express app.
   */
  private config(): void {
    // Native Express configuration
    if (process.env.NODE_ENV === 'testing') {
      dotenv.config({ path: '.env.testing' });
    } else {
      dotenv.config({ path: '.env' });
    }
    this.app.use(express.urlencoded({ extended: false }));
    this.app.use(express.json());
    this.app.use(express.static(path.join(__dirname, 'public'), { maxAge: 31557600000 }));
    this.app.use(cors());
    this.app.use(helmet());
    // 별도로 작성한 몽고DB 연결 코드 실행
    MongoDbService.initProjectDb();
  }
}

댓글