50
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Web APIに型を付与する"Aspida"が結構便利な話

Last updated at Posted at 2020-02-21

HTTPクライアントに型がなくてつらい

VueやReactもかなりTypescriptへのサポートが厚くなって来ているが、どうしてもバックエンドとクライアントの間だけは型の恩恵を得ることが難しい。
実際、僕が少し前にVue.jsでWebサービスを作ろうとした際にも、以下のようなaxiosをラップした関数を大量に含むファイルが含まれていた。

// プレイヤーを追加する
// 返値: PlayerID
export async function addPlayer(
  roomID: RoomID,
  playerName: string
): Promise<PlayerID> {
  const result = await axios.post(`${urlPrefix}/room/${roomID}/player`, {
    name: playerName
  });
  return result.data.id;
}

しかし、この例で言えばresult.data.idanyであるから、ここを書き間違えることはよくある。
また、サーバサイドに渡す引数名を変えた際などにも、型として繋がっていないためにエディタの力を借りて一気に修正することは難しい。

Web APIを付与するAspidaを使ってみる

axiosやfetchのようなHTTPクライアントに型を付与するaspidaがかなりいい感じだったので紹介する。

image.png
https://github.com/aspidajs/aspida

npmを用いてaspidaをインストールする。aspidaではRESTクライアントとしてaxioskyfetchが使えるがここではfetchを使ってみる。

npm install aspida @aspida/fetch --save

APIの型定義を記述する

ここでは例えば以下のWeb APIをサーバ側が提供していたとして、それに対応する型定義を記述してみる。

GET /user/:userId

指定したユーザの情報を返す。

Request

  • header
    • なし
  • body
    • なし

Response

  • header
    • なし
  • body
    • UserData型を示すJSON
interface UserData{
   userId:string;
   name:string;
}

POST /user

Request

  • header
    • なし
  • body
    • UserCreationData型を示すJSON
interface UserCreationData{
   name:string;
}

Response

  • header
    • なし
  • body
    • UserCreationResult型を示すJSON
interface UserCreationResult{
   userId:string;
}

PUT /user/:userId

Request

  • header
    • Authorization:string
  • body
    • UserCreationData型を示すJSON
interface UserCreationData{
   name:string;
}

Response

  • header
    • なし
  • body
    • なし

DELETE /user/:userId

Request

  • header
    • Authorization:string
  • body
    • なし

Response

  • header
    • なし
  • body
    • なし

Aspida用型定義の記述

以上のAPIに対する型を生成するためにaspida用のapi定義フォルダとしてapisフォルダを作成し、その中にuserフォルダを作り以下の2つのファイルを配置する。

apis/user/index.ts
export interface UserCreationData {
  name: string;
}

export interface UserCreationResult {
  userId: string;
}

export interface Methods {
  // Methodsという名前のinterfaceをexportしている必要がある
  post: {
    // 他にもget,delete,putなどをこの形式で記述する
    reqBody: UserCreationData; // reqBodyはリクエストの型を指定する。

    resBody: UserCreationResult; // resBodyはレスポンスの型を指定する。
  };
}
apis/user/_userId@string.ts
// パス自身が引数を指している場合には`_<引数名>@{string|number}.tsのように記述する。
// GET /group/:groupId/user/:userId のように中間のパスが引数となる場合には
// /apis/group/_groupId@string/user/_userId@string.tsのようなファイルを作成すれば良い 
import { UserCreationData } from "./index";

export interface UserData {
  userId: string;
  name: string;
}

export interface AuthHeaders{
   Authorization: string;
}

export interface Methods {
  get: {
    resBody: UserData;
  };
  put: {
    reqBody: UserCreationData;
    reqHeaders: AuthHeaders;
  };
  delete: {
    reqHeaders: AuthHeaders;
  };
}

Aspidaによって型定義の生成

もともとのAPIから直感的にaspida用のインターフェースに書き下すことが出来る。このaspida用のインターフェースをaspidaは解釈をして、実際にRESTクライアントを用いる際の型ファイルを生成する。

npx aspida --build

とすると、以下のようなaspidaが吐き出す型定義である$api.tsが生成される。


/* eslint-disable */
import { AspidaClient } from 'aspida'
import { Methods as Methods0 } from './index'
import { Methods as Methods1 } from './_userId@string'

const api = <T>(client: AspidaClient<T>) => {
  const prefix = (client.baseURL === undefined ? '' : client.baseURL).replace(/\/$/, '')

  return {
    _userId: (val0: string) => ({
      get: (option?: { config?: T }) =>
        client.fetch<Methods1['get']['resBody']>(prefix, `/${val0}`, 'GET', option).json(),
      $get: async (option?: { config?: T }) =>
        (await client.fetch<Methods1['get']['resBody']>(prefix, `/${val0}`, 'GET', option).json()).data,
      put: (option: { data: Methods1['put']['reqBody'], headers: Methods1['put']['reqHeaders'], config?: T }) =>
        client.fetch<void>(prefix, `/${val0}`, 'PUT', option).send(),
      $put: async (option: { data: Methods1['put']['reqBody'], headers: Methods1['put']['reqHeaders'], config?: T }) =>
        (await client.fetch<void>(prefix, `/${val0}`, 'PUT', option).send()).data,
      delete: (option: { headers: Methods1['delete']['reqHeaders'], config?: T }) =>
        client.fetch<void>(prefix, `/${val0}`, 'DELETE', option).send(),
      $delete: async (option: { headers: Methods1['delete']['reqHeaders'], config?: T }) =>
        (await client.fetch<void>(prefix, `/${val0}`, 'DELETE', option).send()).data
    }),
    post: (option: { data: Methods0['post']['reqBody'], config?: T }) =>
      client.fetch<Methods0['post']['resBody']>(prefix, '', 'POST', option).json(),
    $post: async (option: { data: Methods0['post']['reqBody'], config?: T }) =>
      (await client.fetch<Methods0['post']['resBody']>(prefix, '', 'POST', option).json()).data
  }
}

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

生成された型定義の利用

さらに、実際にリクエストを行うために以下のようにaspidaによってfetchを初期化する。

client.ts
import api from "../apis/$api";
import aspida from "@aspida/fetch";

const f = api(aspida(fetch, { baseURL: "http://hogeAPI.com" }));

こうして初期化されたRESTクライアントを用いることで型の恩恵を受けた状態でプログラムを書くことができる。
$get,$post,$put,$deleteをこのRESTクライアントの参照から呼び出すことによってリクエストを飛ばす。

VSCodeでAPIを叩くときは補完が以下のように働く。

パス上に引数がない例

パス上に引数がある場合の例

ヘッダーが必要なAPIの例

まとめ

自分はTypescriptではAPIを扱う際に自前でラップする関数群を作ることが標準的だった。
aspidaを使うことで、やり取りするデータに対してのみ注力すれば、実際のそれらのデータ毎に対応したREST APIの呼び出しを意識せず型の恩恵を受けることが出来る。
また、このaspida用に記述する形式の中でやり取りされる型をインターフェースなどにしてexportすることによって、Node.js用にサーバが記述されているのであればこれらの型をサーバ上でも用いることが出来る。

50
44
1

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
50
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?