この記事に書いてあること
- 今すぐpydantic v2 + fastapi v100を使うため、アドホックにOAS3.0.xなopenapi.jsonを吐けるようにする話
この記事に書いてないこと
- pydantic v2の変更点
- JSON SchemaとOASの細かいバージョン差異
前置き
ついにpydantic v2がリリースされました!
パフォーマンスの向上も勿論ですが、それ以外にもrequiredとnullableの関係が整理されたり, namespaceが整理されたりと、便利な変更が盛り沢山です。
fastapiも早速v2対応をしたv0.100をリリースしているので、さて移行するぞ!と、行きたいところなのですがここで問題があります。
JSON SchemaとOpenAPIの差分に関する問題です。
ざっくりOASとJSON Schemaの差分について整理すると
- おそらく一番メジャーなopenapi code generatorであるopenapi-generatorはOAS3.0までしか対応していない(generatorの種類も多いしOAS3.1対応はまだかかりそう…)
- OAS3.0.xはJSON Schema Draft 5(JSON Schema Specification Wright Draft 00)のサブセットを使っている
- pydantic v1ではJSON Schema Draft 7を採用していたが、pydantic v2ではJSON Schema Draft 2020-12をターゲットにしている
- OAS3.1はJSON Schema Draft 2020-12準拠になる
ということで、要はpydantic v2の吐くschemaはOAS3.1でないと読めないという問題が発生します。
本題
手元で触っていて困ったのは
- Literalがv1ではstringのenumだったが、v2ではconstになる
- T | Noneがv1ではtype: T + nullable: trueだったが、v2ではanyof[{ type: T }, { type: null }]になる
の2点です
1は
class A(BaseModel):
a: Literal['a']
がv1では
{"title": "A", "type": "object", "properties": {"a": {"title": "A", "enum": ["a"], "type": "string"}}, "required": ["a"]}
からv2では
{"properties": {"a": {"const": "a", "title": "A"}}, "required": ["a"], "title": "A", "type": "object"}
になるという問題です
2は
class B(BaseModel):
b: int | None
がv1では
{"title": "B", "type": "object", "properties": {"b": {"title": "B", "type": "integer"}}}
からv2では
{"properties": {"b": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "B"}}, "required": ["b"], "title": "B", "type": "object"}
になります。
アドホックな解決策
ということで上記スキーマをv2の方からv1の方に変換するためにFastAPI.openapi
を無理矢理上書きします。
from typing import Any, Callable
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
def _override_schema(schema: dict[str, Any]) -> dict[str, Any]:
new_schema = schema
if next((x for x in schema.get("anyOf", []) if x.get("type", None) == "null"), None) and len(schema.get("anyOf", [])) == 2:
new_schema = {
**next((x for x in schema["anyOf"] if x.get("type", None) != "null")),
"nullable": True,
}
if schema.get("title", None):
new_schema["title"] = schema["title"]
schema = new_schema
elif schema.get("const", None) is not None:
new_schema = {
"type": "string",
"enum": [schema["const"]],
}
if schema.get("title", None):
new_schema["title"] = schema["title"]
schema = new_schema
else:
schema = new_schema
return schema
def _override_openapi_schema(openapi_schema: dict[str, Any]) -> dict[str, Any]:
for key in openapi_schema["components"]["schemas"]:
openapi_schema["components"]["schemas"][key]["properties"] = {
key: _override_schema(value) for key, value in openapi_schema["components"]["schemas"][key]["properties"].items()
}
for path in openapi_schema["paths"]:
for method in openapi_schema["paths"][path]:
if openapi_schema["paths"][path][method].get("parameters", None):
openapi_schema["paths"][path][method]["parameters"] = [
{
**parameter,
"schema": _override_schema(parameter["schema"]),
} for parameter in openapi_schema["paths"][path][method]["parameters"]
]
return openapi_schema
app = FastAPI(title="app_name", version="app_version")
def gen_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title="app_name",
version="app_version",
routes=app.routes,
openapi_version="3.0.2",
)
app.openapi_schema = _override_openapi_schema(openapi_schema)
return app.openapi_schema
app.openapi = gen_openapi
というような感じで、上記2つの問題を無理矢理変換することでOASv3.0まで対応のcode generatorでもpydantic v2 + fastapi v0.100を使うことができるようになります。
手元で詰まったのはこの2点のみでしたが、仕様上は他にも変換するべき項目があると思うので(OASが対応していないkeywordはこの辺参照)、見つけたら是非教えてください。