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.id
はany
であるから、ここを書き間違えることはよくある。
また、サーバサイドに渡す引数名を変えた際などにも、型として繋がっていないためにエディタの力を借りて一気に修正することは難しい。
Web APIを付与するAspidaを使ってみる
axiosやfetchのようなHTTPクライアントに型を付与するaspida
がかなりいい感じだったので紹介する。
https://github.com/aspidajs/aspida
npmを用いてaspidaをインストールする。aspidaではRESTクライアントとしてaxios
、ky
、fetch
が使えるがここでは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つのファイルを配置する。
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はレスポンスの型を指定する。
};
}
// パス自身が引数を指している場合には`_<引数名>@{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を初期化する。
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用にサーバが記述されているのであればこれらの型をサーバ上でも用いることが出来る。