フロントとサーバーのAPI疎通を静的に検査できるTypeScriptフレームワークを作った

:warning: この記事の内容は`frourio v0.12`以前のものであり、最新の`frourio v0.13`以降の情報は以下リンクを参照してください
[frourio でフロントエンドとバックエンドを一緒に静的型検査する](https://qiita.com/su8ru/items/08d4222af6ddb8eb218b)

## フロントとサーバーの疎通をTypeScriptで静的に検査したい

フロント（React/Vue/Angular）とサーバー（Express/Nest）を両方TypeScriptで開発しているプロジェクトの情報を少しずつ見かけるようになりました
しかしながら、フロントのTSとサーバーのTSは緩く型で繋ぐ程度で両者のAPI疎通まで静的に型検査することは難しいです
せっかく両方TSで作っても、ブラウザを動かすとかテスト回すとかで動的に疎通チェックをしているのではないでしょうか

![無題3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/68a25d11-4e29-8244-7668-19209cb0e2d0.png)

フロント開発で特に厄介なのが、HTTPリクエストで使うaxios/ky/fetchです
エンドポイントを文字列で指定するという性質上、パスやリクエスト・レスポンスの型検査が不可能でした

そこに新たな解決策を提示したのが「aspida」です
[HTTPリクエストが静的検査可能になるaxios/ky/fetchのラッパーを自動生成するTS製のツール「aspida」](https://qiita.com/m_mitsuhide/items/68406158d35a14fa0aa2)

aspidaの登場により、フロントは最大の割れ窓であったHTTPリクエストの静的型問題を克服できました
一方サーバーはコントローラが管理しているAPIエンドポイントが仕様通りの実装であることを静的に検査する方法がありません
フロントがaspidaで正確なリクエストを投げたとしても、サーバーと期待通りに疎通するかは動かしてみるまでわからない

API周りの大きな割れ窓を解決し、フロントとサーバーのTypeScript大統一を果たすのがこの記事のゴールです

## aspidaの型定義でコントローラの実装を強制する

例えばTodoアプリを作るとして、 `/tasks` と `/tasks/:taskId` というGETのAPIエンドポイント二つを作る場合、aspidaでは以下のように型定義します

```typescript:/apis/tasks/index.ts
export type Task = {
  id: number
  label: string
  done: boolean
}

export type Methods = {
  get: {
    resBody: Task[]
  }
}
```

```typescript:/apis/tasks/_taskId@number/index.ts
import { Task } from '../'

export type Methods = {
  get: {
    resBody: Task
  }
}
```

`/apis` 配下のディレクトリ名がエンドポイントのパスと一致します
（`_`から始まるディレクトリ名はパス変数を意味していて、`@number`が型、省略するとstring型）
フロントからは以下のようにエンドポイントを示すaspidaのプロパティ+メソッドでリクエストを書けるので静的検査ができます

```typescript:client.ts
const task = await api.tasks._taskId(1).$get()
// taskはTask型
```

これら `/apis` 配下に作ったエンドポイントの型定義を流用してサーバーのコントローラにも実装を強制すればAPI疎通を静的に検査可能になるという着想で開発したのが「frourio（フルーリオ）」です

- Expressベース
- オートルーティング
- TypeORM組み込み
- MulterでFormDataを自動パース
- class-validatorで入力値チェック

サーバーフレームワークに必要とされる機能は一通り揃っています
構成はこんな感じです

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/c81b5dfd-5cea-91f8-dd58-80669985bed3.png)

## データベースを用意しておく

DBがなくても動かせますが、今回はMySQLを使うのであらかじめ準備しておきます
例として
- ポート：3306
- ユーザー名：root
- パスワード：root
- データベース名：test
という条件で空のデータベースを作成した前提で進めていきます

## 環境構築のストレスフリーなTypeScript大統一開発を始めよう

frourioの環境構築はcreate-frourio-appを使うとインストール直後から動きます
自分で組むのは難しいフロントフレームワーク（Next.js/Nuxt.js）との型連携からDBとの接続設定、frourio本体にはない認証システムとしてPassport、デーモン化のためのPM2、開発サーバー監視のnodemonまで揃ったTypeScriptフルスタック環境が3分で整います



[GitHub: create-frourio-app](https://github.com/frouriojs/create-frourio-app)

```bash
$ npx create-frourio-app next-test
または
$ yarn create frourio-app next-test
```

ターミナル上で設問に回答した通りにフロントエンド（Next/Nuxt）とTypeORMが自動設定されます
![無題5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/392216c0-2f15-7f9b-8281-3b94937874e3.png)

回答したDB接続情報は `server/.env` に書き込まれます

```
SERVER_PORT=8080
BASE_PATH=/api
TYPEORM_HOST=localhost
TYPEORM_USERNAME=root
TYPEORM_PASSWORD=root
TYPEORM_DATABASE=test
TYPEORM_PORT=3306
```

DBアプリ起動時にマイグレーションを行うので回答通りに空のDBを準備しておいてください
DBを使わない場合（Choose the database typeでNoneと回答）はプロジェクト内に生成される `server/database.json` をデータベース代わりに使用するので準備不要です

インストールが完了したらディレクトリに移動してアプリを起動します
![無題0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/71fc6907-035c-fb78-aae4-d2cf016f272d.png)


`http://localhost:3000` でNextかNuxtのTodoアプリが表示されます
![無題1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/e60ddcf3-63fd-3e7a-3efa-11d5eb641ad8.png)

## VSCodeのTypeScriptバージョンを3.9以上にする

frourioはTypeScript3.9で改善されたPromiseの型推論に依存しているため、3.9未満だと型エラーになります
もちろんcreate-frourio-appは3.9.5をインストールするので正常に動作しますが、エディタのTypeScriptが古いと見た目上エラーが出ます
VSCodeは2020/06/10のリリースで3.9.4が標準になったのでアップデートするのがおススメです
![無題2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/6338f7db-b474-711c-867a-625fbd45f074.png)

## /server/api/**/index.tsの定義を変えるとフロントとサーバー両方で型エラーになる

`/server/api` 配下にはフロントとサーバーを繋ぐAPIの型定義ファイルがあります
ディレクトリ名がそのままエンドポイントのパスになります

「API疎通を静的に型検査できる」のがfrourioの特徴です
例えば、Todoのタスクリストを取得するエンドポイントを書き換えてみます

```typescript:/server/api/tasks/index.ts
import { Task } from '~/server/types'

export type Methods = {
  get: {
    resBody: Task // <- 配列を消した
  }
  post: {
    reqBody: Pick<Task, 'label'>
    resBody: Task
  }
}
```

すると、最初に同じ階層の `/server/api/tasks/@controller.ts` で型エラーが出ます
![無題6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/19092cac-eced-cbef-f194-fe3d7fcf6b5d.png)

次にフロントのAPIリクエスト部分 `/pages/index.tsx` でも型エラー
![無題7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/2ef7ebfa-904e-c221-c0f2-5881a22bcd11.png)

resBodyだけでなくquery/headersはもちろん、ディレクトリ名を変えたりパス変数の型が違ってもフロント・サーバー両方でエラーが出るので静的に疎通検査が出来ていることがわかります
URLやデータの不整合でAPIが疎通しないというミスが大幅に減ることが想像できますね

frourioのファイル名をAPIエンドポイントに変換するルールはaspidaのサブセットです
`/server/api/test.ts` はエンドポイントになりません
必ずディレクトリ＋index.tsの形式 `/server/api/test/index.ts` にしてください
ディレクトリがないとコントローラやミドルウェアを解釈できないのでこのルールにしました

## ルートはNext or Nuxt、/server配下にaspidaとfrourio

全体のディレクトリ構成はNext/Nuxtの標準通りです
`/server` 配下にAPIサーバーの型定義と実装が存在します
`$`から始まるファイルはfrourio/aspidaが自動変更するためユーザーが変更を加えることはできません

| 名前       |  | 用途     |
|-----------------|------------------|------------------|
| /server/api/             |              | aspidaでエンドポイント定義 |
| /server/public/          |            | 静的配信 |
| /server/types/           |              | 型定義とバリデータ |
| /server/index.ts      |           | サーバーのエントリーポイント |
| /server/tsconfig.json      |           |  |
| /server/webpack.config.js      |           |  |
| /server/entity/          | DB選択時のみ | TypeORM専用 |
| /server/migration/       | DB選択時のみ | TypeORM専用 |
| /server/subscriber/      | DB選択時のみ | TypeORM専用 |
| /server/ormconfig.ts      | DB選択時のみ | TypeORM/CLI専用 |
| /server/.upload/      | 自動生成 | アップロードファイルをmulterが保存 |
| /server/$app.ts      | 自動生成 | Expressの初期化処理 |
| /server/index.js      | 自動生成 | Webpackによるバンドル結果 |
| /server/service         | オプション |  |

`/server/service` は消してもいいし、上記と衝突しない名前で好きなだけ `/server` にディレクトリを作ってOKです

## エントリーポイント

`/server/index.ts` でサーバー起動処理を行っています

```typescript:/server/index.ts
import { run } from './$app'
import {
  SERVER_PORT,
  BASE_PATH,
  TYPEORM_HOST,
  TYPEORM_USERNAME,
  TYPEORM_PASSWORD,
  TYPEORM_DATABASE,
  TYPEORM_PORT
} from './service/envValues'

run({
  port: Number(SERVER_PORT),
  basePath: BASE_PATH,
  cors: true,
  typeorm: {
    type: 'mysql',
    host: TYPEORM_HOST,
    username: TYPEORM_USERNAME,
    password: TYPEORM_PASSWORD,
    database: TYPEORM_DATABASE,
    port: Number(TYPEORM_PORT),
    migrationsRun: true,
    synchronize: false,
    logging: false
  }
})
```


| プロパティ名       | 型 |      |
|-----------------|------------------|------------------|
| port             | number / string   | Expressサーバーのポート |
| basePath?          |  string   | APIエンドポイントの |
| helmet?           |  boolean / helmet.IHelmetConfigration  | helmetの有効化 |
| cors?      | boolean / cors.CorsOptions  | corsの有効化 |
| typeorm?      | typeorm.ConnectionOptions | typeormの設定 |
| multer?      | multer.Options | multerの設定 |


## /server/apis/{path}/index.tsに型定義⇒/server/apis/{path}/@controller.tsに実装

```typescript:/server/api/index.ts
export type Methods = {
  get: {
    resBody: string
  }
}
```

```typescript:/server/api/@controller.ts
import { createController } from './$relay'

export default createController({
  get: () => ({ status: 200, body: 'Hello, world!' })
})
```

`/server/api` 配下に新たなディレクトリを作ると `index.ts` `@controller.ts` `$relay.ts` が自動生成されます

`$relay.ts` はfrourioが管理しつづける型中継ファイルです
`$` から始まるファイルなので編集できません

`index.ts` にエンドポイントの型定義を行います
[エンドポイントの型定義ファイルを作成する - aspida](https://github.com/aspidajs/aspida/tree/master/packages/aspida/docs/ja#%E3%82%A8%E3%83%B3%E3%83%89%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88%E3%81%AE%E5%9E%8B%E5%AE%9A%E7%BE%A9%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B)

`@controller.ts` で型定義通りにstatus/body/headersをreturnするとサーバーレスポンスになります

ブラウザで http://localhost:8080/api にアクセスすると `Hello, world!` が表示されます

## 基本的なCRUDのサンプルは /server/api/tasks

シンプルにCRUDを行うGET/POST/PATCH/DELETEのエンドポイントが `/server/api/tasks` 配下にあります
Todoアプリのリクエストが全てここに飛んできます

![無題8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/7906f4e8-55fc-f725-a090-da7a329584b2.png)


## バリデーション

class-validatorを使えます

- クラス名を `Valid` から始める
- `/server/types/index.ts` で exportする
- フロントと共有するクラスなのでサーバー固有のロジックを書かない

という条件を満たす必要があります
このクラスを `index.ts` の `query/reqHeaders/reqBody` に指定すると自動でバリデート処理が挿入されます
バリデートエラー時にはコントローラに届かずstatus:400が返却されます

```typescript:/server/types/index.ts
import { MinLength } from 'class-validator'

export class ValidLoginBody {
  @MinLength(2)
  id: string

  @MinLength(4)
  pass: string
}
```

## queryは数値型にならない

クエリのパースはExpressに任せているため、型でnumberを指定しても数値に変換されません

```typescript
import { Task } from '~/server/types'

export type Methods = {
  get: {
    query: {
      limit: number // <- ランタイムでstringのまま
    }
    resBody: Task[]
  }
}
```

数値としてバリデートしたい場合は `class-validator` の `IsNumberString` を使うといいです

## パス変数は数値に変換される

`/server/api/tasks/_taskId@number/index.ts` のパス変数 `taskId` はfrourioによって数値に変換されます
数値じゃない場合はバリデートエラーで400を返します
numberの指定がない場合は文字列のまま、バリデートはしません

![無題9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/21eb85d5-911c-626b-8202-3057a8ff3d5e.png)

## マイグレーション

`/server/index.ts` の `migrationsRun: true` によってサーバー起動時に自動マイグレーションされます
`synchronize: false` なので開発中にentityを追加するだけではマイグレーションは起こりません

`/server/entity` 配下にTypeORMの作法でエンティティを定義
⇒ `$ npm run migration:generate` で `/server/migration` にマイグレーションファイルを生成
⇒ サーバーがnodemonによって再起動するのでDBマイグレーションされる

意図せぬタイミングでマイグレーションファイルが生成されると困るので手動の操作を挟むようにしています

## middlewareでPassport認証

画面右上のLOGINボタンでPassport認証の動作をチェックできます
![無題11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/a65cb0db-7603-c09a-2273-7db7d4fdc3ce.png)

.envに書いてある通り、
id -> id
pass -> pass
とこのまま入力するとログインできます
違う入力は弾かれます
認証状態をlocalStorageなどで永続化してないのでリロードしたらもとに戻ります

![無題12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/b5ebe9ea-bd72-091a-893c-34de5078f3b1.png)

frourioのミドルウェアはExpressのミドルウェアそのままです
あるディレクトリ以下全てに適用する場合は `@middleware.ts` ファイルを作ります
`export type User = { ... }` と記述すると、配下全てのコントローラでuserの値が取得できます
(型を正しく当てるの間に合わなくてコメントアウトしました・・・)

```typescript:/server/api/user/@controller.ts
import passport from 'passport'
import { createMiddleware } from './$relay'
import { getUserIdByToken } from '~/server/service/user'

export type User = {
  id: string
}

passport.use(
  // eslint-disable-next-line
  new (require('passport-trusted-header').Strategy)(
    { headers: ['token'] },
    // eslint-disable-next-line
    (headers: { token: string }, done: Function) => {
      done(null, getUserIdByToken(headers.token))
    }
  )
)

export default createMiddleware([
  passport.initialize(),
  passport.authenticate('trusted-header', { session: false })
])
```

特定のコントローラのみにミドルウェアを適用したい場合は、 `@controller.ts` でmiddlewareをexportします

https://github.com/frouriojs/frourio/blob/master/packages/frourio/server/api/%40controller.ts

## FormData

画像をFormDataで送信するサンプルはLOGIN後にファイルを選択ボタンで試せます
（アイコンが変わるだけ）

![無題13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/38533ac0-d253-19df-7552-462d838b2360.png)



エンドポイントのindex.tsに `reqFormat: FormData` を指定する以外に特別な記述は不要です
ファイルを送信する場合はBlob型を指定すると良いです
（Fileでも問題ないけど、MulterのFile型と混同しやすい）

```typescript:index.ts
export type Methods = {
  post: {
    reqFormat: FormData
    reqBody: {
      name: string
      names: string[]
      image: Blob
      images: Blob[]
    }
  }
}
```

```typescript:@controller.ts
export default createController({
  post: ({ body }) => console.log(body.image) // Express.Multer.File型
})
```

アップロードされたファイルは `/server/.upload` に保存されるのでS3やpublicディレクトリに移すなどしてください
Multerの挙動を変えたい場合は `/server/index.ts` で変えられます

```typescript
run({
  port: SERVER_PORT,
  multer: {
    dest: 'hoge/fuga'
  }
})
```

フロントのリクエストはJSONの場合とほぼ変わりません

```typescript
  const editIcon = useCallback(
    async (e: ChangeEvent<HTMLInputElement>) => {
      if (!e.target.files?.length) return

      setUserInfo(
        await apiClient.user.$post({
          headers: { token },
          body: { icon: e.target.files[0] }
        })
      )
    },
    [token]
  )
```

fileを配列で送信する場合には少し工夫が必要になります

```typescript
await apiClient.user.$post({
  body: { icons: Array.from(e.target.files) }
})
```

## テスト

```bash
$ npm run lint
$ npm run typecheck
```

静的テスト二つは標準で用意されてます
動的なテストについては特定の作法はありません

```typescript:$relay.ts
export const createController = (methods: ServerMethods<Methods, Types>) => methods
```

```typescript:index.ts
import { createController } from './$relay'

export default createController({
  get: () => ({ status: 200, body: 'Hello, world!' })
})
```

createControllerは型を付けるためだけに存在していて実体は何もしない関数です
好きなテストフレームワークを使って単純な関数としてコントローラをテストしてください

## デプロイ

`$ npm run start:front` と `$ npm run start:server` でそれぞれのデプロイが可能です
`$ npm start` は両方を同時に呼び出すだけのコマンドです 
フロントのポートやモード（SSRとかSSGとか）はNext/Nuxtの作法で変更してください
サーバーのポートは `.env` で変えられます

## 今後の機能追加

書きやすさを維持して依存性注入を行う方法を考え中です