🎃

TS製型安全APIクライアントaspida入門@マナリンク

2021/10/07に公開

本記事について

オンライン家庭教師マナリンクで利用しているaspidaという TS 製型安全 API クライアントライブラリの入門記事です。

マナリンクでもっとも aspida を利用しているのは Nuxt アプリケーションなので、その Nuxt アプリケーションの README.md として aspida の解説を書き加えています。

本記事は、その README.md を丸っとコピーして少し修正した記事です。つまり、開発者用の README として解説をストックしつつ、外部発信もしようという取り組みです。スタートアップなので各開発者が広い範囲を開発することになりますので、各範囲に入門用の README や情報置き場があることは重要だと思います。記事で発信しつつ入門 README を増やせるなら一石二鳥だと思い、今回 aspida でやってみることにしました。


aspida について

aspida は TypeScript で API の型定義ができるライブラリです。

GitHub リポジトリ

aspida が解決する課題

aspida が解決する問題は、API を通してデータを取得すると TypeScript 上で any 型になってしまう問題です。

例えば以下のようなコードを例に考えましょう。家庭教師の指導コースを一覧で取得する例です。

const teachingCourseResponse = await this.$axios.$get(
  `/v1/teachers/${params.id}/teaching-courses`,
)

このコードが抱えている問題点はいくつか挙げられます

  • 返り値の型がわからない。せっかく TypeScript を使っているのに any になってしまう
  • API の Path をベタ打ちしているので、typo しても気づかない
  • API の仕様が変わったとき、Grep して当該 API を使っている場所を探さないといけない

1 つ目の any に関しては、ジェネリクスで指定することで回避しているパターンも多いですが、だとしても 2 つ目以降の欠点が解消できません。

API のパスと、API の戻り値の型を紐付けるような存在を作ることができれば、パスを指定してデータを取得したときに、自動で TypeScript 上で型安全になる世界観が作れると思われます。これを内製するのではなく、ライブラリとしてサポートしてくれるのが aspida です。

aspida を利用するとどうなるか

API へのアクセスを以下のように書くことができます。

const teachingCourseResponse = await app.$api.teachers
  ._id(params.id)
  .teaching_courses.$get({
    query: {
      page: parseInt(query.page as string),
    },
  })
  .catch((err) => {
    if (
      err instanceof AxiosError &&
      err.response.data &&
      err.response.data.errors
    ) {
      this.errorMessage = `${err.response.data.errors.message}`
    }
    return false
  })

ポイントは以下の通りです。

  • API の Path をオブジェクトのチェーンで表現でき、型定義がされているので、typo すると TypeScript のコンパイルエラーになる
  • API の仕様が変わったときは、もととなる型定義を書き換えると、その API を使っているページがすべてコンパイルエラーになるので Grep しなくていい
  • 返り値の型も型定義できる

利用方法

ざっくりいうと以下の手順をたどることで API の型定義を作成し、利用を開始できます。すでに3まではセットアップ済みなので、既存APIを利用する場合は4のみ、新規でAPIを作る場合は1と4の手順を踏みます

  1. 特定のディレクトリ以下に、API のパスに従ったディレクトリ構造で型定義ファイルを TS で記述
  2. ビルドコマンド(npx aspidaなど)を実行する。マナリンクではnpm run dev時に同時並行で実行されているので、このコマンドの手動実行は不要
  3. 必要に応じてNuxt 等のフレームワークにプラグインとして統合する
  4. コンポーネント側でapi.teachers.._id(params.id).teaching_courses.$get()といった方法で利用する

たまに勘違いされるのですが、aspida は実行時に実際に届いたデータを見て assert するような機能は備えていません。あくまで、フロントエンドかつコンパイル前の段階で完結しているライブラリです。

あくまで型のレイヤーを自動生成することに特化したライブラリなので、Fetcher も axios や fetch から選択できます。マナリンクではNuxtのaxiosプラグインを使っているためaxiosを利用します。

./apis ディレクトリ以下に型定義を作成

API のパスに従った階層に index.ts というファイル名で型定義ファイルを作ります。

./apis/teachers/_userId@number/teaching-courses/index.ts

export type Methods = {
  get: {
    query: {
      page?: number
    }
    resBody: {
      teaching_courses: TeachingCourse[]
      page: number
      hits: number
      total_hits: number
      is_last_page: boolean
    }
  }
}

export type TeachingCourse = {
  teacher: Teacher
  title: string
  description: string
  price: number
  image_url: string
  subject: Subject
  is_public: boolean
}

export type Teacher = {
  id: number
  name: string
  avatar: string
}

export type Subject = {
  id: number
  name: string
  slug: string
}

ポイントは以下のとおりです。

  • ディレクトリを API の Path とそろえる
    • 開発した、または開発予定のバックエンドAPIのパスと、API定義を書いたindex.tsを置くディレクトリのパスは揃える必要があります。ちょうどNuxtのルーティングの仕組みと同じです
  • GET、POST、PUT それぞれ書き方があるので既存ファイルを参照のこと
    • MethodsgetqueryresBodyなどは予約語です
    • Methods APIの型定義はすべてこのTypeの中に定義します
    • get HTTPメソッドをキー名にします。他にもpost/put/deleteが利用できます
    • query GETメソッドでのクエリパラメータの型も定義できます
    • reqBody POST/PUT等のリクエストボディの型定義をします
    • resBody 戻り値の型定義をします

※戻り値の型は、Teacher や Subject といった意味のある単位で切り出して他のディレクトリtypesなどにまとめるほうが再利用ができるため望ましいです

npm run api:build

マナリンクではnpm run devを以下のようにnpm-run-allを使ってセットアップしているため、npm run devを実行すると自動でapisディレクトリ以下のAPI定義をファイルウォッチして、常に最新の型定義を吐き出してくれます。。

    "dev": "run-p dev:*",
    "dev:api": "aspida --watch",
    "dev:nuxt": "nuxt",

手動でビルドする場合は以下のコマンドを実行します。

    $ npm run api:build

実行後、./apis/$api.ts が自動で作成、変更されます。これが型定義が書かれたファイルで、このファイルの自動生成こそがaspidaの責務です。

たまにこのファイルを手動で変更する方がいますが、このファイルは自動生成されるものなので書き換えないでください。Git 管理するとよくコンフリクトするので、管理せずに GitHub Actions でのビルド時に生成しています。 詳しくは.githubディレクトリ以下のGitHub Actionsのymlを見てください。

Nuxt.js への統合

実際のコードは plugins/apiClient.ts を参照してください。

import { Plugin } from '@nuxt/types'
import api, { ApiInstance } from '~/apis/$api'

declare module 'vue/types/vue' {
  interface Vue {
    $api: ApiInstance
  }
}

declare module '@nuxt/types/app' {
  interface NuxtAppOptions {
    $api: ApiInstance
  }
}

const plugin: Plugin = ({ $axios, app }, inject) => {
  inject('api', api($axios))
  app.$api = api($axios)
}

export default plugin

ポイントは、以下の行です。

import api, { ApiInstance } from '~/apis/$api'
// ...
inject('api', api($axios))

先程自動生成した$api.tsから import した api メソッドを実行し、第 1 引数に Fetcher を渡すことで、型安全に通信できるインスタンスが生成できます。これを Nuxt の Inject で注入すれば準備は完了というわけです。

以上で、Nuxt で app.$api などで型安全なリクエストを実行できる体制が整います。コンポーネントでは特にジェネリクスを使ったり、型をインポートすらしていないのに、戻り値が安全になっていることが確認できます。

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/57f03be0-f5fc-4e50-9d4c-6181b6d67985/_2020-03-04_16.57.23.png

Reference

マナリンク Tech Blog

Discussion