
FastAPIで生成されるOpenAPIスキーマを修正する
こんにちは!新規事業統括部のこーすけです。
FastAPIで生成されるOpenAPIスキーマを修正したい事案があったのでその備忘録になります。
モチベーションは、FastAPI+React+Typescriptで開発を行っている中で、FastAPIが出力するOpenAPIスキーマを、@hey-api/openapi-tsを使ってTypescriptの型を自動生成する際に、表現したい型を生成できなかったことです。どのようにすれば実現できるかを調査した結果をまとめました。
前提知識
OpenAPIとは
HTTP API を記述するための標準仕様で、エンドポイントやリクエスト・レスポンスの形式を統一的に表現できます。これにより、APIドキュメントの自動生成や、エンジニア間の知識の共有化が容易になり、効率よく開発ができるようになります。
FastAPIとは
Python製の高速な Web フレームワークで、Python標準の型ヒントを活用した自動バリデーションや自動ドキュメント生成が強みです。開発したAPIのエンドポイントや入出力といった仕様をドキュメントに書き起こさなくてよいのは非常に便利です。OpenAPIに準拠したAPIスキーマファイルを生成できます。
@hey-api/openapi-tsとは
OpenAPI のスキーマから TypeScript の型やクライアントコードを自動生成するライブラリです。自動生成ライブラリにはほかにもいくつかありますが、比較的新しく、開発が活発なライブラリです。MITライセンスのため無料で使用可能です。
FastAPIが生成するOpenAPIスキーマを基に@hey-api/openapi-tsでTypescriptの型を生成できます。これによりバックエンドとフロントエンドで型を安全に共有でき、実装の重複や齟齬を防ぎながら効率的に開発を進められます。
困っていること
FastAPIで入出力やデータ型の定義をする際は、PydanticのBaseModelを継承して作成するのが一般的だと思います。
例えば、Userモデルで name
を「デフォルトは None
のOptional
なフィールド」にしたいとします。
from typing import Optional
from pydantic import BaseModel, Field
class User(BaseModel):
name: Optional[str] = Field(default=None)
OpenAPI スキーマを確認してみる
FastAPI はこのモデルをOpenAPIスキーマへ変換します。生成されたopenapi.json
を確認すると、name
フィールドは次のように出力されていました。
"User": {
"properties": {
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name"
}
},
"type": "object",
"title": "User"
},
変換結果
そして、このスキーマを@hey-api/openapi-tsでTypeScriptに変換すると以下のようになります。
@hey-api/openapi-tsの使用方法については上記のブログに記載していますのでご覧下さい。
export type User = {
name?: string | null;
};
ここで問題になるのが null
が必ず含まれてしまう点です。
今回、実現したかった事は、
- バックエンドでは「デフォルト値は
None
」にする - フロントエンドでは
string | undefined
にしたい、つまりname?: string
としたい
ということですが、 FastAPI → OpenAPI の変換過程で 必ず null
を許容する型に展開されてしまうため、最終的に TypeScript 側でも string | null
が混ざってしまうという問題がありました。
原因と解決策
原因は、FastAPIがOptional[str] = Field()
を 「null を必ず許可するスキーマ」 として出力してしまう点にあります。
これはどういうことかというと、Optional
なフィールドとして定義した場合、自動生成されるOpenAPIスキーマにはanyOfで{ "type": "null" }
が入り込んでしまうため、変換されたTS型では T | null
という型になってしまうのです。
"User": {
"properties": {
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name"
}
},
"type": "object",
"title": "User"
},
この問題は FastAPI が自動で生成する OpenAPI スキーマをカスタマイズして、不要なnull
をスキーマから取り除くことで解決できます。
FastAPI では app.openapi()
をオーバーライドすることで、生成されるスキーマを加工することが可能です。以下ではOpenAPIスキーマ中にある不要なnullを取り除くカスタム関数を作成し、オーバーライドしました。
def remove_null_in_anyof(schema: dict) -> dict:
"""
OpenAPIスキーマのanyOfに含まれる {"type": "null"} を除去する。
再帰的に探索して修正する。
"""
if isinstance(schema, dict):
# anyOfをチェック
if ("anyOf" in schema) and (isinstance(schema["anyOf"], list)):
# null以外を残す
non_null = [s for s in schema["anyOf"] if s.get("type") != "null"]
if len(non_null) == 1:
schema.clear()
schema.update(non_null[0])
else:
schema["anyOf"] = non_null
# 各キーを再帰的に処理
for key, value in schema.items():
if isinstance(value, (dict, list)):
schema[key] = remove_null_in_anyof(value)
elif isinstance(schema, list):
schema = [remove_null_in_anyof(item) for item in schema]
return schema
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
# nullを含むanyOfを全て探索&nullを除去
openapi_schema = remove_null_in_anyof(openapi_schema)
app.openapi_schema = openapi_schema
return app.openapi_schema
app = FastAPI(
title="Mock Backend",
description="Backend API",
version="0.0.1"
)
# オーバーライドする
app.openapi = custom_openapi
このようにapp.openapi()をカスタム関数でオーバーライドすることで、自由に整形することが可能です。生成されたOpenAPIスキーマはこのようになり、{ "type": "null" }
を取り除くことができました。
"User": {
"properties": { "name": { "type": "string" } },
"type": "object",
"title": "User"
},
@hey-api/openapi-tsによって生成される型はこのようになり、今回実現したかったことを達成できました。
export type User = {
name?: string;
};
まとめ
困っていた点を改めてまとめると、
- Pydanticで
Optional[str] = Field(default=None)
を書くと、OpenAPI スキーマがanyOf [string, null]
に展開されてしまう。 - そのため、@hey-api/openapi-ts が生成する型は
string | null
またはstring | null | undefined
となり、 - 「バックエンドではデフォルト None、フロントでは
string | undefined
」という型設計ができなかった。
解決までのアプローチとしては、
- 原因 : FastAPI が Optional を「null を許す」仕様でスキーマに変換している。
- 対応 :
app.openapi()
をオーバーライドし、スキーマから不要な{ "type": "null" }
を再帰的に取り除く関数を実装した。 - 結果 : OpenAPI スキーマが
"type": "string"
に修正され、@hey-api/openapi-ts 側ではname?: string
(string | undefined
) が正しく生成されるようになった。
以上です!カスタム関数を作成すればほかにも様々な問題に対応できそうですね。