tsoaとOpenAPIでスキーマ駆動開発


# はじめに

tsoaとOpenAPIを使って、（yamlを書かずに）TypeScriptだけで完結するスキーマ駆動開発を試してみました。

OpenAPIと言えば、yamlファイルを書いてGeneratorでコードを生成するフローがよくあるパターンかと思いますが、フロントエンドもバックエンドもTypeScriptで書いている場合、yamlファイルを書かずに、TypeScriptだけで完結できると効率が良いのではないかと思い、実装してみました。

# 環境
- Node.js
- TypeScript
- Express
- tsoa
- OpenAPI（openapi-generator-cli）

# 全体の流れ（ざっくり）

1. TypeScriptでリクエスト・レスポンスのモデルを定義
2. TypeScriptでコントローラーを定義
3. 上記1,2の定義を元に、tsoaでルーターとswagger.jsonを生成
    - swagger.jsonはAPIスキーマであり、（通常の）yamlファイルに相当するもの
4. swagger.jsonを元に、openapi-generator-cliでAPIクライアントを生成


# セットアップ
インストール周りは省略しますが、最終的には以下の構成になります。
（バージョンの指定は任意です）


```json
{
  "scripts": {
    "tsoa": "ts-node-dev tsoaconfig.ts"
  },
  "dependencies": {
    "express": "^4.18.2",
    "tsoa": "^5.1.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "@types/node": "^20.6.2",
    "@types/swagger-ui-express": "^4.1.3",
    "swagger-ui-express": "^5.0.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.2.2"
  }
}
```

tsoaのコンフィグファイルを作成。
生成するAPIスキーマやルーターの仕様、出力先などを設定します。


```typescript:tsoaconfig.ts
import {
  generateRoutes,
  generateSpec,
  ExtendedRoutesConfig,
  ExtendedSpecConfig,
} from "tsoa";

(async () => {
  const specOptions: ExtendedSpecConfig = {
    entryFile: "src/index.ts",
    noImplicitAdditionalProperties: "throw-on-extras",
    outputDirectory: "src/infrastructure/express",
    specVersion: 3,
    version: "1.0.0",
    controllerPathGlobs: ["src/controllers/**/*.ts"],
    name: "API",
    host: "localhost:4000",
    schemes: ["http"],
    basePath: "/",
    specFileBaseName: "swagger",
  };

  const routeOptions: ExtendedRoutesConfig = {
    entryFile: "src/index.ts",
    noImplicitAdditionalProperties: "throw-on-extras",
    routesDir: "src/infrastructure/express",
    controllerPathGlobs: ["src/controllers/**/*.ts"],
  };

  await Promise.all([generateSpec(specOptions), generateRoutes(routeOptions)]);
})();
```

`specOptions`がAPIスキーマの設定、`routeOptions`がルーターの設定です。
`specOptions`によりswagger.json、`routeOptions`によりルーターが生成されます。

# モデルとコントローラーの定義

今回はUserの作成・取得を例に、モデルとコントローラーを定義します。
（サンプルのため、コントローラーより上位レイヤーの実装は省略しています）

## モデル

User作成時のリクエストパラメーターとUser取得時のレスポンスのモデルを定義します。

```typescript:controllers/users/user-model.ts
export class UserCreateParams {
  name: string;
  status: string;

  constructor({ name, status }: { name: string; status: string }) {
    this.name = name;
    this.status = status;
  }
}

export class UserResponse {
  id: string;
  name: string;
  status: string;

  constructor({
    id,
    name,
    status,
  }: {
    id: string;
    name: string;
    status: string;
  }) {
    this.id = id;
    this.name = name;
    this.status = status;
  }
}
```

※ モデルはtypeやinterfaceでも良いです。

## コントローラー

tsoaのコントローラーは、`@Route`デコレーターをつけたクラスとして定義します。
`@Route`デコレーターの引数には、ルーティングのベースパスを指定します。

```typescript:controllers/users/user-controller.ts
import { Request as EXRequest } from "express";
import {
  Body,
  Controller,
  Get,
  Path,
  Post,
  Route,
  SuccessResponse,
  Tags,
  Request,
} from "tsoa";
import { UserCreateParams, UserResponse } from "./user-model";

@Route("users")
@Tags("User")
export class UserController extends Controller {
  @Post()
  @SuccessResponse("200", "Return a user")
  public async createUser(
    @Request() _: EXRequest,
    @Body() params: UserCreateParams
  ): Promise<UserResponse> {
    return new UserResponse({
      id: "1",
      name: params.name,
      status: params.status,
    });
  }

  @Get("{id}")
  @SuccessResponse("200", "Return a user")
  public async findUser(
    @Request() _: EXRequest,
    @Path() id: string
  ): Promise<UserResponse> {
    return new UserResponse({
      id,
      name: "test user",
      status: "active",
    });
  }
}
```

以上で、ルーターとswagger.jsonの生成準備が出来ました。

# ルーターとswagger.jsonの生成

以下のコマンドで実行します。

```bash
yarn tsoa
```

tsoaconfig.tsで設定した場所に、routes.tsとswagger.jsonが生成されます。

routes.ts（ルーター）はExpressのルーターとして設定します。


```typescript:infrastructure/express/app.ts
import { RegisterRoutes } from "./routes";
import swaggerUi from "swagger-ui-express";
import swaggerDocument from "./swagger.json";

const app = express();

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

RegisterRoutes(app);

export default app;
```

# APIクライアントの生成

openapi-generator-cliを使って、APIクライアントを生成します。
cliを使用するためには、Javaの実行環境が必要になりますが、私の場合、普段、Javaを使うことがほとんどなく、このためにJavaの環境構築するのも億劫なので、Dockerを使って実行することにしました。


```bash
docker run \
  --rm \
  -v ${PWD}/src/infrastructure/express:/input \
  -v ${PWD}/client/openapi/gen:/output \
  openapitools/openapi-generator-cli \
  generate \
  -g typescript-axios \
  -i /input/swagger.json \
  -o /output/v1.0 \
  --additional-properties enumPropertyNaming=UPPERCASE
```

`-i`オプションでAPIスキーマとなるswagger.jsonを指定し、`-o`オプションでAPIクライアントの出力先を指定します。

実行すると、以下のディレクトリ・ファイルが生成されます。

```bash
client
└── openapi
    └── gen
        └── v1.0
            ├── api.ts
            ├── base.ts
            ├── common.ts
            ├── configuration.ts
            ├── git_push.sh
            └── index.ts
```
生成されたapi.tsの一部を抜粋すると、以下のようにAPIクライアントの型が定義されています。これらの型を使ってクライアント側を実装します。

```typescript:api.ts
/**
 * UserApi - object-oriented interface
 * @export
 * @class UserApi
 * @extends {BaseAPI}
 */
export class UserApi extends BaseAPI {
    /**
     * 
     * @param {UserCreateParams} userCreateParams 
     * @param {*} [options] Override http request option.
     * @throws {RequiredError}
     * @memberof UserApi
     */
    public createUser(userCreateParams: UserCreateParams, options?: AxiosRequestConfig) {
        return UserApiFp(this.configuration).createUser(userCreateParams, options).then((request) => request(this.axios, this.basePath));
    }

    /**
     * 
     * @param {string} id 
     * @param {*} [options] Override http request option.
     * @throws {RequiredError}
     * @memberof UserApi
     */
    public findUser(id: string, options?: AxiosRequestConfig) {
        return UserApiFp(this.configuration).findUser(id, options).then((request) => request(this.axios, this.basePath));
    }
}
```
クライアント側の実装例。

```typescript
const baseConfig = new Configuration({
  basePath: API_BASE_URL,
});

const userApi = new UserApi(baseConfig);
const user = userApi.findUser(id)
```

以上で、APIクライアントの実装は完了です。


最後に、Expressサーバを起動し、クライアントからAPIを叩ければ成功です。


```typescript:index.ts
import app from "./infrastructure/express/app";

const port = process.env.API_PORT;
app.listen(port, () => {
  console.log(`Running server at http://localhost:${port}`);
});
```

Swagger UIでAPIスキーマも確認できます。
![swagger-ui.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/d0b62848-e897-7573-a12d-53b6565120b1.png)

# 参考
- [tsoa](https://tsoa-community.github.io/docs/)
- [OpenAPI Generator](https://openapi-generator.tech/)
- [Express](https://expressjs.com/)