🐈

軽量なWebフレームワークtsoaを使って、OpenAPIとexpressルーティングを自動生成する

2022/01/19に公開約18,100字

最近、TypeScript + tsoa + express + TypeORM というような技術スタックでバックエンドの WebAPI 開発を行っています。 tsoa という軽量な Web フレームワークを使っているんですが、OpenAPI の自動生成をしてくれるので、実装と API 仕様書の齟齬がなく快適に開発ができています。この記事では、TypeScript + tsoa + express + TypeORM を使って 簡単な WebAPI を作成してみたいと思います。

tsoa とは

tsoa とは、TypeScript のコードから OpenAPI 定義の自動生成と express、koa、hapi と統合できる Web フレームワークです。TypeScript のデコレータを使ってコントローラークラスに付与して開発していきます。なんらかの MVC フレームワークを使ったことがある方は馴染みがある書き方かなと思います。

Getting Started

必要最低限のライブラリをインストールしてプロジェクトを作成しておきます。Getting Startedの 通りにプロジェクトを作成します。

プロジェクトの作成

mkdir sample-tsoa
cd sample-tsoa

git init
yarn init -y

yarn add tsoa express body-parser
yarn add -D typescript @types/node @types/express @types/body-parser

yarn run tsc --init
tsconfig.json
{
  "compilerOptions": {
    "target": "esNext",
    "lib": ["ESNext"],
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "rootDir": ".",
    "baseUrl": ".",
    "moduleResolution": "node",
    "allowJs": false,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

tsoa.json の作成

tsoa.json というファイルをプロジェクトルートに置きます。これは、tsoa にアプリケーションのエントリーポイントやコントローラーの場所、ビルド後の生成ファイルを出力するディレクトリを伝えるための設定ファイルです。今回は、以下のようにしています。

tsoa.json
{
  "entryFile": "api.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "controllerPathGlobs": ["controllers/**/*Controller.ts"],
  "spec": {
    "outputDirectory": ".build",
    "specVersion": 3
  },
  "routes": {
    "routesDir": ".build"
  }
}

REST API を実装する

docker-compose でローカルの DB を起動

まずは、docker-compose を使って、ローカルに MySQL を起動しておきます。以下のファイルをプロジェクトルートに置きます。また、.env ファイルを作成して必要な環境変数を設定しておきます。

docker-compose.yml
version: "3"
services:
  db:
    image: mysql
    container_name: mysql_local
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      TZ: "Asia/Tokyo"
    volumes:
      - ./docker/db/data:/var/lib/mysql
      - ./docker/db/mysql.cnf:/etc/mysql/conf.d/mysql.cnf
    ports:
      - ${DB_PORT}:3306
  phpmyadmin:
    container_name: phpmyadmin_local
    image: phpmyadmin/phpmyadmin
    ports:
      - ${DB_ADMIN_PORT}:80
    environment:
      PMA_HOSTS: mysql_local
      PMA_USER: ${DB_USERNAME}
      PMA_PASSWORD: ${DB_PASSWORD}
    depends_on:
      - db
.env
DB_DATABASE=sample
DB_HOST=127.0.0.1
DB_PORT=3998
DB_USERNAME=test
DB_PASSWORD=test
DB_ADMIN_PORT=3999
docker-compose up -d

必要な依存関係のインストール

TypeORM と MySQL を使うので、必要なライブラリをインストールします。reflect-metadata については、TypeORM を使う際に必須なのでインストールしておきます。また、アプリケーションから環境変数を扱いたいので、dotenv をインストールします。

yarn add typeorm reflect-metadata mysql2 dotenv ts-node

ormconfig.ts の作成

TypeORM から MySQL と接続するために、ormconfig.ts を作成します。

ormconfig.ts
import dotenv from "dotenv";
import { ConnectionOptions } from "typeorm";

dotenv.config();

export const ormconfig: ConnectionOptions = {
  type: "mysql",
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_DATABASE,
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  synchronize: false,
  logging: false,
  entities: ["entities/**/*.ts"],
  migrations: ["migrations/**/*.ts"],
  subscribers: ["subscribers/**/*.ts"],
  cli: {
    entitiesDir: "entities",
    migrationsDir: "migrations",
    subscribersDir: "subscribers",
  },
};

UserEntity の作成

次に TypeORM の Entity を作成します。今回はユーザーの CRUD をする API なので、シンプルな User クラスを作成します。また、TypeORM のデコレータを付与します。

entities/User.ts
import {
  Column,
  CreateDateColumn,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";

export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  name!: string;

  @CreateDateColumn({
    comment: "作成日時",
    precision: 0,
    default: () => "CURRENT_TIMESTAMP",
  })
  createdAt!: string;

  @UpdateDateColumn({
    comment: "更新日時",
    precision: 0,
    default: () => "CURRENT_TIMESTAMP",
  })
  updatedAt!: string;
}

マイグレーション

↑ で User クラスを作成したので、TypeORM のマイグレーション機能で users テーブルを作成します。package.jsonscriptsに以下を追記します。

package.json
{
  "scripts": {
    "migration:generate": "ts-node -r dotenv/config ./node_modules/typeorm/cli.js migration:generate -f ormconfig.ts -n Initialize",
    "migration:run": "ts-node -r dotenv/config ./node_modules/typeorm/cli.js migration:run -f ormconfig.ts",
    "migration:revert": "ts-node -r dotenv/config ./node_modules/typeorm/cli.js migration:revert -f ormconfig.ts"
  },
}

追記したら、yarn run migration:generate を実行します。以下の用にマイグレーションファイルが生成されます。

migrations/1642568824822-Initialize.ts
import {MigrationInterface, QueryRunner} from "typeorm";

export class Initialize1642568824822 implements MigrationInterface {
    name = 'Initialize1642568824822'

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`CREATE TABLE \`user\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(255) NOT NULL, \`createdAt\` datetime(0) NOT NULL COMMENT '作成日時' DEFAULT CURRENT_TIMESTAMP, \`updatedAt\` datetime(0) NOT NULL COMMENT '更新日時' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`DROP TABLE \`user\``);
    }

}

マイグレーションファイルを確認して、意図通りの SQL が生成されていたら、yarn run migration:run を実行して、ローカルの MySQL に users テーブルを作成します。

yarn run migration:run
yarn run v1.22.11
$ ts-node -r dotenv/config ./node_modules/typeorm/cli.js migration:run -f ormconfig.ts
query: SELECT * FROM `INFORMATION_SCHEMA`.`COLUMNS` WHERE `TABLE_SCHEMA` = 'sample' AND `TABLE_NAME` = 'migrations'
query: SELECT * FROM `sample`.`migrations` `migrations` ORDER BY `id` DESC
0 migrations are already loaded in the database.
1 migrations were found in the source code.
1 migrations are new migrations that needs to be executed.
query: START TRANSACTION
query: CREATE TABLE `user` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `createdAt` datetime(0) NOT NULL COMMENT '作成日時' DEFAULT CURRENT_TIMESTAMP, `updatedAt` datetime(0) NOT NULL COMMENT '更新日時' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`)) ENGINE=InnoDB
query: INSERT INTO `sample`.`migrations`(`timestamp`, `name`) VALUES (?, ?) -- PARAMETERS: [1642568824822,"Initialize1642568824822"]
Migration Initialize1642568824822 has been executed successfully.
query: COMMIT]
✨  Done in 3.37s.

Controller を作成する

ローカルの DB ができたので、コントローラーを作成します。Controller は tsoa における各 WebAPI のエントリーポイントとなるクラスです。

controllers/UserController.ts
import { User } from "../entities/User";
import {
  Body,
  Controller,
  Delete,
  Get,
  Patch,
  Path,
  Post,
  Route,
  SuccessResponse,
  Tags,
} from "tsoa";
import { getRepository } from "typeorm";

type CreateUserParams = {
  name: string;
};

type UpdateUserParams = CreateUserParams;

type UserResponse = {
  id: number;
  name: string;
};

@Route("users")
@Tags("user")
export class UsersController extends Controller {
  /**
   * ユーザーを作成する
   * @param requestBody リクエストボディ
   */
  @SuccessResponse(201, "Created")
  @Post()
  async createUser(@Body() requestBody: CreateUserParams): Promise<void> {
    const repo = getRepository(User);
    const user = repo.create(requestBody);
    repo.save(user);
  }

  /**
   * ユーザーを取得する
   * @param userId ユーザーID
   * @returns ユーザー情報
   */
  @Get("{userId}")
  async getUser(@Path() userId: number): Promise<UserResponse> {
    const repo = getRepository(User);
    const user = await repo.findOne(userId);

    if (!user) {
      throw new Error("ユーザーが見つかりませんでした。");
    }
    return {
      id: user.id,
      name: user.name,
    };
  }

  /**
   * ユーザーを更新する
   * @param userId ユーザーID
   * @param requestBody リクエストボディ
   */
  @Patch("{userId}")
  async updateUser(
    @Path() userId: number,
    @Body() requestBody: UpdateUserParams
  ): Promise<UserResponse> {
    const repo = getRepository(User);
    const user = await repo.findOne(userId);

    if (!user) {
      throw new Error("ユーザーが見つかりませんでした。");
    }

    user.name = requestBody.name;
    const updateUser = await repo.save(user);
    return {
      id: updateUser.id,
      name: updateUser.name,
    };
  }

  @Delete("{userId}")
  async deleteUser(@Path() userId: number): Promise<void> {
    const repo = getRepository(User);
    const user = await repo.findOne(userId);

    if (!user) {
      throw new Error("ユーザーが見つかりませんでした。");
    }

    repo.delete(user);
  }
}

実装を見ていただくとわかるかと思いますが、馴染みのあるデコレータが色々出てきていますね。tsoa が提供しているデコレータを付与します。このデコレーターにより、tsoa が OpenAPI 定義の自動生成や express のルートの自動生成を行います。

OpenAPI の JSON を生成する

コントローラーの実装が終わったので、tsoa を使って OpenAPI の自動生成をしてみます。

yarn run tsoa spec

実行すると、.build 配下に swagger.json が生成されます。

.build/swagger.json
{
    "components": {
        "examples": {},
        "headers": {},
        "parameters": {},
        "requestBodies": {},
        "responses": {},
        "schemas": {
            "CreateUserParams": {
                "properties": {
                    "name": {
                        "type": "string"
                    }
                },
                "required": [
                    "name"
                ],
                "type": "object"
            },
            "UserResponse": {
                "properties": {
                    "name": {
                        "type": "string"
                    },
                    "id": {
                        "type": "number",
                        "format": "double"
                    }
                },
                "required": [
                    "name",
                    "id"
                ],
                "type": "object"
            },
            "UpdateUserParams": {
                "$ref": "#/components/schemas/CreateUserParams"
            }
        },
        "securitySchemes": {}
    },
    "info": {
        "title": "sample-tsoa",
        "version": "1.0.0",
        "license": {
            "name": "MIT"
        },
        "contact": {
            "name": "sato.naoya ",
            "email": "sato.naoya@classmethod.jp"
        }
    },
    "openapi": "3.0.0",
    "paths": {
        "/users": {
            "post": {
                "operationId": "CreateUser",
                "responses": {
                    "201": {
                        "description": "Created"
                    }
                },
                "description": "ユーザーを作成する",
                "tags": [
                    "user"
                ],
                "security": [],
                "parameters": [],
                "requestBody": {
                    "description": "リクエストボディ",
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/CreateUserParams"
                            }
                        }
                    }
                }
            }
        },
        "/users/{userId}": {
            "get": {
                "operationId": "GetUser",
                "responses": {
                    "200": {
                        "description": "ユーザー情報",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/UserResponse"
                                }
                            }
                        }
                    }
                },
                "description": "ユーザーを取得する",
                "tags": [
                    "user"
                ],
                "security": [],
                "parameters": [
                    {
                        "description": "ユーザーID",
                        "in": "path",
                        "name": "userId",
                        "required": true,
                        "schema": {
                            "format": "double",
                            "type": "number"
                        }
                    }
                ]
            },
            "patch": {
                "operationId": "UpdateUser",
                "responses": {
                    "200": {
                        "description": "Ok",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/UserResponse"
                                }
                            }
                        }
                    }
                },
                "description": "ユーザーを更新する",
                "tags": [
                    "user"
                ],
                "security": [],
                "parameters": [
                    {
                        "description": "ユーザーID",
                        "in": "path",
                        "name": "userId",
                        "required": true,
                        "schema": {
                            "format": "double",
                            "type": "number"
                        }
                    }
                ],
                "requestBody": {
                    "description": "リクエストボディ",
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/UpdateUserParams"
                            }
                        }
                    }
                }
            },
            "delete": {
                "operationId": "DeleteUser",
                "responses": {
                    "204": {
                        "description": "No content"
                    }
                },
                "tags": [
                    "user"
                ],
                "security": [],
                "parameters": [
                    {
                        "in": "path",
                        "name": "userId",
                        "required": true,
                        "schema": {
                            "format": "double",
                            "type": "number"
                        }
                    }
                ]
            }
        }
    },
    "servers": [
        {
            "url": "/"
        }
    ]
}

express のルート定義を作成する

次に、tsoa を使って express のルート定義を作成します。

yarn run tsoa routes

実行が終わると、.build フォルダ配下にroutes.tsが作成されて、express のルーティング関数が自動生成されます。

.build/routes.ts
export function RegisterRoutes(app: express.Router) {
    // ###########################################################################################################
    //  NOTE: If you do not see routes for all of your controllers in this file, then you might not have informed tsoa of where to look
    //      Please look into the "controllerPathGlobs" config option described in the readme: https://github.com/lukeautry/tsoa
    // ###########################################################################################################
        app.post('/users',

            function UsersController_createUser(request: any, response: any, next: any) {
            const args = {
                    requestBody: {"in":"body","name":"requestBody","required":true,"ref":"CreateUserParams"},
            };

            // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa

            let validatedArgs: any[] = [];
            try {
                validatedArgs = getValidatedArgs(args, request, response);

                const controller = new UsersController();


              const promise = controller.createUser.apply(controller, validatedArgs as any);
              promiseHandler(controller, promise, response, undefined, next);
            } catch (err) {
                return next(err);
            }
        });
        ...
        ...
        ...

express サーバーを作成する

OpenAPI とルーティング関数が作成できたので、express を使って HTTP サーバーを作成します。さきほど生成された RegisterRoutes 関数を実行します。また、swagger-ui を使って express でルーティングさせたいので、必要なライブラリをインストールします。

yarn add swagger-ui-express
yarn add --dev @types/swagger-ui-express
api.ts
import express from "express";
import bodyParser from "body-parser";
import swaggerUi from "swagger-ui-express";
import swaggerDocument from "./.build/swagger.json";

import { RegisterRoutes } from "./.build/routes";

export const app = express();

app.use(
  bodyParser.urlencoded({
    extended: true,
  })
);
app.use(bodyParser.json());

// Swagger UI
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));

// 自動生成された関数にexpress appを引数に渡して実行する
RegisterRoutes(app);
server.ts
import ormconfig from "./ormconfig";
import "reflect-metadata";
import { createConnection } from "typeorm";
import { app } from "./api";

const port = process.env.PORT || 3000;

(async () => {
  await createConnection(ormconfig);

  app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`);
  });
})();

実行してみる

実装が一通り終わったので、実行してみます。

ts-node server.ts

以下にアクセスすると、コントローラに実装した内容に沿った、Swagger UI が表示されました。この画面から API を実行して動作を確認することもできました。

http://localhost:3000/api/docs

おわりに

tsoa を使った WebAPI の実装方法について紹介しました。tsoa を使うことで、API 仕様書と TypeScript の実装が常に一致するので齟齬がなくなり便利でした。基本的には express などの既存のフレームワークの補助的に使う形なので、express に理解があれば比較的簡単に導入できます。ただ、コントローラの実装がデコレーターまみれになるのと、コントローラに直接実装していくと見通しが悪くなっていくので、適宜レイヤ分けをして実装していくのが良いと思います。

この記事のサンプルリポジトリ

https://github.com/briete/tsoa-sample
GitHubで編集を提案

Discussion

ログインするとコメントできます