TS製型安全APIクライアントaspida入門@マナリンク
本記事について
オンライン家庭教師マナリンクで利用しているaspidaという TS 製型安全 API クライアントライブラリの入門記事です。
マナリンクでもっとも aspida を利用しているのは Nuxt アプリケーションなので、その Nuxt アプリケーションの README.md として aspida の解説を書き加えています。
本記事は、その README.md を丸っとコピーして少し修正した記事です。つまり、開発者用の README として解説をストックしつつ、外部発信もしようという取り組みです。スタートアップなので各開発者が広い範囲を開発することになりますので、各範囲に入門用の README や情報置き場があることは重要だと思います。記事で発信しつつ入門 README を増やせるなら一石二鳥だと思い、今回 aspida でやってみることにしました。
aspida について
aspida は TypeScript で API の型定義ができるライブラリです。
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の手順を踏みます。
- 特定のディレクトリ以下に、API のパスに従ったディレクトリ構造で型定義ファイルを TS で記述
- ビルドコマンド(
npx aspida
など)を実行する。マナリンクではnpm run dev
時に同時並行で実行されているので、このコマンドの手動実行は不要 - 必要に応じてNuxt 等のフレームワークにプラグインとして統合する
- コンポーネント側で
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のルーティングの仕組みと同じです
- 開発した、または開発予定のバックエンドAPIのパスと、API定義を書いた
- GET、POST、PUT それぞれ書き方があるので既存ファイルを参照のこと
-
Methods
、get
、query
、resBody
などは予約語です -
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 などで型安全なリクエストを実行できる体制が整います。コンポーネントでは特にジェネリクスを使ったり、型をインポートすらしていないのに、戻り値が安全になっていることが確認できます。
Reference
オンライン家庭教師マナリンクを運営するスタートアップNoSchoolのテックブログです。 manalink.jp/ 創業以来年次200%前後で売上成長しつつ、技術面・組織面での課題に日々向き合っています。 カジュアル面談はこちら! forms.gle/fGAk3vDqKv4Dg2MN7
Discussion