🛡️

月間2万DL突破!REST APIを型安全にする最強のTypeScript製HTTPクライアントaspidaを始めよう

2021/01/26に公開

TypeScript製REST APIクライアント「aspida」

最近TypeScript界隈で話題にしていただいている pathpidafrourio の開発者Solufaです。
今回はこれらのライブラリの原点である「aspida」を紹介します。

npmへの初公開から1年3か月が経ち、GitHubスター420・npm月間DL数2万を超えるまでに成長しました。
Star history of aspida

出典:Star history

どこでこんなに使われているのか正確なデータはないのですが、

あたりが使ってくれているのをテックブログから把握しています。
(他にもウチで使ってるよーって情報があればコメントで教えてくれると嬉しいです!)

aspida logo
https://github.com/aspida/aspida

どんな問題を解決するのか

モダンなフロントエンド環境でREST APIリクエストを行う場合、axiosかfetchを使うことが多いでしょう。例えば以下のようにaxiosで /articles/:articleId/?user={userId} をGETするケースを考えます。

type Article = {
  id: number
  content: string
}

const articleId = 'sample title'
const userId = 2
const { data } = await axios.get<Article>(
  `/article/${articleId}`,
  { params: { userId } }
)

型引数によってdataがArticle型だということはわかります。
しかし、URLに文字列を使っていて型安全ではないため3か所もミスがあります。

type Article = {
  id: number
  content: string
}

const articleId = 'sample title' // IDにタイトルを代入している
const userId = 2
const { data } = await axios.get<Article>(
  `/article/${articleId}`, // article"s"のタイポ
  { params: { userId } } // { params: { user: userId } } が正解
)

axiosもfetchもこのミスを静的に検査することが不可能です。
やむなくリクエストごとにオレオレ関数を作って対処しているのではないでしょうか?
関数を作る手間を省きつつミスをTypeScriptで検知可能にしてくれるのがaspidaです。

オレオレ関数の代わりにファイルシステムベースで型定義を書いておくことでURL文字列の代わりにプロパティとメソッドでリクエスト出来るようになります。

api/articles/_articleId@number.ts
type Article = {
  id: number
  content: string
}

export type Methods = {
  get: {
    query: {
      user: number
    }
    resBody: Article
  }
}

上記のような型定義ファイルを作成すると /api 以降のファイル名情報と合わせて

  • URLは /articles/:articleId
  • articleIdはnumber型
  • Queryにnumber型のuserが必須
  • レスポンスはArticle型

という制約を持つHTTPクライアントが自動生成されます。
import周りを省略してサクッとリクエスト方法を見てみましょう。

const articleId = 1
const userId = 2
const article = await aspida.articles._articleId(articleId).$get({ query: { user: userId } })

上記のコードブロックからは読み取れませんが、articleIdやqueryの型を間違えるとエディタ上でエラーになります。
これならIDにタイトルを代入することはなく、articlesをタイポすることもなく、queryのプロパティを間違えることもありません。
aspidaを使うとオレオレ関数に頼らずガッチガチに型安全なREST APIリクエストが出来るようになるのです。

HTTPメソッド名の「$」を消すとヘッダーやステータスコードも取得できます。

const articleId = 1
const userId = 2
const { body, headers, status } = await aspida.articles._articleId(articleId).get({ query: { user: userId } })

Next.jsで使ってみよう

フロントのフレームワークはなんでもいいのですが今回はNext.js+axiosで解説します。
Next.jsとTypeScriptの環境がすでにある前提です。

$ yarn add @aspida/axios axios

axios本体のインストールを忘れないでください。
※Nuxt.jsの場合は @nuxtjs/axios に付属するaxiosを利用可能
※aspidaはaxios以外にfetchとnode-fetch用のモジュールも選択可能

型定義ファイルを作成します。
ファイル名やディレクトリ名をアンダースコアから始めるとパス変数として解釈されます。
@ で型を指定できますがこれはオプションです。
numberstring を指定できてデフォルトは string | number です。

types/index.ts
export type Article = {
  id: number
  content: string
}
api/articles/_articleId@number.ts
import { Article } from '../../../types'

export type Methods = {
  get: {
    query: {
      user: number
    }
    resBody: Article
  }
}
package.json
{
  "scripts": {
    "dev": "run-p dev:*",
    "dev:next": "next dev",
    "dev:aspida": "aspida --watch",
    "build": "aspida && next build"
  }
}

devコマンドでNext.jsと一緒にaspidaも監視モードで起動します。

$ yarn dev

api/$api.ts に以下のファイルが自動生成されるはずです。

api/$api.ts
/* eslint-disable */
import { AspidaClient } from 'aspida'
import { Methods as Methods0 } from './articles/_articleId@number'

const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? '' : baseURL).replace(/\/$/, '')
  const PATH0 = '/articles'
  const GET = 'GET'

  return {
    articles: {
      _articleId: (val1: number) => {
        const prefix1 = `${PATH0}/${val1}`

        return {
          get: (option?: { query?: Methods0['get']['query'], config?: T }) =>
            fetch<Methods0['get']['resBody']>(prefix, prefix1, GET, option).json(),
          $get: (option?: { query?: Methods0['get']['query'], config?: T }) =>
            fetch<Methods0['get']['resBody']>(prefix, prefix1, GET, option).json().then(r => r.body),
          $path: () => `${prefix}${prefix1}`
        }
      }
    }
  }
}

export type ApiInstance = ReturnType<typeof api>
export default api

これを使って型安全なクライアントを作成します。
ファイル名や場所は自由です。

lib/apiClient.ts
import axios from 'axios'
import aspida from '@aspida/axios'
import api from '../api/$api'

export const apiClient = api(aspida(axios, { baseURL: 'https://example.com/api' }))

baseURLは環境変数から取得するとより良いです。
axiosのinterceptorsでエラーハンドリングを一括して設定もできます。
このクライアントを任意のファイルで読み込んで使います。

pages/index.tsx
import React, { useState, useEffect } from 'react'
import { Article } from '../types'
import { apiClient } from '../lib/apiClient'

const Home = () => {
  const [article, setArticle] = useState<Article | null>(null)

  useEffect(() => {
    const fetchArticle = async () => {
      const articleId = 1
      const userId = 2
      const res = await apiClient.articles._articleId(articleId).$get({ query: { user: userId } })
      setArticle(res)
    }
    fetchArticle()
  }, [])

  return <div />
}

export default Home

APIリクエストを静的に型検査出来るようになりました。

OpenAPI/Swaggerをaspidaの型定義ファイルに変換する

すでにAPIが大量にあるプロジェクトでaspidaの型定義ファイルをゼロから手書きするのは大変です。openapi2aspida を使うとRailsやLaravelのようなフレームワークで自動出力したOpenAPI/Swaggerをaspidaに変換することができます。

例えばSwaggerのサンプルでよく見かけるPerstoreを変換してみましょう。

Swagger Petstore
Swagger Petstore

サイト上部にあるswagger.jsonが変換したいSwaggerファイルのURLです。
社内プロジェクトなら ../swagger.yaml などの相対パスになると思います。
拡張子はyamlとjsonに対応しています。

空のディレクトリを作成し、openapi2aspida を呼び出すnpxコマンド1行で変換します。

$ mkdir petstore-api
$ cd petstore-api
$ npx openapi2aspida -i https://petstore.swagger.io/v2/swagger.json

api/$api.ts was built successfully. というメッセージとともにapiディレクトリに型定義ファイル一式が生成されるはずです。
($api.tsがこれ以外にも生成されるのは最低限使うエンドポイントのクライアントだけを読み込んでバンドルサイズを節約することも出来るようにするため)
このままサクッとnode.js(ts-node)からリクエストを飛ばしてみます。

必要なモジュール一式をインストール。

$ npm init -y
$ yarn add @aspida/axios axios typescript ts-node @types/node

ディレクトリのルートでPOSTとGETを行います。

index.ts
import axiosClient from '@aspida/axios'
import api from "./api/$api"
import { Pet } from './api/@types'

;(async () => {
  const client = api(axiosClient())
  const petId = 100
  const body: Pet = {
    id: petId,
    name: 'hoge',
    photoUrls: [],
    status: 'available'
  }

  await client.pet.$post({ body })
  const pet = await client.pet._petId(petId).$get()
  console.log(pet)
})()

起動コマンドの追加も忘れずに。

package.json
{
  "scripts": {
    "start": "ts-node index.ts"
  }
}

これで準備完了。ターミナルで起動しましょう。

$ yarn start

{ id: 100, name: 'hoge', photoUrls: [], tags: [], status: 'available' } という結果が表示されるはずです。
これまでの1年間でGitHubのIssueに寄せられた機能要望を全て実装してきたのでかなりニッチなOpenAPIの仕様もサポートしています。
HTTP通信が発生する外部ファイル参照も可能なのでぜひ隅々まで試してみてください。

まとめ

ファイルシステムベースで型定義を記述するだけで型安全にREST APIリクエストを行えるのがaspidaの特徴です。TS製フルスタックフレームワークfrourioも同じ型定義ファイルを利用してバックエンドのコントローラーを型安全にしてくれます。

ちなみにaspidaの意味はギリシャ語で「盾」、frourioは「要塞」です。
堅牢な型でアプリケーションを守ってくれそうなカッコいい名前ですよね!

最後まで読んでいただきありがとうございました。
GitHubにスターを押してくれるととても嬉しいです。

https://github.com/aspida/aspida

サポート

GitHubのIssueはもちろんのこと、TwitterのDMやコメントで質問していただいてもOKです。

https://twitter.com/m_mitsuhide/status/1339373391388626945

Discussion