👍

openapi-fetchでOpenAPIからコード生成してみた

2024/02/10に公開

フロントエンドでOpenAPI 3から型ファイルを生成したいとライブラリを探していたところopenapi-typescriptというリポジトリを見つけました。色々な方が記事で紹介していました。

https://zenn.dev/micin/articles/openapi-typescript-with-type-puzzles

しかし、openapi-typescriptばかりでopenapi-fetchがあまり紹介されていなかったので記事にしてみました。

openapi-typescriptのリポジトリでは以下の二つが紹介されています。

OpenAPIスキーマからTypeScriptの型のみを生成します

OpenAPIスキーマを取り込むタイプセーフなfetch client。重さは2KBで、実行時間は事実上ゼロです。React、Vue、Svelte、またはバニラ JS で動作します。

フロントエンドでのHttpClientはfetchを使う予定だったので、OpenAPIから型とfetchのClientまで生成してくれるopenapi-fetchを利用します。以下の表で示されるようにとっても速いです。

Library Size (min) “GET” request
openapi-fetch 2 kB 151k ops/s (fastest)
openapi-typescript-fetch 4 kB 99k ops/s (1.4× slower)
axios 32 kB 90k ops/s (1.6× slower)
superagent 55 kB 42k ops/s (3× slower)
openapi-typescript-codegen 367 kB 71k ops/s (2× slower)

余談ですが、よく使われるaxiosですが、個人的にはあまりfetchと使用感は変わらないので、より軽量で速いfetchを使うのが良いと思います。

実装に関しては基本的にはREADMEに書かれている内容に沿ってやっていけばいけると思います。

具体的な実装

Setup

> npm i openapi-fetch
> npm i -D openapi-typescript typescript

型の生成

型の生成は以下コマンドで行います。

> npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts

ただ、私はDockerでやりたかったので、docker runで実行する方法を取りました。以下はDockerfileとMakefileです

https://github.com/ryo034/react-go-template/blob/main/Makefile#L96

/container/schema/openapi/openapi-typescript/Dockerfile

FROM node:alpine

WORKDIR /app

RUN npm install -g openapi-typescript

ENTRYPOINT ["openapi-typescript"]

Makefile

.PHONY: gen-system-client-openapi
gen-system-client-openapi:
	@rm -rf ./apps/system/client/src/generated/schema/openapi
	@docker build -f ./container/schema/openapi/openapi-typescript/Dockerfile -t openapi-typescript-codegen-tmp .
	@docker run --rm -v .:/app \
		openapi-typescript-codegen-tmp \
		/app/schema/api/system/openapi/openapi.yaml -o /app/apps/system/client/src/generated/schema/openapi/systemApi.ts

コード生成のたびにdocker run --rmでコンテナ終了時にコンテナを自動的に削除しています。コード生成自体が高速なので、あまり時間もかからず、ローカル環境が汚されないのでおすすめです。

HTTPクライアント作成

クライアントは以下のようになっており自動生成されたpathsを入れることで型が効くようになります。

import createClient from "openapi-fetch";
import type { paths } from "./api/v1"; // generated by openapi-typescript

const { GET, PUT } = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });

インターセプトしたい場合

リクエストヘッダーにtokenを載せたかったので上記のクライアントにinterceptorを実装しました。clientのuseを使いmiddlewareを設定することができます。

以前の書き方

ライブラリのバージョンが更新されMiddlewareの書き方が少し変わりました。こちらは以前の書き方です

import createClient, { type Middleware } from "openapi-fetch"
import type { paths } from "~/generated/schema/openapi/systemApi"
import { firebaseAuth } from "~/infrastructure/firebase"

const fetchRequestInterceptor: Middleware = {
  async onRequest(req, _options) {
    if (firebaseAuth.currentUser === null) {
      return req
    }
    const token = await firebaseAuth.currentUser.getIdToken()
    if (!token) {
      return req
    }
    req.headers.set("Authorization", `Bearer ${token}`)
    return req
  }
}

const fetchResponseInterceptor: Middleware = {
  async onResponse(res, _options) {
    if (res.status === 401) {
      await firebaseAuth.signOut()
    }
    return res
  }
}

export const openapiFetchClient = createClient<paths>({
  baseUrl: import.meta.env.VITE_API_BASE_URL
})

openapiFetchClient.use(fetchRequestInterceptor)
openapiFetchClient.use(fetchResponseInterceptor)

export type SystemAPIClient = typeof openapiFetchClient
import createClient, { type Middleware } from "openapi-fetch"
import type { paths } from "~/generated/schema/openapi/systemApi"
import { firebaseAuth } from "~/infrastructure/firebase/setup"

const fetchRequestInterceptor: Middleware = {
  async onRequest({ request }) {
    if (firebaseAuth.currentUser === null) {
      return request
    }
    const token = await firebaseAuth.currentUser.getIdToken()
    if (!token) {
      return request
    }
    request.headers.set("Authorization", `Bearer ${token}`)
    return request
  }
}

const fetchResponseInterceptor: Middleware = {
  async onResponse({ response }) {
    if (response.status === 401) {
      await firebaseAuth.signOut()
    }
    return response
  }
}

export const openapiFetchClient = createClient<paths>({
  baseUrl: import.meta.env.VITE_API_BASE_URL
})

openapiFetchClient.use(fetchRequestInterceptor)
openapiFetchClient.use(fetchResponseInterceptor)

export type SystemAPIClient = typeof openapiFetchClient

レスポンスのハンドリング

ステータスコードに応じて処理を実行したかったので以下のような処理を書きました。

export const openapiFetchErrorInterpreter = (res: unknown): Error | null => {
  if (res !== null && typeof res === "object" && "response" in res && (res as any).response instanceof Response) {
    const r = res as {
      data?: undefined
      error?: { code?: number; message?: string }
      response: Response
    }
    return convertToErrorByStatusCode(r.response.status, r.error?.message)
  }
  return null
}

responseで返ってくるerrorをそのまま渡します。

const apiRequest = async () => {
  const response = await GET("/blogposts/{post_id}", {
    params: {
      path: { post_id: "123" }
    }
  })

  if (response.error) {
    throw openapiFetchErrorInterpreter(response)
  }
  return response.data
}

まとめ

軽量でとてもシンプルなので使いやすいライブラリでした。今後もこれを使っていこうと思います。

Discussion