🐙

【OpenAPI】APIスキーマから勝手に型がつくaxiosを作って幸せになる【openapi-typescript】

2022/03/15に公開約8,500字6件のコメント

はじめに


axiosの型付けはどうやらそれなりに頭を悩ませる課題のようです。
実際にuhyo氏のTwitterでのTypeScriptコミュニティにて以下のような質問がありました。

https://twitter.com/renoa_ts/status/1501542216162242563?s=20&t=ECJWXFDFB8yF1zpzofWRMw

私もaxiosの型付けに悩まされた一人です。
最近それなりに幸せに型付けできる方法が整理できたので、自身の備忘録も兼ねてまとめます。
前提として、openapiファイルが用意されていることとします。

幸せになるとは何か


結論から言うと次のような型の補完が効き、かつ型安全なaxiosのrequest APIのカスタムAPIを作る事です。


URLパスの補完が効いていて、

実装されているHTTPメソッドの補完も効いており、

パラメータの型も補完され、

レスポンスの型も手に入るrequest APIです。
見た目上は、request APIですので、axiosのドキュメントが流用できるのも美味しいです。

なぜできるのか


これが実現できるのは、openapiファイルから型を自動生成するopenapi-typescriptのおかげです。
執筆時点(2022/03)ではバージョン5.2.0であり、次のプロジェクトゴールの言葉から熱意を感じます。

Support converting any OpenAPI 3.0 or 2.0 (Swagger) schema to TypeScript types, no matter how complicated

しかし私がopenapi-typescriptを推すのはプロジェクトの熱量だけではありません。
openapi-typescriptで最も特筆すべきことは、URLパスとHTTPメソッドからAPIの情報を全て導けるような型が生成される点です。
Web APIはURLパスとHTTPメソッドのペアで一意的に決まるのだからURLパスとHTTPメソッドからAPIの情報が導けるのは当然と言えば当然です。
当然なのですが、URLパスとHTTPメソッドからAPI情報を導くという原則を型の情報として落とし込むのはどうやら大変な労力が必要なようでopenapi-typescript以外ではなかなか実現できてないようです。

実践

はじめに

実際にaxiosのrequest APIを作っていきます。
コードはこちらで公開しております。
今回利用したopenapiファイルはSwagger Editorのデフォルトを利用しております。

APIスキーマを生成する

適当なopenapiファイルを用意したとします。
openapi-typescriptのUsageに則り、次のコマンドを実行します。

npx openapi-typescript schema.yaml --output schema.ts

成功すると、schema.tsというファイルが生成されております。

schema.ts
/**
 * This file was auto-generated by openapi-typescript.
 * Do not make direct changes to the file.
 */

export interface paths {
  "/pet": {
    put: operations["updatePet"];
    post: operations["addPet"];
  };
  "/pet/findByStatus": {
    /** Multiple status values can be provided with comma separated strings */
    get: operations["findPetsByStatus"];
  };
  (中略)
}

URLパスとHTTPメソッドからAPI情報が導けています。
これが後に大変役に立ってくれます。

型の抽出


openapiファイルから型の情報は生成できました。
ですが、この状態では使いづらいので型を抽出するヘルパーを用意します。
ここでもAPIの原則を使います。
つまりURLパスとHTTPメソッドを指定すると、型の情報が抽出できるようにします。
結果は次のファイルになります。

schemaHelper.ts
import { paths } from "./schema";
import { UnionToIntersection, Get } from "type-fest";

export type UrlPaths = keyof paths;

export type HttpMethods = keyof UnionToIntersection<paths[keyof paths]>;

export type HttpMethodsFilteredByPath<Path extends UrlPaths> = HttpMethods &
  keyof UnionToIntersection<paths[Path]>;

export type RequestParameters<
  Path extends UrlPaths,
  Method extends HttpMethods
> = Get<paths, `${Path}.${Method}.parameters.query`>;

export type RequestData<
  Path extends UrlPaths,
  Method extends HttpMethods
> = Get<paths, `${Path}.${Method}.requestBody.content.application/json`>;

export type ResponseData<
  Path extends UrlPaths,
  Method extends HttpMethods
> = Get<paths, `${Path}.${Method}.responses.200.content.application/json`>;

改めて個々に解説します。
まずはschema.tsからURLパスを抽出します。

export type UrlPaths = keyof paths;

次にHTTPメソッドです。

export type HttpMethods = keyof UnionToIntersection<paths[keyof paths]>;

export type HttpMethodsFilteredByPath<Path extends UrlPaths> = HttpMethods &
  keyof UnionToIntersection<paths[Path]>;

ここでHTTPメソッドは2つ定義してます。
後者のHTTPメソッドは、URLパスが決まれば実装されているHTTPメソッドは絞られることを反映したものです。
HTTPメソッドを定義する際にUnionToIntersection<T>というtype-festのユーティリティを使っています。
TypeScriptで込み入った型操作をチームメンバーに共有するのは課題です。
その点type-festはドキュメントがあるので、メンバーへの共有が楽に済み、メンテナンスもされているので安全です。
型付けで頑張る必要があるときは、type-festのユーティリティを組み合わせて作ることは非常におすすめです。

次にリクエストのパラメータと、データを用意します。

export type RequestParameters<
  Path extends UrlPaths,
  Method extends HttpMethods
> = Get<paths, `${Path}.${Method}.parameters.query`>;

export type RequestData<
  Path extends UrlPaths,
  Method extends HttpMethods
> = Get<paths, `${Path}.${Method}.requestBody.content.application/json`>;

ここでもtype-festが大変役に立ちます。
最後にレスポンスの型を抽出します。

export type ResponseData<
  Path extends UrlPaths,
  Method extends HttpMethods
> = Get<paths, `${Path}.${Method}.responses.200.content.application/json`>;

ここまで用意すれば、本題のaxiosのrequest APIのカスタムAPIが作れます。

カスタムAPIの作成

カスタムAPIの結果は次のようになります。

axiosUtils
import axios, { AxiosResponse } from "axios";
import * as schemaHelper from "../models/schemaHelper";

export type AxiosConfigWrapper<
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods
> = {
  url: Path;
  method: Method & schemaHelper.HttpMethodsFilteredByPath<Path>;
  params?: schemaHelper.RequestParameters<Path, Method>;
  data?: schemaHelper.RequestData<Path, Method>;
};

export function request<
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods
>(config: AxiosConfigWrapper<Path, Method>) {
  return axios.request<
    schemaHelper.ResponseData<Path, Method>,
    AxiosResponse<schemaHelper.ResponseData<Path, Method>>,
    AxiosConfigWrapper<Path, Method>["data"]
  >(config);
}

個々に解説していきます。
request APIはconfigを引数に取るので、まずはconfigを型付けします。
ここでもURLパスとHTTPメソッドからAPI情報は決まるという原則を適用します。

export type AxiosConfigWrapper<
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods
> = {
  url: Path;
  method: Method & schemaHelper.HttpMethodsFilteredByPath<Path>;
  params?: schemaHelper.RequestParameters<Path, Method>;
  data?: schemaHelper.RequestData<Path, Method>;
};

最後にカスタムAPIをURLパスとHTTPメソッドから導けるような形で実装します。

export function request<
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods
>(config: AxiosConfigWrapper<Path, Method>) {
  return axios.request<
    schemaHelper.ResponseData<Path, Method>,
    AxiosResponse<schemaHelper.ResponseData<Path, Method>>,
    AxiosConfigWrapper<Path, Method>["data"]
  >(config);
}

これで完成です。
実際にコードを触ると、TypeScriptが強力な型推論を提供してくれることを感じられます。

axiosの元来の型付けについて

ここまで駆け足で説明したので、axios本来の型付けは何であったかを確認します。

request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>): Promise<R>;

これはrequest APIのaxios公式の型定義です。
Tはレスポンスのデータ型、Dはaxios configのリクエストのデータ型を表しています。
axiosは元々URLパスとHTTPメソッドではなく、リクエストとレスポンスのデータ型を直接参照しています。
これは汎用性のあるライブラリとしては仕方がないのですが、APIはレスポンスの型とリクエストの型だけでは特定できません。
例えば店舗の情報を取得するAPIがあるとして、店舗IDで検索するAPIと店舗名で検索するAPIはレスポンスの型が同じですが違うAPIであることが多々あります。
APIが特定できないということは、openapiファイルにどのようなAPIが定義されているのかがaxiosは知らないことを意味します。
では、何のAPIが定義されているのかをaxiosに教える役目はこのままでは人の手です。
つまりaxiosを利用するたびにopenapiファイルを見て、リクエストとレスポンスの型を埋めることになります。
これはO(n)の実装コストと、ヒューマンエラーを抱えることになる明確な課題です。
この課題を解決するために、openapiファイルに定義されたAPIを全て知るaxiosを作るというのは自然な問題です。
さて再三繰り返してきましたが、APIはURLパスとHTTPメソッドのペアで特定できます。
一方で、APIを特定するのに最も自然な情報はURLパスとHTTPメソッドでしょう。
ですので、この物知りなaxiosを作成するという問題は「openapiファイルに定義されたURLパスとHTTPメソッドを型情報として持つaxiosであり、このペアが指定されるとレスポンスの型等の付随情報を推論できるようなaxiosを作る」ことで解決されます。
これが上述したカスタムAPIの作成にてURLパスとHTTPメソッドを主眼に置いている理由なのです。
そしてこのURLパスとHTTPメソッドを軸にしたアプローチを支え、実現させてくれたのはopenapi-typescriptがopenapiファイルの情報をURLパスとHTTPメソッドのペアから導けるように生成してくれたおかげです。

最後に

実は、schema.tsのヘルパーは完璧ではありません。
プロジェクトによってはもっと作り込む必要もあるでしょう。
とはいえ作り込みの完成度をどれくらい求めるかという問題は、プロジェクトの現実解を模索する問題に通じます。
課題に対して現実解を模索し、落とし込むことはエンジニアの醍醐味の1つでしょう。
今後とも楽しく課題と遊び、幸せになる方法を模索していきたいものです。

おまけ

SWRのカスタムフック

SWRも似たようにカスタムフックが作れます。
結果だけですが紹介します。

useAppSWR
import useSWR from "swr";
import * as $axios from "../utils/axiosUtils";
import * as schemaHelper from "../models/schemaHelper";
import { AxiosError } from "axios";

const fetcher = <
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods
>(
  config: $axios.AxiosConfigWrapper<Path, Method>
) => {
  return $axios.request<Path, Method>(config).then((res) => res.data);
};

export const useAppSWR = <
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods
>(
  config: $axios.AxiosConfigWrapper<Path, Method>
) =>
  useSWR<schemaHelper.ResponseData<Path, Method>, AxiosError>(config, fetcher);

GitHubで編集を提案

Discussion

こちらの記事大変参考になりました。一点質問があります。
urlのpathには/pet/{pedId}{petId}ように動的に変化するpathもあるかと思っています。この場合はどのような型解決のアプローチを取っているのかお手すきに教えていただきたいです!

自分ではas を使用して解決する方法を思いついたのですが他にいい手がないかと思いまして。

{
  url: `/pet/${petId}` as '/pet/{pedId}'
}

追記:
すみません、こちら私の勉強不足だったようで、paramにpedIdを渡して上げればよいのですね。。

{
  url: `/pet/{petId}`,
  param: { petId }
}

追記:
axiosではパスパラメータをサポートしていませんでしたmm

コメントありがとうございます!

実は、schema.tsのヘルパーは完璧ではありません。
プロジェクトによってはもっと作り込む必要もあるでしょう。

当記事で触れていたschema.tsのヘルパーは完璧ではないと述べておりますが、まさしくパスパラメータを指定したいケースが作り込みが足りないケースです
この場合は '/pet/{pedId}'というLiteral型からTemplate Literal Typeである/per/${string}へ変換する型が必要です
具体的には次を追記します

import {Replace} from 'type-fest'

type ReplacePathParameter<T extends string> = Replace<T, `{${string}}`, string, {all: true}>

詳しくはTypeScript playgroundで例を書きましたので、こちらを見ていただければと

ですが、/pet/${string}型から'/pet/{pedId}'型を絞り込むことも必要ですね
こちらに関してはすみませんが答えが見つけられてないです
解決した場合は教えていただければ幸いです!

すみません、こちら私の勉強不足だったようで、paramにpedIdを渡して上げればよいのですね。。

教えていただきありがとうございます!
私がこれを作った時はパスパラメータの無いケースであったので知りませんでした
このように回避できるのですね

こちら丁寧にご回答ありがとうございます!

この場合は '/pet/{pedId}'というLiteral型からTemplate Literal Typeである/per/${string}へ変換する型が必要です

具体例までありがとうございます。なるほどです、ダウンキャストして許容する形にするのですね。試してみます!

すみません、こちら私の勉強不足だったようで、paramにpedIdを渡して上げればよいのですね。。

申し訳ないですが、こちらに関しては完全に私の間違いで、axiosではパスパラメータをサポートしていませんでしたmm(コメントにも追記しました)

こちらの記事のおかげで手動の苦労から開放されました、ありがとうございます🙏

こちらでもパスパラメータの箇所だけカスタマイズしたので共有させていただきます(最低限動くようにしただけなので諸々改良の余地はあると思います)

schemaHelper.ts
// 追加
export type RequestUrlPaths<
  Path extends UrlPaths,
  Method extends HttpMethods,
> = Get<paths, `${Path}.${Method}.parameters.path`>
axiosUtils.ts
export type AxiosConfigWrapper<
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods> = {
  url: Path
  method: Method & schemaHelper.HttpMethodsFilteredByPath<Path>
  params?: schemaHelper.RequestParameters<Path, Method>
+ paths?: schemaHelper.RequestUrlPaths<Path, Method>
  data?: schemaHelper.RequestData<Path, Method>
}

export function request<
  Path extends schemaHelper.UrlPaths,
  Method extends schemaHelper.HttpMethods>(config: AxiosConfigWrapper<Path, Method>) {
+ const { url, paths, ...baseConfig } = config
+ const requestConfig: AxiosRequestConfig = {
+   ...baseConfig,
+   url: Object.entries(paths ?? {}).reduce(
+     (previous, [key, value]) =>
+       previous.replace(new RegExp(`\\{${key}\\}`), String(value)),
+     url as string,
+   ),
+ }

  return axios.request<
    schemaHelper.ResponseData<Path, Method>,
    AxiosResponse<schemaHelper.ResponseData<Path, Method>>,
    AxiosConfigWrapper<Path, Method>['data']
- >(config)
+ >(requestConfig)
}

コメントありがとうございます!
正規表現で捌くとうまくいくのですね
勉強になります!

ログインするとコメントできます