openapi-typescriptと型パズルで作るREST APIクライアント
この記事は MICIN Advent Calendar 2022 の16日目です。
前回は外山さんの GA4で実現するクロスプラットフォームのデータ計測と分析 でした。
皆様はじめまして。株式会社MICINでアプリケーションエンジニアとして働いている小泉です。普段は主にMiROHAという治験業務支援アプリケーションのフロントエンドの開発を行っています。
TypeScriptでフロントエンドの実装を行う場合、APIのレスポンスやパラメータに型をつけることは当然のことかと思います。最近ではGraphQLのスキーマやOpenAPIのスキーマから型やクライアント用のコードを自動生成することも広まってきている印象があります。
一方でバックエンドのAPIに関わる型や処理を自前で定義している場合もあるかもしれません。私が携わっているプロジェクトでも以前はバックエンドの実装やAPI仕様書を見つつフロントエンドは自前で実装するということを行っており、しばしば型の定義や実装の不整合という問題が発生していました。単純なtypoのせいでデータが表示されないという悲しいことが発生したこともあります。
この記事では、こういった非生産的な状況をopenapi-typescriptというライブラリによる型生成と、Mapped TypesなどといったTypeScriptの高度な型システムを生かした型定義(型パズル)の組み合わせによって解消する方法を紹介しようと思います。
今回、実務で行った内容をもとにサンプルのリポジトリを作成したので良かったらこちらもご参照ください。
前提
前述の通り、私が携わっているプロジェクトのフロントエンドではAPIの呼び出しを自前で定義していました。HTTPクライアントのライブラリにはaxiosを使用しているので以下のようにコードを定義していました。
// レスポンスの型定義
type Pet = {
id: number;
name: string;
};
// GETリクエストを行う関数
export const listPets = async (): Promise<Pet[]> => {
const url = "/pets";
try {
const res = await axios.get<Pet[]>(url);
return res.data;
} catch (e) {
throw e;
}
};
もちろん、こういったやり方でも手作業で書いているコードに誤りがなければエラーは発生しません。しかし、単純に多くの労力が必要なので面倒なうえ、手作業であれば100%ミスは発生します。上記のケースでも以下のように誤った型定義をしてしまったとしても型レベルではエラーが出ないため画面を見ながら「名前が表示されない!」ということになりかねません。
type Pet = {
id: number;
Name: string; //PascalCaseで定義してしまった!
};
幸いなことに、私たちのプロジェクトではOpenAPI形式のAPIドキュメントが整備されており、メンテナンスもしっかりと行われていました。そのため既にあるドキュメントから型定義を生成すればよさそうという状況でした。そもそもAPIドキュメントが整備されていない、メンテナンスされていないという状況ではなかった点はご認識いただければと思います。
技術選定
タイトルにもあるように私たちのプロジェクトではopenapi-typescriptというライブラリを使用してOpenAPIから型づけを行うことにしました。一般的な名前のライブラリではあるものの、OpenAPI Generatorなどと比べるとそこまで情報が豊富なわけではないため馴染みがないかもしれません。
情報が少ない中、以下の記事はコメントのやりとりも含めて大変参考になりました。ありがとうございます。これから紹介するアイデアはこちらでご紹介されている内容を大いに参考にさせて頂きました。
openapi-typescriptの選定理由
今回このライブラリを選定した理由は主に以下の理由からです。
薄い
openapi-typescriptはinputとなるyamlファイルをもとに型定義ファイルのみを出力してくれます。一方でaxiosやtypescript-fetchのクライアントコードまでを自動生成してくれるわけではありません。後述するOpenAPI Generatorやaspidaといったライブラリではクライアントコードまで自動生成してくれるため、これは一見デメリットのように思えますがカスマイズの幅が広いということも意味します。この記事のテーマの一つでもある型パズルを行うことで強固な型を持つAPIクライアントを定義できるのです。すでに大きくなっているプロジェクトや他のクライアント生成まで行ってくれるライブラリで痒いところに手が届かないという場合にはこのカスタマイズが効くということは非常に嬉しい点になるのではないかと思います。
複雑な型にも対応している
TypeScriptで開発を進めていくなら、TypeScriptの強力な型システムの恩恵を享受したいものです。openapi-typescriptはプロジェクトのGoalでも以下のように述べられており、OpenAPIに定義されている情報をかなり正確にTypeScriptの型に変換してくれます。
Support converting any valid OpenAPI schema to TypeScript types, no matter how complicated.
例えば、OpenAPIで定義されているenumはTypeScriptのenumではなくユニオン型に変換してくれます。TypeScriptのenumは数値に割り当てた際の型安全性の欠如やJavaScriptとの互換性の問題からユニオン型を使うほうが好ましいとされています。このあたり、オプションを設定せずともユニオン型で生成されるのは嬉しいですね。
# inputとなるOpenAPIスキーマ
components:
schemas:
Pet:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
category:
type: string
enum: # enum定義
- dog
- cat
// 出力されるTypeScriptの型定義
export interface components {
schemas: {
Pet: {
/** Format: int64 */
id: number;
name: string;
/** @enum {string} */
category?: "dog" | "cat";
};
}
他にも、OpenAPIスキーマのminItems
やmaxItems
の値に応じてTuple型を吐き出してくれたりするなど、より型安全な型を出力してくれる点はTypeScriptが好きな身としては非常に嬉しい点です。
開発が活発
openapi-typescriptは2022年12月現在非常に活発に開発が行われており、つい先月最新のメジャーバージョンである6.0がリリースされました。OpenAPIのフォーマット自体もアップデートが行われており、この手のライブラリを選択する際に開発が活発であるという点は大きなアドバンテージになるのではないかと思います。
選ばなかった主なライブラリ
OpenAPI Generator
この手のことをやろうとした場合の老舗ライブラリだと思います。情報もたくさんあるのですが実装がJavaのためNodeの環境と別に実行環境を用意しなければならない点や、ライブラリ自体の継続的なメンテナンスに対する不安などから今回は候補から除外しました。
openapi2aspida
最近流行りのHTTPクライアントライブラリであるaspida用の型定義をOpenAPIのドキュメントから出力してくれます。クライアントのコードからモデルの型まで出力され「すごい」という印象を抱きます。かなり良い印象ではあったものの、openapi-typescriptで薄く導入するほうが良いという判断をしたので今回は見送りました。これからなにか作る場合は是非とも導入してみたいと思っています。
型定義
入力ファイルと出力ファイルのパスを指定して以下のコマンドを実行すると型定義ファイルが出力されます。これだけです。
npx openapi-typescript schema.yaml --output schema.gen.ts
以下のようにpackage.jsonにscriptを定義しておくと良いでしょう。
"scripts": {
"generate:api": "npx openapi-typescript schema.yaml --output schema.gen.ts"
}
すべての情報が1ファイルに出力されます。これを型パズルによって使いやすくし、カスタムAPIクライアントの実装を行っていきます。
型パズルとAPIクライアントの実装
openapi-typescriptによる型生成を実行することで以下のようなTypeScriptの型を得ることができます。
schema.gen.ts
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/pets": {
/** List all pets */
get: operations["listPets"];
/** Create a pet */
post: operations["createPets"];
};
"/pets/{petId}": {
/** Info for a specific pet */
get: operations["showPetById"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Pet: {
/** Format: int64 */
id: number;
name: string;
/** @enum {string} */
category?: "dog" | "cat";
};
Pets: (components["schemas"]["Pet"])[];
Error: {
/** Format: int32 */
code: number;
message: string;
};
/** PetValidationError */
PetValidationError: {
/** @enum {string} */
field?: "name" | "category";
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type external = Record<string, never>;
export interface operations {
listPets: {
/** List all pets */
parameters?: {
/** @description How many items to return at one time (max 100) */
query?: {
limit?: number;
};
};
responses: {
/** @description A paged array of pets */
200: {
headers: {
/** @description A link to the next page of responses */
"x-next"?: string;
};
content: {
"application/json": components["schemas"]["Pets"];
};
};
/** @description unexpected error */
default: {
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
createPets: {
/** Create a pet */
requestBody?: {
content: {
"application/json": {
name?: string;
};
};
};
responses: {
/** @description Null response */
201: never;
/** @description Bad Request */
400: {
content: {
"application/json": {
message?: string;
validationError?: components["schemas"]["PetValidationError"];
};
};
};
/** @description unexpected error */
default: {
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
showPetById: {
/** Info for a specific pet */
parameters: {
/** @description The id of the pet to retrieve */
path: {
petId: string;
};
};
responses: {
/** @description OK */
200: {
content: {
"application/json": components["schemas"]["Pet"];
};
};
/** @description Expected response to a valid request */
400: {
content: {
"application/json": components["schemas"]["Pet"];
};
};
/** @description unexpected error */
default: {
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
}
この型情報があれば以下のようにルックアップ型を使って特定のエンドポイントのレスポンスを取得するようなことは可能になりますがこれだとあまり使いやすくはありません。
type ListPetsResponse =
paths["/pets"]["get"]["responses"]["200"]["content"]["application/json"];
ここで型パズルの出番です。APIクライアントに必要な型を取得できるよう以下のように型パズルを駆使したutilityを定義していきます。
schema-util.ts
import { paths } from "./schema.gen"
export type ApiPath = keyof paths
// union( | ) to intersection( & )
type UnionToIntersection<T> = (T extends any ? (k: T) => void : never) extends (
k: infer U
) => void
? U
: never
export type HttpMethod = keyof UnionToIntersection<paths[keyof paths]>
// 指定したパスが取りうるhttpメソッドを絞り込む
export type ExactHttpMethodByPath<Path extends ApiPath> = HttpMethod &
keyof UnionToIntersection<paths[Path]>
// 指定したhttpメソッドを取りうるパスを絞り込む
// key-remappingを使っている cf)https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
export type ExactPathByHttpMethod<Method extends HttpMethod> =
Method extends any
? keyof {
[K in keyof paths as paths[K] extends Record<Method, any>
? K
: never]: paths[K]
}
: never
// ネストされたプロパティの型を取得する。
// ex) GetNestedValue<{ a: { b: { c: { someKey: someValue } } } }, ['a', 'b', 'c']> is { someKey: someValue }
type GetNestedValue<
T extends Record<string, any>,
Keys extends (string | number)[]
> = 0 extends Keys["length"]
? T
: Keys extends [infer First, ...infer Rest]
? First extends keyof T
? Rest extends (string | number)[]
? GetNestedValue<Required<T[First]>, Rest>
: never
: never
: never
type GetContent<
Path extends ApiPath,
Method extends HttpMethod,
Code extends number
> = GetNestedValue<
paths,
[Path, Method, "responses", Code, "content", "application/json"]
>
export type ApiResponse<
Path extends ApiPath,
Method extends HttpMethod
> = GetContent<Path, Method, 200 | 201>
export const httpErrorStatusCodes = [400, 401, 404, 500] as const
type HttpErrorCode = typeof httpErrorStatusCodes[number]
// schemaにエラーレスポンスが定義されている場合はその型を、そうでない場合は {message: string}型をdataに当てはめる
export type ApiErrorWithErrorCode<
Path extends ApiPath,
Method extends HttpMethod,
Code extends HttpErrorCode
> = Code extends number // ここで型変数Codeを条件分岐にかけることによってユニオン型を得られるようにする(union distribution)
? GetContent<Path, Method, Code> extends never
? {
status: Code
data: {
message: string
}
}
: {
status: Code
data: GetContent<Path, Method, Code>
}
: never
export type ApiError<
Path extends ApiPath,
Method extends HttpMethod
> = ApiErrorWithErrorCode<Path, Method, HttpErrorCode>
export type ApiPathParam<
Path extends ApiPath,
Method extends HttpMethod
> = GetNestedValue<paths, [Path, Method, "parameters", "path"]>
export type ApiQueryParam<
Path extends ApiPath,
Method extends HttpMethod
> = GetNestedValue<paths, [Path, Method, "parameters", "query"]>
export type ApiBody<
Path extends ApiPath,
Method extends HttpMethod
> = GetNestedValue<
paths,
[Path, Method, "requestBody", "content", "application/json"]
>
これで、APIのレスポンスやパラメータの型を効率よく取得できるようになりました。これを使ってAPIクライアントの実装を行います。APIクライアントは最終的に以下のような形になります。
client.ts
type CreateApiClientOption<Path extends ApiPath, Method extends HttpMethod> = {
path: Path
httpMethod: Method
params?: {
paths?: ApiPathParam<Path, Method>
query?: ApiQueryParam<Path, Method>
body?: ApiBody<Path, Method>
}
}
export type RequestResponse<Path extends ApiPath, Method extends HttpMethod> =
| { result: "success"; data: ApiResponse<Path, Method> }
| { result: "error"; error: ApiError<Path, Method> }
export const createApiClient = <
Path extends ApiPath,
Method extends HttpMethod
>(
option: CreateApiClientOption<Path, Method>
) => {
const path = () => {
// {trial_uid} などとなっているpathを実際の値に変換する
const fullPath = Object.entries(option.params?.paths ?? {}).reduce(
(prev, [key, value]) =>
prev.replace(new RegExp(`\\{${key}\\}`), String(value)),
option.path as string
)
const searchParam = new URLSearchParams()
Object.entries(option.params?.query ?? {}).forEach(([key, value]) => {
if (typeof value === "string") {
searchParam.set(key, value)
}
})
if (searchParam.toString().length > 0) {
return fullPath + "?" + searchParam.toString()
}
return fullPath
}
const request = async (): Promise<RequestResponse<Path, Method>> => {
try {
const res = await axios.request<ApiResponse<Path, Method>>({
baseURL: "",
method: option.httpMethod,
url: path(),
data: option.params?.body,
withCredentials: true,
})
return {
result: "success",
data: res.data,
}
} catch (e) {
if (axios.isAxiosError(e) && !!e.response) {
const errorData = { status: e.response.status, data: e.response.data }
if (isExpectedError<Path, Method>(errorData)) {
return {
result: "error",
error: errorData,
}
}
}
throw new Error("unexpected error")
}
}
return { path, request }
}
// schema-utilで定義しているエラーコードのリストにレスポンスのステータスコードが含まれるかチェック
const isExpectedError = <Path extends ApiPath, Method extends HttpMethod>(res: {
status: number
data: any
}): res is ApiError<Path, Method> => {
return httpErrorStatusCodes.map(Number).includes(res.status)
}
これで独自のAPIクライアントが完成しました。以下のようにクライアントを呼び出し、request()
という関数を呼び出すことでAPIリクエストを実行することができます。工夫した点としてはエラーの扱いが挙げられます。単にthrowするのではなくhttpステータスに応じてエラーにも型がつくようにしています。これは私たちのプロジェクトでバリデーションエラーなどで詳細なエラーレスポンスを返却するという要求があるためです。エラーをオブジェクトの形式で返してthrowしないというやり方はこちらを参考にさせていただきました。
このあたりもカスタマイズできるからこそ実現できたことではないかと思います。また、VSCodeなどで開発していればpathやstatusで絞り込んだ場合のdataの値については補完が効くので開発効率もアップします。
const createPet = async () => {
const api = createApiClient({
path: "/pets",
httpMethod: "post",
params: {
body: {
name: "new_pet",
},
},
});
const res = await api.request();
if (res.result === "success") {
console.log(res.data);
}
if (res.result === "error") {
// 400の場合はバリデーションエラーが発生しているということを型レベルで絞り込める
if (res.error.status === 400) {
const validationError = res.error.data.validationError;
console.error("error field is", validationError.field);
}
console.error(res.error.data.message);
}
};
補完が効く
カスタムhooksをつくる
これで自動生成された型に守られたAPIクライアントを手に入れることができました。これでも以前に比べれば大幅に安全性と楽さを向上させることができました。ただ、毎回エラーハンドリングをif文で行ったりすることはやや面倒です。私たちのプロジェクトではReactを使っているので、生成された型を生かしたカスタムhooksを作りたいと思います。ここではGETリクエスト用のカスタムhooksであるuseFetch
の実装をご紹介しようと思います。私たちのプロジェクトではSWRを使っいるのでSWRをラップした同様のインターフェイスを提供するhooksを提供しようと思います。実装は以下です。
use-fetch.ts
type GetPath = ExactPathByHttpMethod<'get'>
type UseFetchOption<Path extends GetPath> = {
path: Path
params?: {
paths?: ApiPathParam<Path, 'get'>
query?: ApiQueryParam<Path, 'get'>
}
shouldCancel?: boolean
onSuccess?: (data: ApiResponse<Path, 'get'>) => void
onError?: (error: ApiError<Path, 'get'>) => void
} & Omit<SWRConfiguration, 'onSuccess' | 'onError'> // revalidateOnFocus などのSWRのオプションも渡せるようにしておく
export const useFetch = <Path extends GetPath>(
options: UseFetchOption<Path>,
) => {
const { path, params, onSuccess, onError, shouldCancel, ...swrOptions } =
options
const api = createApiClient({ path, httpMethod: 'get', params })
const {
data: swrData,
mutate: swrMutate,
isValidating,
} = useSWR<RequestResponse<Path, 'get'>>(
shouldCancel ? null : api.path(),
() => api.request(),
{
onSuccess: res => {
if (res.result === 'success' && !!onSuccess) {
onSuccess(res.data)
return
}
if (res.result === 'error' && !!onError) {
onError(res.error)
return
}
},
onError: e => {
// unexpected error をハンドリング
console.error(e.message)
},
...swrOptions,
},
)
const data = useMemo(() => {
if (!swrData) return undefined
if (swrData.result !== 'success') return undefined
return swrData.data
}, [swrData])
const error = useMemo(() => {
if (!swrData) return undefined
if (swrData.result !== 'error') return undefined
return swrData.error
}, [swrData])
const mutate = useCallback(
(
data?: ApiResponse<Path, 'get'>,
shouldRevalidate?: boolean,
) => {
swrMutate(
data ? { result: 'success', data } : undefined,
shouldRevalidate,
)
},
[swrMutate],
)
return { data, error, mutate, isValidating }
}
前述のように例外はthrowせずオブジェクトで返すようにしているのでSWRのエラーは期待するエラーではありません。そのためSWRのonSuccess
内で引数で受け付けるonSuccess
とonError
を処理するなどといった処理を実装しています。
使い方は以下のようにSWRとほとんど同様ですが、keyを指定するのではなくAPIスキーマに定義されているpathを指定します。dataとエラーにもこれだけで型がつくようになります。これでデータ取得をより型安全に、より宣言的に行えるようになりました。
function App() {
const petId = "petId"
const { data, error } = useFetch({
path: "/pets/{petId}",
params: { paths: { petId } },
onError: e => {
// result='error'の場合のエラー
if (e.status === 400) {
console.error(e.data.id)
}
},
})
if (!data) return null
if (error && error.status !== 400) {
return <p>{error.data.message}</p>
}
return (
<div className="App">
<p>{data.name}</p>
</div>
)
}
もちろん補完が効く
GETリクエスト用のhooksについて紹介しましたがPOSTリクエストやPUTリクエストも同様にhooksで定義したくなるかと思います。こちらもuseSubmit
というカスタムhooksを実装しました。こちらは実装のご紹介にとどめますがこれですべてのAPIリクエストを型安全に、hooksで記述できるようになりました。
use-submit.ts
type SubmitHttpMethod = Exclude<HttpMethod, "get">
type SubmitPath = ExactPathByHttpMethod<SubmitHttpMethod>
type ExactSubmitMethod<Path extends SubmitPath> = SubmitHttpMethod &
ExactHttpMethodByPath<Path>
type UseSubmitOption<
Path extends SubmitPath,
Method extends ExactSubmitMethod<Path>
> = {
path: Path
httpMethod: Method
params?: {
paths?: ApiPathParam<Path, Method>
}
onSuccess?: (data: ApiResponse<Path, Method>) => void
onError?: (error: ApiError<Path, Method>) => void
onRequestStarted?: () => void
onRequestDone?: () => void
}
export const useSubmit = <
Path extends SubmitPath,
Method extends ExactSubmitMethod<Path>
>(
option: UseSubmitOption<Path, Method>
) => {
const {
path,
httpMethod,
params,
onSuccess,
onError,
onRequestStarted,
onRequestDone,
} = option
const [requesting, setRequesting] = useState(false)
const [data, setData] = useState<ApiResponse<Path, Method>>()
const [error, setError] = useState<ApiError<Path, Method>>()
const requestStart = useCallback(() => {
setRequesting(true)
if (!!onRequestStarted) {
onRequestStarted()
}
}, [onRequestStarted])
const requestDone = useCallback(() => {
setRequesting(false)
if (!!onRequestDone) {
onRequestDone()
}
}, [onRequestDone])
const request = useCallback(
async (body?: ApiBody<Path, Method>) => {
const api = createApiClient({
path,
httpMethod,
params: { paths: params?.paths, body },
})
try {
requestStart()
const res = await api.request()
requestDone()
if (res.result === "success") {
setData(res.data)
if (!!onSuccess) {
onSuccess(res.data)
}
return
}
if (res.result === "error") {
setError(res.error)
if (!!onError) {
onError(res.error)
}
}
} catch (e) {
// apiエラー以外のエラーを捕捉
requestDone()
throw e
}
},
[
path,
httpMethod,
params?.paths,
requestStart,
requestDone,
onSuccess,
onError,
]
)
return { request, data, error, requesting }
}
課題
ここまでopenapi-typescriptを使ったAPIクライアントの実装とそのメリットについて紹介してきました。大きなメリットを得られることは間違いない一方今後の運用に向けた課題も少しばかり感じています。そこで現時点で感じている課題を簡単に紹介しようと思います。
モデルの型
openapi-typescriptではtype Pet
のようなモデルの型は出力されません。データを取得して流す場合などは特に問題になりませんが関数の引数やpropsで型を受け付けたい場合の指定をApiResponse<'/pets', 'get'>
のように型引数に指定して型を定義することになるのでもう少し直感的にしたいところです。
parameterの型
お気づきかもしれませんがcreateApiClient
の引数に渡すparams
の値はoptionalな引数として定義しています。例えば/pets/{petId}
のようなエンドポイントの場合pathパラメータを指定することは必須ですが現状型レベルで必須であることを表現できていません。入力時の補完は得られるもののもう一歩指定しておきたいところです。現状は実装の複雑さとのトレードオフを考慮して対応していませんが機会をみて対応しようと思います。
schemaの運用
これはopenapi-typescriptの課題というわけではありませんがスキーマの運用は今後の課題になりそうです。というのも、これまではバックエンドのリポジトリのみでAPIスキーマを管理していたのですがこれからはフロントエンドでもスキーマを参照する機会が増大するためその運用はこれまで以上に整備したいところです。現状はAPIの更新時にフロントエンドも手動でスクリプト実行をして対応していますが自動でのPR作成などを実現できれば良いと考えています。
さいごに
いかがでしたでしょうか。今回紹介した内容を取り入れることでAPIリクエストの実装の苦しみを大きく軽減することができました。すでにスキーマをベースとした開発を進めているプロジェクトからすれば当たり前のことも多いかもしれないですが同じ苦しみを抱えているプロジェクトもあるのではないでしょうか。少しでもこの記事の内容がお役に立てば幸いです。
MICINではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
MICIN採用ページ:https://recruit.micin.jp/
Discussion
自分もまさに同じ事をしたくって、自前でコード生成ツールを作ってみたり、悶々と数年過ごしていました…
実装内容、参考にさせていただきます! 🙏🙏🙏