はじめに
orvalはOpenAPIからTypeScript用のAPIクライアントと、APIのリクエスト/レスポンスで使うオブジェクトやEnumの型定義を生成するツールです。
今回はorvalを使って、FastAPIが生成したOpenAPIからコード生成する導入方法と、カスタマイズしておいた方が良い点について紹介したいと思います。
orvalは以下のような特徴があります。
- axiosに対応(既存のaxiosInstanceをそのまま使うことができるので、インターセプターを使った既存の仕組みもそのまま使用可能)
- fetchにも対応(axiosである必要がない場合、容量を削減することができる)
- Node.jsさえあればorvalを実行可能
- APIクライアントのカスタマイズが容易
- Tanstack Queryのコードを生成することも可能
- MSW用のモックを生成できる
それでは、ここからはFastAPI + orvalの構成で説明していきます。
FastAPI側の準備
今回はサンプルとしてCRUDを用意します。(APIの定義さえあれば良いので処理はダミーとなっています。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | import uuid from fastapi import APIRouter, FastAPI from pydantic import BaseModel, ConfigDict from enum import StrEnum, auto from datetime import datetime from pydantic.alias_generators import to_camel app = FastAPI(title="Petstore", version="1.0.0") # --- Models --- class BaseSchema(BaseModel): """APIのリクエスト・レスポンスJSONをキャメルケースにするためのベースクラス""" model_config = ConfigDict( # フィールドをキャメルケースに変換 alias_generator=to_camel, # スネークケースも許可ための設定 # (この設定をオンにしないと、Pythonコード上でスネークケースでのインスタンス生成ができなくなってしまう) populate_by_name=True, ) class CategoryEnum(StrEnum): dogs = auto() cats = auto() birds = auto() reptiles = auto() others = auto() class Pet(BaseSchema): id: uuid.UUID name: str category: CategoryEnum photo_urls: list[str] on_sale_from: datetime | None = None # --- Endpoints --- pets_router = APIRouter(tags=["pets"]) # ここでタグを指定する @pets_router.get("/pets", response_model=list[Pet]) def list_pets(): # ダミー実装 return [ Pet( id=uuid.uuid4(), name="doggie", category=CategoryEnum.dogs, photo_urls=["http://example.com/photo1.jpg"], on_sale_from=datetime.now(), ), Pet( id=uuid.uuid4(), name="kitty", category=CategoryEnum.cats, photo_urls=["http://example.com/photo2.jpg"], on_sale_from=datetime.now(), ), ] @pets_router.get("/pets/{petId}", response_model=Pet) def get_pet_by_id(petId: uuid.UUID): # ダミー実装 return Pet( id=uuid.uuid4(), name="doggie", category=CategoryEnum.dogs, photo_urls=["http://example.com/photo1.jpg"], on_sale_from=datetime.utcnow(), ) @pets_router.post("/pets", response_model=Pet) def add_pet(pet: Pet): # ダミー実装 return pet @pets_router.put("/pets/{petId}", response_model=Pet) def update_pet(petId: uuid.UUID, pet: Pet): # ダミー実装 return pet @pets_router.delete("/pets/{petId}") def delete_pet(petId: int): # ダミー実装 pass app.include_router(pets_router) |
FastAPI側のポイントは2点です。
1点目は、
BaseSchema
を定義し、それを継承して
Pet
を定義していることです。
BaseSchema
ではリクエスト・レスポンスでキャメルケースを受けるために必要な設定をしています。
Pythonではスネークケースを使い、TypeScriptではキャメルケースを使いたいということはよくあると思います。その問題を解決するため、このような設定をしています。
この設定をすることで、FastAPIが出力するOpenAPI上もキャメルケースになってくれるため、orvalでの生成結果もキャメルケースにすることができます。
2点目は、
APIRouter
を生成するときにタグを指定している点です。ここでタグを指定すると、
APIRouter
に紐づくAPIには同じタグが振られるようになります。
orvalは出力ファイルをタグ単位で分割することができるので、そのために指定しています。
orvalのセットアップとコード生成
まずはorvalを追加します。orvalは実行時には不要なので、devDependencies に追加するようにします。
1 | pnpm add -D orval |
次に
orval.config.ts
ファイルを作成し、設定をしていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | import { defineConfig } from "orval"; export default defineConfig({ petstore: { output: { mode: "tags-split", // タグごとにディレクトリ分割 target: "src/api/", // APIクライアントの出力先ディレクトリ mock: true, // MSWのモックを生成 override: { // 生成ファイルに eslint-disable コメントを追加 header: (info) => [ `Generated by orval 🍺`, `Do not edit manually.`, ...(info.title ? [info.title] : []), ...(info.description ? [info.description] : []), ...(info.version ? [`OpenAPI spec version: ${info.version}`] : []), "eslint-disable", ], // date/date-timeの型をDateに変換 useDates: true, // 生成される関数の名前を変更 operationName: (operation) => { if (!operation.summary) { throw new Error( "Operation summary is required for generating operation names." ); } // summaryは「Get Pet By Id」のような形式なので、キャメルケースに変換 const pascalCase = operation.summary.replace(/\s+/g, ""); return `${pascalCase.charAt(0).toLowerCase()}${pascalCase.slice(1)}`; }, }, // 生成後に Prettierでフォーマット prettier: true, }, input: { // OpenAPIの取得先 target: "http://localhost:8000/openapi.json", }, }, }); |
ここで特におすすめなのが
mode
と
operationName
の設定です。
まず、
mode
は出力するファイルをどのように分割するかを設定します。デフォルトは
single
となっていて1つのファイルに全て出力されます。
今回は
tags-split
となっていて、以下のようにタグごとにファイルが分けられます。(これが最も細かく分割されるモードです)
1 2 3 4 5 6 | my-app └── src ├── petstore.schemas.ts // APIで使用するオブジェクトやEnumの型定義 └── pets // タグごとにディレクトリが切られる ├── petstore.msw.ts // MSWモック └── petstore.ts // APIクライアント |
次に
operationName
のカスタマイズです。この設定をしない状態だと、以下のようなAPIクライアントが生成されます。
1 2 3 4 5 6 7 8 9 10 | /** * @summary Get Pet By Id */ export const getPetByIdPetsPetIdGet = <TData = AxiosResponse<Pet>>( petId: number, options?: AxiosRequestConfig ): Promise<TData> => { return axios.default.get( `/pets/${petId}`,options ); } |
関数名がとてもわかりにくいものとなってしまっています。この関数名はデフォルトでOpenAPIの
operationId
から生成されますが、FastAPIが生成するOpenAPI以下のようになっていて、かなり分かりにくいものとなっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | { "paths": { "/pets/{petId}": { "get": { "tags": [ "pets" ], "summary": "Get Pet By Id", "operationId": "get_pet_by_id_pets__petId__get", "parameters": [ { "name": "petId", "in": "path", "required": true, "schema": { "type": "integer", "title": "Petid" } } ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } } } } } } } |
このOpenAPIを眺めてみると、
summary
はFastAPIで定義した関数名が使用されていることに気が付きます。
このテキストであれば、APIクライアントの関数名に使用できそうです。ただし、このままでは使用できないため、以下のように、空白を除去し先頭を小文字にすることで、キャメルケースに変換した上で使用しています。
1 2 3 4 5 6 7 8 9 10 11 12 | // 生成される関数の名前を変更 operationName: (operation) => { if (!operation.summary) { throw new Error( "Operation summary is required for generating operation names." ); } // summaryは「Get Pet By Id」のような形式なので、キャメルケースに変換 const pascalCase = operation.summary.replace(/\s+/g, ""); return `${pascalCase.charAt(0).toLowerCase()}${pascalCase.slice(1)}`; }, |
最後に、コマンド
pnpm orval --config ./orval.config.ts
を実行すればコードが生成されます。
生成されたコードの確認
まずは
petstore.schemas.ts
を見ていきます。このファイルにはAPIで使用するオブジェクトの型やEnumが定義されています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | /* eslint-disable */ /** * Generated by orval 🍺 * Do not edit manually. * Petstore * OpenAPI spec version: 1.0.0 */ export type CategoryEnum = typeof CategoryEnum[keyof typeof CategoryEnum]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const CategoryEnum = { dogs: 'dogs', cats: 'cats', birds: 'birds', reptiles: 'reptiles', others: 'others', } as const; export interface HTTPValidationError { detail?: ValidationError[]; } export type PetOnSaleFrom = Date | null; export interface Pet { id: string; name: string; category: CategoryEnum; photoUrls: string[]; onSaleFrom?: PetOnSaleFrom; } export type ValidationErrorLocItem = string | number; export interface ValidationError { loc: ValidationErrorLocItem[]; msg: string; type: string; } |
このファイルで注目したい点は
interface Pet
の各フィールド名がキャメルケースとなっている点です。先ほど説明した通り、PydanticモデルのConnfigの設定が、FastAPIが出力するOpenAPIに反映されているため、orvalが生成するTypeSciprtの型もキャメルケースになりました。
次に、APIクライアントである
pets/pets.ts
を見ていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | /* eslint-disable */ /** * Generated by orval 🍺 * Do not edit manually. * Petstore * OpenAPI spec version: 1.0.0 */ import * as axios from 'axios'; import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { Pet } from '../petstore.schemas'; /** * @summary List Pets */ export const listPets = <TData = AxiosResponse<Pet[]>>( options?: AxiosRequestConfig ): Promise<TData> => { return axios.default.get( `/pets`,options ); } /** * @summary Add Pet */ export const addPet = <TData = AxiosResponse<Pet>>( pet: Pet, options?: AxiosRequestConfig ): Promise<TData> => { return axios.default.post( `/pets`, pet,options ); } /** * @summary Get Pet By Id */ export const getPetById = <TData = AxiosResponse<Pet>>( petId: string, options?: AxiosRequestConfig ): Promise<TData> => { return axios.default.get( `/pets/${petId}`,options ); } /** * @summary Update Pet */ export const updatePet = <TData = AxiosResponse<Pet>>( petId: string, pet: Pet, options?: AxiosRequestConfig ): Promise<TData> => { return axios.default.put( `/pets/${petId}`, pet,options ); } /** * @summary Delete Pet */ export const deletePet = <TData = AxiosResponse<unknown>>( petId: number, options?: AxiosRequestConfig ): Promise<TData> => { return axios.default.delete( `/pets/${petId}`,options ); } export type ListPetsResult = AxiosResponse<Pet[]> export type AddPetResult = AxiosResponse<Pet> export type GetPetByIdResult = AxiosResponse<Pet> export type UpdatePetResult = AxiosResponse<Pet> export type DeletePetResult = AxiosResponse<unknown> |
このファイルで注目したい点は、各関数名です。先ほど
orval.config.ts
で
operationName
の生成をカスタマイズし、OpenAPIの
summary
から生成するようにしたので、分かりやすい関数名になっています。
さいごに
FastAPI + orvalの組み合わせの場合、双方の設定をカスタムすることで実用的なAPIクライアントを生成することができました。