Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
10
Help us understand the problem. What is going on with this article?
@mejileben

フロントエンドで始める「API の型定義」のススメ(Nuxt×TS×aspida)

More than 1 year has passed since last update.

概要

本記事では、Nuxt.js のプロジェクトに TypeScript を導入するにあたって、バックエンドの API を型定義することで API 呼び出しにも型付けの恩恵を受けられる方法を示します。

本記事の通りに API を型定義すると、API のレスポンスだけでなく、Path も TypeScript で定義できます。これにより、レスポンスを型安全に扱えますし、API の Path が変更された時にコンパイルエラーで変更箇所を検知できるといったメリットを享受できます。

API の型定義はライブラリの導入によって実現できます。Nuxt.js を例に説明しますが、他のフロントエンドにも導入しやすいはずなので、ぜひご活用ください。

本記事の流れ

はじめに既存実装の問題点を提示し、そのあとライブラリの導入によって API を型定義した結果を示します。問題点が解消できることを確認し、具体的な導入手順を補足します。随所に弊社で僕が実際に感じている課題、実際に運用して解決できそうな課題についても補足することで、実運用へのヒントを示します。

ソリューションはライブラリの導入なので、導入方法等はライブラリの README が適切であることから、僕の記事では問題点の提起と実運用に関して論述するところに厚みを持たせます。How To が気になる方は途中で示すライブラリの GitHub へ飛んでもらえればと思います。

既存実装の問題点

一例として、下記のように@nuxtjs/axiosを用いて API を叩いている事例を示します(※)。

const userResponse = await this.$axios.$get("/api/v1/contents/" + params.id);

このコードが抱えている課題として以下があります。

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

Path のベタ打ちや、返り値が any になるのは axios のみならず fetch や ky を使っていたとしても当然とも思われますが、これらの課題を API の型定義によって解消できる、というのが本記事の主眼です。


※)独自の axios ラッパーを定義してエラーハンドリング等を共通化する事例もありますが、本記事では axios をそのまま使っている例を出しました

弊社の状況

ここで、せっかくなので、自社で導入した背景を補足として説明します。

Nuxt.js に TypeScript を導入したのは Nuxt.js で構成したサイトをリリースしておよそ半年経過してからでした。

導入の際、主な懸念(TS を導入してもさほど効果が上がらないリスク)は

  • Vuex の型付け
  • Template 内の型付け
  • REST API の型付け

でした。

Vuex についてはほとんど使用しないようにしていて、ユーザーのステート管理くらいでしたので、vuex-module-decoratorsを導入して少し実装を調整すると終わりました。

Template 内の型付けも、(導入当時はまだ機能不足の感がありましたが)VSCode の Vetur プラグインで補完や null 判定等もかなり DX を保って行えるため、そこの懸念も小さかったです。

ただ問題は REST API との通信で、GET した値が any になるなら as で型定義することになるでしょうし、POST/PUT 時に投げる値については都度 Body の内容を型定義しても、他のページで同じ API に POST するときに Body に型定義することを強制できないです。

(ちょっと乱暴な例ですが)下記のように僕が実装したとして、

await this.$axios.$put(`/api/users/${id}`, {
  title: "hogehoge",
  body: "hogehoge",
} as UserAccount);

他のメンバーが同じ API を型付けせずに実装しても、コンパイルエラーしないため、運用でカバー、レビューでカバーみたいに風化することが予想できますね。

await this.$axios.$put(
  `/api/users/${id}`,
  {
    tilte: "hogehoge",
    boby: "hogehoge",
  } // 型付けしなくても別に通るし、なんなら上のtitle, bodyがtypoしているけどコンパイルエラーしないですね
);

TypeScript API Type Definitionといった単語でググっても全然ヒットしないので(※)、一般的なライブラリのような形に落とし込まれたソリューションは無いのかなと思っていた、というところまでが弊社の状況でした。


※)ある程度のエキスパートであれば、もしかすると各プロジェクトに応じて何かしらの解決策、例えば API の Path と渡す Body の型定義を辞書のようにした巨大なオブジェクトを使って Repository を構成するようなことをやってのけているかもしれませんが、そこまで頑張るのなら TypeScript やらなくていいかな・・・と思っていました。

API を型定義するとどうなるか

それではここまでの前提を踏まえて、API を型定義することで問題点を解消していけることを説明していきます。

ライブラリの概要

API を型定義するには aspida(アスピーダ)というライブラリを使います。国産のライブラリ、というか、普通に都内のフロントエンドの勉強会に登壇されているような方が 2019 年末に立ち上げたプロジェクトであるため、気軽に作者に連絡や改善要望できる点は嬉しいポイントです。

ここまでの話で、気になって仕方なくなった&ある程度 README 読んだらわかりそうだって方は以下のリポジトリを見てください。

API を型定義したあとの世界

それでは aspida を導入することで実装内容がどのように変わり、どのような利点を享受できるのかを説明していきます。

実装内容が変わること

まずは実装で変わることを説明します。

以下の既存実装のソースコードがあるとします。先程例示したように、API の Path をベタ打ちで、リクエストの Body は無理やり型定義しています。

await this.$axios.$put(`/api/users/${this.$route.params.id}`, {
  title: "hogehoge",
  body: "hogehoge",
} as UserAccount);

aspida を導入し、API の型定義をした後は下記のように書けます。

await this.$api.users._id(this.$route.params.id).$put({
  data: {
    title: "hogehoge",
    body: "hogehoge",
  },
});

見た目の観点で変わっていることとしては

  • API の Path を文字列ベタ書きではなく、オブジェクトのプロパティをネストして表現している
  • $axiosではなく$apiという別のクライアントを使っている

といったところです。

続いて、このように実装内容が変わることで、どのような利点があるのかを実装時、運用面の両方から整理します。

実装時に得をすること

もし API の Path を typo すると、コンパイルエラーになる

aspida で API を定義すると、API の Path がオブジェクトのプロパティとして表現されます。

したがって、例えば/api/usersの users を user を打ち間違えるとコンパイルエラーになります。

PUT する Body の内容を typo してもコンパイルエラーになる

同様に、渡すリクエストボディも予め型定義された状態で扱えます。

例えば title を tilte に打ち間違えるとコンパイルエラーになります。

運用時に得をすること

API を誰が使ってもリクエストボディ、レスポンスボディの内容を型定義できる

解説は省略しましたが、レスポンスボディも型定義した状態で扱えます。
そのため、aspida の記法で API を扱っている全エンジニアが、型の恩恵を受けた状態で開発ができます。

API の仕様変更時に追従していない実装をコンパイルエラーで気づくことができる

API を仕様変更したとき、例えば

  • POST を PUT に変えた
  • user を users に変えた
  • リクエストボディの内容が変わった
  • レスポンスの内容が変わった

といった場合、aspida 上で型定義した内容を書き換えると、対応した実装がすべてコンパイルエラーになります。

このあと、実際の導入手順を示した上で、コンパイルエラーで検知できる例を示しますので、合わせてご覧ください。

導入方法

それでは実際の導入手順を示していきます。詳しくはリポジトリの README を見てください。

大まかには下記の手順で実施します。

  • aspida の npm install
  • apis ディレクトリを作成し、なにか一つ API を型定義してaspida --build
  • $api オブジェクトを$axiosを元に Wrap し、Vue インスタンスと Nuxt.js コンテキストに Inject する

npm install

aspida は 2020 年 3 月現在、axios、ky、fetch を Wrap して型定義機能のついた HTTP クライアントに変換することができます。

ここでは axios を Wrap する前提で説明します。また、すでに@nuxtjs/axiosを利用していることも前提です。していない場合は別途、素の axios をインストールしてください。

npm install @aspida/axios

apis ディレクトリを作成し、なにか一つ API を型定義してaspida --build

ここが aspida の最大の特色です。

apis ディレクトリをルートディレクトリに作成したら、そこから API の Path の構造と同じ構造でディレクトリを作成します。
ちょうど、Nuxt.js でページのルーティングをディレクトリ構造で表現するのと同じです。

/api/v1/users/${userId}という API を作成したい場合は、apis/v1/users/_userId@number.tsにその API の型定義を記載します(※)。実際の型定義の方法は README をご覧ください。

しかし、ここで型定義を書いたとしても、最終形ではthis.$api.hogehoge.hogehoge....という形で利用できることから、$apiオブジェクトを生成する必要があります。

それをやってくれるのがaspida --buildコマンドです。
/apisディレクトリの中のディレクトリ構造から、自動で API の型定義ファイルを生成してくれます。

package.json
{
  "scripts": {
    "api:build": "aspida --build"
  }
}

※)$axiosのほうで、BASE_URL を設定していれば、$axiosを Wrap するクライアントを作るのが aspida なので/apiディレクトリを作成する必要はありません。

$api オブジェクトを$axiosを元に Wrap し、Vue インスタンスと Nuxt.js コンテキストに Inject する

aspida --buildコマンドが行うことの実態は、/apis/$api.tsに自分が定義した API をすべて吐き出すことです。実際に出力された内容を読むと理解しやすいと思います。

続いて、これらを Nuxt.js Component で利用する必要があるので、inject します。

/plugins/apiClient.ts
import { Plugin } from '@nuxt/types'
import `axios` Client from '@aspida/axios'
import api, { ApiInstance } from '@/apis/$api'

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

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

declare module 'vuex/types/index' {
  interface Store<S> {
    $api: ApiInstance
  }
}

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

export default plugin

最後のapi(axiosClient($axios))がポイントで、@nuxtjs/axiosaspida が用意している Wrap メソッドのaxiosClient()を実行することで Wrap オブジェクトを生成し、その結果を inject しています。

弊社の場合だと、$axios 自体に BASEURL の設定などをすでに終わらせていたので、設定内容がそのまま aspida にも適用できたのが便利ポイントです。

こうすることで、API の Path がディレクトリに基づいてオブジェクトのプロパティのネストで表現できるようになり、例えば返り値のAxiosResponseも型定義ファイルで定義した型を扱えるように Wrap されます。

コンポーネントで使ってみる

コンポーネントで使うときはthis.$apiまたはcontext.$apiで利用できます。エディタで実際に書いてみると、以降の API の内容がプロパティになっているので補完されていくことが確認できるでしょう。

以降は、バックエンドエンジニアから API の仕様を聞いたら aspida の書き方に落とし込んで型定義ファイルを自動生成し、実装していくのを繰り返します。

注意点

一度勘違いされたことがあるのですが、型定義自体をミスってしまうことは防げません(そもそも TypeScript がそうですからね)。
なので、例えばサーバーサイドから返ってくる API のtitleを間違ってnumberと定義してしまうと、実行時エラーが起きる原因になります。

利点の検証

さて、実際に aspida を導入する内容を説明したところで、上記で示した API の型定義の利点を検証してみましょう。

ここでは、API の仕様変更時に追従していない実装をコンパイルエラーで気づくことができるを検証してみます。

API の仕様変更でコンパイルエラーになる実際の事例

実際に弊社のソースコードで、POST /user/registerというユーザー登録の API があるのですが、これをPOST /usersという RESTful な API に書き換えてみます。

やるべきことは以下の 2 つです。

  • /apis/user/register/index.tsの内容を/apis/users/index.tsにコピー
  • aspida --buildを実行

その瞬間、npm run devを実行しているコンソールが動き出し、下記のようなエラーが出ます。

 ERROR  ERROR in /Users/.../signup.vue(87,53):
87:53 Property 'register' does not exist on type '{}'.
    85 |   methods: {
    86 |     async submit (params: RegisterBody) {
  > 87 |       const registerResponse = await this.$api.user.register
       |                                                     ^
    88 |         .$post({
    89 |           data: {
    90 |             email: params.email,

このように、API の仕様を変えたとき、既存実装が Type Error を吐いてくれるので、移行すべき実装に気がつくことができます。

API を容赦なく仕様変更したり削除できるかどうかは長期的に見て大きなメリットがあるはずです。特に弊社はほとんど Web 開発を僕 1 人でやっているようなベンチャーですが、PMF するまでの改善プロセスでプロダクトを作っては捨てていくことが多く、aspida のように容赦なく捨てられるような工夫を仕込んでおくことにメリットを感じています。

導入にあたっての注意点など

ライブラリの成熟度

aspida自体は始まったばかりのライブラリなので、破壊的な仕様変更が発生する可能性があります(2020 年 3 月現在、まだメジャーバージョンが 0)。弊社のように小回りがきくベンチャーなら導入しやすいですが、タイトな現場ではまだ様子見したほうがいいかもしれません。

逆に、作者の方は Twitter 等でも活動していたり discord でコミュニティを作っているので、改善要望等の連絡はつきやすいということが利点としてあげられます。僕は破壊的変更があったときにアップデートタイプする際、めっちゃ DM で質問攻めにさせていただきましたw
https://twitter.com/m_mitsuhide

まとめ

API の型定義自体は応用範囲が非常に広く、backend が strapi や microCMS、Firebase の REST API で作られています、という場合にも応用できると考えられます。
本記事を読んだ方が、REST API をより便利に TypeScript から利用する契機を掴んでいただけたら幸いです。

告知

僕の会社ではオンライン家庭教師に特化したマッチングサービス NoSchool(https://noschool.asia/ )を運営しています。

親御さんは生徒にどんな教育を受けさせていいかわからない、家庭教師側は個人で活動していても集客がままならない、といった双方の課題をどうやって解決していくかを考えつつ、Zoom 等の普及に伴ってオンライン家庭教師市場そのものを大きくしていこうというベンチャーです。
Nuxt×TypeScript + Laravel + AWS + Firebase な環境で開発しています。事業領域、開発内容に興味ある方はぜひお気軽に遊びに来てください!
https://twitter.com/Meijin_garden

10
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
mejileben
オンライン家庭教師マナリンク(https://manalink.jp/)のCTOです。好きなプログラミング言語はTypeScript。好きなCSSプロパティはtransform。好きなAWSのサービスはLambdaです。 経歴は奈良高専卒→LIFULLでHOME'SのWebエンジニアを3年→2019年現職に転職。 技術スキルはWebフルスタックで、SEOやUIデザインもよしなにカバーします。
noschool
中高生向けのオンライン家庭教師サービス”マナリンク”を開発・運営しています。2018年創立のベンチャー企業です。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
10
Help us understand the problem. What is going on with this article?