LoginSignup
139
117

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-06-16

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

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

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

無題3.png

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

そこに新たな解決策を提示したのが「aspida」です
HTTPリクエストが静的検査可能になるaxios/ky/fetchのラッパーを自動生成するTS製のツール「aspida」

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

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

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

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

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

export type Methods = {
  get: {
    resBody: Task[]
  }
}
/apis/tasks/_taskId@number/index.ts
import { Task } from '../'

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

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

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

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

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

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

architecture.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

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

ターミナル上で設問に回答した通りにフロントエンド(Next/Nuxt)とTypeORMが自動設定されます
無題5.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

http://localhost:3000 でNextかNuxtのTodoアプリが表示されます
無題1.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

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

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

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

/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

次にフロントのAPIリクエスト部分 /pages/index.tsx でも型エラー
無題7.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 でサーバー起動処理を行っています

/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に実装

/server/api/index.ts
export type Methods = {
  get: {
    resBody: string
  }
}
/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

@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

バリデーション

class-validatorを使えます

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

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

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

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

  @MinLength(4)
  pass: string
}

queryは数値型にならない

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

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

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

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

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

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

無題9.png

マイグレーション

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

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

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

middlewareでPassport認証

画面右上のLOGINボタンでPassport認証の動作をチェックできます
無題11.png

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

無題12.png

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

/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します

FormData

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

無題13.png

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

index.ts
export type Methods = {
  post: {
    reqFormat: FormData
    reqBody: {
      name: string
      names: string[]
      image: Blob
      images: Blob[]
    }
  }
}
@controller.ts
export default createController({
  post: ({ body }) => console.log(body.image) // Express.Multer.File型
})

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

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

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

  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を配列で送信する場合には少し工夫が必要になります

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

テスト

$ npm run lint
$ npm run typecheck

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

$relay.ts
export const createController = (methods: ServerMethods<Methods, Types>) => methods
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 で変えられます

今後の機能追加

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

139
117
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
139
117