FoalTS + openapi-generatorで型安全なAPI開発

FoalTS

この前はFoalTSについて紹介しました。

FoalTSを使ってみた。これは熱いフレームワークだ。
久しぶりにGASじゃない話。と言うか、フロントエンドでもなく、今日はバックエンドの話。FoalTSが私の中で熱いという話をしたいと思います。巷ではNestJSの方が人気かと思いますが、、、フレームワークの選定基準まずは、私がFoa...

この記事では実際に開発フローを紹介していきたいと思います。基本的には公式のチュートリアルに沿っていきますが、最後の方で、「型安全」について触れたいと思います。

記事執筆時点での環境です

$ node -v
v16.13.1

$ npm -v
8.1.2

インストール

CLIツールをインストールします

$ npm install -g @foal/cli

インストールされたかどうかは、バージョンを確認するとかで。

$ foal -v
2.8.1

プロジェクトの作成

foal createapp コマンドで行います。

$ foal createapp my-app

 ------------------------------------------------- 
|                                                 |
|                     Foal                        |
|                                                 |
 ------------------------------------------------- 

  📂 Creating files...

  📦 Installing dependencies (yarn)...

  📔 Initializing git repository...

✨ Project successfully created.
👉 Here are the next steps:

    $ cd my-app
    $ npm run develop

作成されたプロジェクトに移動し、 npm run develop で開発サーバーが立ち上がります。

今回作成するモデル

公式のチュートリアルに沿って、 Todo モデルを作成することにします。

The Todo Model | FoalTS
The next step is to take care of the database. By default, every new project in FoalTS is configured to use an SQLite database as it does not require any additi...

TypeORMの世界では、モデルをエンティティと呼んでいるので、エンティティを生成するコマンドを実行します。

$ foal generate entity todo
CREATE src/app/entities/todo.entity.ts
UPDATE src/app/entities/index.ts

エンティティの定義を行うファイル todo.entity.ts が生成され、エンティティのエントリポイント index.ts が更新されています。

エンティティ定義の変更とマイグレーション

この先もチュートリアルに沿っていきます。

src/app/entities/todo.entity.ts を以下のように編集します。

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Todo extends BaseEntity {

  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  text: string;

}

エンティティ定義を更新した後は、マイグレーションファイルを生成することになると思います。
生成コマンドは npm run makemigrations です。

$ npm run makemigrations

> my-app@0.0.0 makemigrations
> foal rmdir build && tsc -p tsconfig.app.json && npx typeorm migration:generate --name migration && tsc -p tsconfig.app.json

Migration /Users/path/to/projectspace/my-app/src/migrations/1646229375594-migration.ts has been generated successfully.

マイグレーションを実行しましょう。

$ npm run migrations

> my-app@0.0.0 migrations
> npx typeorm migration:run

query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" = 'migrations'
query: CREATE TABLE "migrations" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "timestamp" bigint NOT NULL, "name" varchar NOT NULL)
query: SELECT * FROM "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: BEGIN TRANSACTION
query: CREATE TABLE "todo" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "text" varchar NOT NULL)
query: CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL)
query: INSERT INTO "migrations"("timestamp", "name") VALUES (?, ?) -- PARAMETERS: [1646229375594,"migration1646229375594"]
Migration migration1646229375594 has been executed successfully.
query: COMMIT

無事にテーブルが作成されました。
余談ですが、プロジェクトを作成した時点で User エンティティがデフォルトで存在します。今回は無視します。

データ投入用のシェルスクリプト作成

公式のチュートリアルによれば、データベースにログインして直接SQLを叩いてデータを投入するのではなく、データ投入用のスクリプトを書いた方が、便利だし安全ですよ、とのことなので、そうします。

$ foal generate script create-todo
CREATE src/scripts/create-todo.ts

生成された src/scripts/create-todo.ts を以下のように編集します。

// 3p
import { IApiSchema } from '@foal/core';
import { createConnection } from 'typeorm';

// App
import { Todo } from '../app/entities';

export const schema: IApiSchema = {
  properties: {
    text: { type: 'string' }
  },
  required: [ 'text' ],
  type: 'object',
};

export async function main(args: { text: string }) {
  // Create a new connection to the database.
  const connection = await createConnection();

  try {
    // Create a new task with the text given in the command line.
    const todo = new Todo();
    todo.text = args.text;

    // Save the task in the database and then display it in the console.
    console.log(await todo.save());
  } catch (error) {
    console.log(error.message);
  } finally {
    // Close the connection to the database.
    await connection.close();
  }
}

ビルドをすることで、スクリプトを実行するためのファイルが生成されます。(ビルドされたファイルは build フォルダに格納されます)

$ npm run build

> my-app@0.0.0 build
> foal rmdir build && tsc -p tsconfig.app.json

このスクリプトを実行してみましょう。

$ foal run create-todo text="Read the docs"
Todo { text: 'Read the docs', id: 1 }

無事データが登録されました。
チュートリアルに沿って、もう二つデータを登録します。

$ foal run create-todo text="Create my first application"
Todo { text: 'Create my first application', id: 2 }
$ foal run create-todo text="Write tests"
Todo { text: 'Write tests', id: 3 }

コントローラの実装

HTTPリクエストを処理する部分を実装します。

チュートリアルでは src/app/controllers/api.controller.ts に直接処理を記述していますが、実際の開発ではエンティティ毎にコントローラを分けるべきと思います。
ので、コントローラを生成します。ここからチュートリアルを逸脱します。

コントローラの生成

$ foal generate controller api/todos --register
CREATE src/app/controllers/api/todos.controller.ts
CREATE src/app/controllers/api/todos.controller.spec.ts
CREATE src/app/controllers/api/index.ts
UPDATE src/app/controllers/api/index.ts
UPDATE src/app/controllers/api.controller.ts

コントローラの命名は、エンティティを複数形にしたものです。生成するコントローラ名の前に、今回で言えば api/ と言う形でスラッシュ区切りのものを付加しています。こうすることで、 src/app/controllers/ 配下で、好きなようにディレクトリを分けることができます。

このコントローラ生成によって、コントローラの処理を記述するファイル todos.controller.ts とそれをテストするファイル todos.controller.spec.ts が生成されます。

また、 src/app/controllers/api.controller.ts の内容が以下のように更新されています。

import { Context, controller, Get, HttpResponseOK } from '@foal/core';
import { TodosController } from './api';

export class ApiController {
  subControllers = [
    controller('/todos', TodosController)
  ];

  @Get('/')
  index(ctx: Context) {
    return new HttpResponseOK('Hello world!');
  }

}

この subcontrollers というところに TodosController が追加されています。このように、親コントローラー内で subcontrollers を定義することでネストされたルーティングを実現するようになっている模様です。

処理記述

処理の内容は主にルーティングです。

import { Context, Delete, Get, HttpResponseCreated, HttpResponseNoContent, HttpResponseNotFound, HttpResponseOK, Post } from '@foal/core';
import { Todo } from '../../entities';

export class TodosController {

  @Get('/')
  async getTodos() {
    const todos = await Todo.find();
    return new HttpResponseOK(todos);
  }

  @Post('/')
  async postTodo(ctx: Context) {
    // Create a new todo with the body of the HTTP request.
    const todo = new Todo();
    todo.text = ctx.request.body.text;

    // Save the todo in the database.
    await todo.save();

    // Return the new todo with the id generated by the database. The status is 201.
    return new HttpResponseCreated(todo);
  }

  @Delete('/:id')
  async deleteTodo(ctx: Context) {
    // Get the todo with the id given in the URL if it exists.
    const todo = await Todo.findOne({ id: ctx.request.params.id });

    // Return a 404 Not Found response if no such todo exists.
    if (!todo) {
      return new HttpResponseNotFound();
    }

    // Remove the todo from the database.
    await todo.remove();

    // Returns an successful empty response. The status is 204.
    return new HttpResponseNoContent();
  }
}

ここで、開発サーバーを起動して動作確認してみましょう

$ npm run develop

開発サーバーが起動したら、以下のようにGet、Post、Deleteの動作を確認します。(見やすくするために、 jq を使っています。)

$ curl http://localhost:3001/api/todos | jq .
[
  {
    "id": 1,
    "text": "Read the docs"
  },
  {
    "id": 2,
    "text": "Create my first application"
  },
  {
    "id": 3,
    "text": "Write tests"
  }
]
$ curl -X POST -H "Content-Type: application/json" -d '{"text":"test text"}' localhost:3001/api/todos
{"text":"test text","id":4}

# ここでもう一回 Getリクエストを送ると、todoが4つになっているはず
$ curl -X DELETE http://localhost:3001/api/todos/4

# http status 204 なので、何も表示されません。
# ここでもう一回 Getリクエストを送ると、todoが3つになっているはず

バリデーションとサニタイズ

専用のデコレータを使って、コントローラに記述します。
今回使っているのは、 @ValidateBody@ValidatePathParam です。
スキーマ情報は create-todo スクリプトを記述した時に定義したので、流用します。

import { Context, Delete, Get, HttpResponseCreated, HttpResponseNoContent, HttpResponseNotFound, HttpResponseOK, Post, ValidateBody, ValidatePathParam } from '@foal/core';
import { Todo } from '../../entities';
import { schema } from '../../../scripts/create-todo';

export class TodosController {

  @Get('/')
  async getTodos() {
    const todos = await Todo.find();
    return new HttpResponseOK(todos);
  }

  @Post('/')
  @ValidateBody(schema)
  async postTodo(ctx: Context) {
    // Create a new todo with the body of the HTTP request.
    const todo = new Todo();
    todo.text = ctx.request.body.text;

    // Save the todo in the database.
    await todo.save();

    // Return the new todo with the id generated by the database. The status is 201.
    return new HttpResponseCreated(todo);
  }

  @Delete('/:id')
  @ValidatePathParam('id', { type: 'number' })
  async deleteTodo(ctx: Context) {
    // Get the todo with the id given in the URL if it exists.
    const todo = await Todo.findOne({ id: ctx.request.params.id });

    // Return a 404 Not Found response if no such todo exists.
    if (!todo) {
      return new HttpResponseNotFound();
    }

    // Remove the todo from the database.
    await todo.remove();

    // Returns an successful empty response. The status is 204.
    return new HttpResponseNoContent();
  }
}

Swagger UIで表示させる

前置きが長くなりました。ここからが重要です。
まずは、このAPIの実装内容をSwagger UIで表示させます。

必要なパッケージのインストール

しておいてください。

$ npm install @foal/swagger

API概要の記述

src/app/controllers/api.controller.ts を以下のように編集します。ここら辺から openapi specな感じがしてきますね。

import { ApiInfo, ApiServer, Context, controller, Get, HttpResponseOK } from '@foal/core';
import { TodosController } from './api';

@ApiInfo({
  title: 'Application API',
  version: '1.0.0'
})
@ApiServer({
  url: '/api'
})
export class ApiController {
  subControllers = [
    controller('/todos', TodosController)
  ];
}

openapiのコントローラ作成

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

$ foal generate controller openapi --register
CREATE src/app/controllers/openapi.controller.ts
CREATE src/app/controllers/openapi.controller.spec.ts
UPDATE src/app/controllers/index.ts
UPDATE src/app/app.controller.ts

生成された openapi.controller.ts を以下のように編集します。

import { SwaggerController } from '@foal/swagger';
import { ApiController } from './api.controller';

export class OpenapiController extends SwaggerController  {

  options = {
    controllerClass: ApiController
  }

}

この状態で開発サーバーを起動し、 http://localhost:3001/openapi にアクセスすると、Swagger UIを表示させることができます。

openapi.ymlの出力

これを出力できれば、、、openapi-generatorが使える。型定義ファイルも手に入る!
ということで、openapi.ymlを出力するスクリプトを作成しましょう。

詳しくはここに書いてあります。

Page Not Found | FoalTS
$ foal generate script generate-openapi-doc
CREATE src/scripts/generate-openapi-doc.ts

src/scripts/generate-openapi-doc.ts を以下のように編集します。

// std
import { writeFileSync } from 'fs';

// 3p
import { createOpenApiDocument } from '@foal/core';
import { stringify } from 'yamljs';

// App
import { ApiController } from '../app/controllers';

export async function main() {
  const document = createOpenApiDocument(ApiController);
  const yamlDocument = stringify(document);

  writeFileSync('openapi.yml', yamlDocument, 'utf8');
}

※おそらくここで yamljs が無いと怒られるので、インストールしておきましょう。

$ npm install yamljs

ビルドして実行します。

$ npm run build
$ foal run generate-openapi-doc

これで、プロジェクトルートに openapi.yml が生成されていれば成功です。

型やバリデーションを「きちんと」定義する

これで、openapi-generatorと連携できるぞー!と思いきや、このままクライアントコードを生成しても、思った通りの型定義を生成してくれません。
実装側でもう少し丁寧に情報を記述してあげる必要があります。

具体的には、コントローラの記述を以下のようにしてあげます。デコレータを使って、APIの情報を丁寧に記述してあげてる感じです。

import { ApiDefineSchema, ApiOperationId, ApiResponse, ApiUseTag, Context, Delete, Get, HttpResponseCreated, HttpResponseNoContent, HttpResponseNotFound, HttpResponseOK, IApiSchema, Post, ValidateBody, ValidatePathParam } from '@foal/core';
import { Todo } from '../../entities';
import { schema } from '../../../scripts/create-todo';

const todoSchema: IApiSchema = {
  ...schema,
  properties: {
    ...schema.properties,
    id: { type: 'number' },
  },
  required: [ 'id', 'text' ],
  additionalProperties: false,
};

const createTodoSchema: IApiSchema = {
  ...schema,
  additionalProperties: false,
}

@ApiUseTag('todos')
@ApiDefineSchema('todo', todoSchema)
export class TodosController {

  @Get('/')
  @ApiOperationId('getTodos')
  @ApiResponse(200, {
    description: 'successful operation',
    content: {
      'application/json': {
        schema: { 
          type: 'array',
          items: todoSchema,
        }
      }
    },
  })
  async getTodos() {
    const todos = await Todo.find();
    return new HttpResponseOK(todos);
  }

  @Post('/')
  @ValidateBody(createTodoSchema)
  @ApiOperationId('postTodo')
  @ApiResponse(201, {
    description: 'successful operation',
    content: {
      'application/json': {
        schema: todoSchema
      }
    },
  })  
  async postTodo(ctx: Context) {
    // Create a new todo with the body of the HTTP request.
    const todo = new Todo();
    todo.text = ctx.request.body.text;

    // Save the todo in the database.
    await todo.save();

    // Return the new todo with the id generated by the database. The status is 201.
    return new HttpResponseCreated(todo);
  }

  @Delete('/:id')
  @ValidatePathParam('id', { type: 'number' })
  @ApiOperationId('deleteTodo')
  @ApiResponse(204, { description: 'successful operation' })
  async deleteTodo(ctx: Context) {
    // Get the todo with the id given in the URL if it exists.
    const todo = await Todo.findOne({ id: ctx.request.params.id });

    // Return a 404 Not Found response if no such todo exists.
    if (!todo) {
      return new HttpResponseNotFound();
    }

    // Remove the todo from the database.
    await todo.remove();

    // Returns an successful empty response. The status is 204.
    return new HttpResponseNoContent();
  }
}

クライアントコードの生成

ちょっと下準備します。
openapi-generatorを実行するときのオプションをファイルにして記述しておきます。

$ touch openapi-generator.conf.json

openapi-generator.conf.json を以下のように編集します

{
    "modelPropertyNaming": "camelCase",
    "typescriptThreePlus": true,
    "useSingleRequestParameter": true
}

このオプションは自由に変更できます。

それでは、以下のコマンドでクライアントコードを生成してみましょう。(dockerはインストールしている前提です。)

$ docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli generate -g typescript-axios -c local/openapi-generator.conf.json -i local/openapi.yml -o local/client

無事成功すると、 client フォルダが生成され、その中には axios のクライアントコードが生成されているはずです。

APIレスポンスやTodoエンティティもしっかり型定義されたファイルが生成されているので、これをフロントエンド側で使ってやれば、APIの実装と乖離することなく型安全に開発を進めることができます。

タイトルとURLをコピーしました