API仕様と実装の乖離を防ぐ — datamodel-code-generatorによるスキーマ駆動開発
はじめに
こんにちは、大阪大学 量子情報・量子生命研究センターの宮永(@orangekame3)です。
本記事では、datamodel-code-generatorとFastAPIを使ったAPIサーバーの開発とTipsについて紹介します。
本記事のサンプルコードは以下のリポジトリにあります。適宜参照してください。
ドキュメントとコード、どちらを起点にするか?
APIサーバーの開発においてつきまとう問題の一つに、APIのドキュメントとコードベースの乖離があります。これは、APIの仕様が変更された際に、ドキュメントとコードの両方を修正する必要があるためです。
通常、こうした問題を解決するために以下のいずれかの方法が取られます。
- ドキュメントからコードを生成する
- コードからドキュメントを生成する
- 人力で頑張る
以上の方法のうち、どの手法を採用するかはプロジェクトの規模や開発スタイルによって異なります。私の参加するプロジェクトでは、以下の要請があったため、ドキュメントからコードを生成する方法を採用しました。
- マイクロサービスでの開発であるため、APIの仕様書を優先して作成する必要があった
- 仕様書自体はある程度完成した状態であったため、それを元にAPIサーバーを開発する必要があった
- OpenAPIのスキーマを制限なく使いたかった(コードからOpenAPIを生成する方法では表現に制限が出る場合がある)
特に1.2.の要請があったため、ドキュメントからコードを生成する方法が適していると判断しました。また、スキーマを先に作成しておくことでロジックの実装に集中することができ、生成されたリクエスト・レスポンスの型を使うことで、型ベースの開発を行うことができます。加えて、先に型を定義しておくことは生成AI時代の昨今の開発状況を考えると、コード補完などで恩恵を受けやすくなると考えられます。今回は、このような背景からdatamodel-code-generatorを使ったAPIサーバーの開発を行いました。
datamodel-code-generatorとは
datamodel-code-generatorは、Pydantic modelやdataclasses.dataclassを生成するツールです。このツールを使うことで、OpenAPIからクラスを自動生成することができます。スキーマを生成するだけなのでAPIサーバーの実装からは分離されています。今後datamodel-code-generatorの利用をやめたとしてもサーバーの開発には影響が少ない点も魅力です。
具体的な使い方は本家リポジトリに紹介があるのでそちらを参照してください。
FastAPIの選択
datamodel-code-generatorを使うことでFastAPIの仕様書自動生成機能を利用しなくなったわけですが、FastAPIはネイティブにPydanticをサポートしているため、依然、datamodel-code-generatorとの親和性が高いです。
インターネットにはFastAPIの参考事例が多くありますので、その点においても開発の効率を高めることができます。
datamodel-code-generatorを使った開発の流れ
datamodel-code-generatorを使った開発は以下の手順で行いました。
- OpenAPIでスキーマを定義
- datamodel-code-generatorを使ってPydantic modelを生成
- FastAPIを使ってAPIサーバーを実装
この手順で開発を行うことで、APIの仕様変更に伴うコードの修正を最小限に抑えることができました。
以下、datamodel-code-generatorを使った開発のTipsを紹介します。
datamodel-code-generatorを使った開発のTips
OpenAPIのスキーマを分割する
ドキュメントからコードを生成する際にボトルネックとなるのが、ドキュメントを手動で管理することです。特に、スキーマが大きくなると、管理が煩雑になりがちです。
そこで、スキーマを分割することで、メンテナンスの負荷を下げる工夫をしました。
分割したOpenAPIはRedoclyCLIを使って単一のyamlファイルに統合できます。
例えば、以下のようにファイルを分割することで、管理しやすくしました。
tree
.
├── openapi.yaml
├── paths
│ └── hello.yaml
├── root.yaml
└── schemas
├── error.yaml
└── hello.yaml
このときroot.yamlはすべてのエンドポイントをまとめる親ファイルとなるため以下のようになります。
openapi: 3.0.1
info:
title: Web API
version: '0.1'
description: simple web api
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
servers:
- url: http://localhost:8080
description: Local server url
paths:
/hello/{name}:
$ref: ./paths/hello.yaml#/hello.name
こうすることで新たにエンドポイントを追加する際は、pathsディレクトリにyamlファイルを追加するだけで済むため、長大なyamlファイルを目grepする必要がなくなります。
修正を加える際も差分が明確になりレビュアーの負担を減らすことができます。
各エンドポイントのリクエストとレスポンスの定義をpathsで指定されたファイルに記述します。以下は
paths:
/hello/{name}:
$ref: ./paths/hello.yaml#/hello.name
のサンプルコードです。
hello.name:
get:
tags:
- hello
summary: say hello
description: Returns a hello message
operationId: sayHello
security: []
parameters:
- in: path
name: name
description: name
required: true
schema:
type: string
example: 'world'
responses:
'200':
description: Returns a hello message
content:
application/json:
schema:
type: array
items:
$ref: "../schemas/hello.yaml#/hello.SayHelloResponse"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: '../schemas/error.yaml#/error.UnauthorizedError'
example:
message: Unauthorized
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '../schemas/error.yaml#/error.InternalServerError'
responsesで参照しているスキーマはschemasディレクトリに記述されています。ここで定義しているスキーマはdatamodel-code-generatorでPydantic modelを生成する際に使用されます。
次に、スキーマの定義について確認します。以下は../schemas/hello.yaml
のサンプルコードです。
hello.SayHelloResponse:
type: object
properties:
message:
type: string
description: hello message
example: 'hello world'
required:
- message
この時、hello.{SchemaName}
のようにスキーマを分割していることがポイントです。このように管理することでdatamodel-code-generator側でhello.pyファイルに生成コードが集約されます。SchemaName
のみの場合は単一のファイルにすべてのスキーマがまとめられます。
スキーマ名とフィールド名ですが、Pythonでバックエンドを実装することを念頭において、Classとなるスキーマ名はUpperCamelCase、フィールド名はsnake_caseで記述するようにルールを設けました。では、分割されたyamlファイルをもとopenapi.yamlを生成します。OpenAPIのファイルを操作するツールはいくつかありますが、ここではRedoclyCLIを使って統合します。
docker pull redocly/cli
docker run --rm -v $(PWD):/spec redocly/cli bundle root.yaml -o openapi.yaml
コード生成時には以下のコマンドを使って生成します。
datamodel-codegen \
--use-schema-description \
--target-python-version 3.12 \
--field-constraints \
--use-annotated \
--use-field-description \
--input openapi.yaml \
--input-file-type openapi \
--collapse-root-models \
--output-model-type pydantic_v2.BaseModel \
--disable-timestamp \
--use-standard-collections \
--strict-nullable \
--use-default \
--output gen/schemas
例えば上記のようなopenapi.yamlをもとにコードを生成すると以下のようなPydantic modelが生成されます。
# generated by datamodel-codegen:
# filename: openapi.yaml
from __future__ import annotations
from typing import Annotated
from pydantic import BaseModel, Field
class SayHelloResponse(BaseModel):
message: Annotated[str, Field(examples=["hello world"])]
"""
hello message
"""
各エンドポイントの実装をする際はここで生成したスキーマをリクエストとレスポンスとして利用します。では、実際に生成されたスキーマを使ってFastAPIのエンドポイントを実装します。
以下のディレクトリ構造でFastAPIのエンドポイントを実装します。
tree
.
├── __init__.py
├── main.py
├── routers
│ ├── __init__.py
│ └── hello.py
└── schemas
├── __init__.py
├── error.py
└── hello.py
main.pyは以下のようになります。
"""Entry point of the development application
This module is the entry point of the development application. It creates the FastAPI
"""
from fastapi import FastAPI
import uvicorn
from .routers import hello as hello_router
app: FastAPI = FastAPI()
app.include_router(
hello_router.router,
tags=["hello"],
)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug")
各エンドポイントはrouterディレクトリに実装します。以下はhello.pyのサンプルコードです。
from fastapi import APIRouter, HTTPException
from ..schemas.hello import SayHelloResponse
from ..schemas.error import InternalServerError
router: APIRouter = APIRouter()
@router.get("/hello/{name}", response_model=SayHelloResponse)
def say_hello(name: str) -> SayHelloResponse:
try:
return SayHelloResponse(message=f"Hello, {name}!")
except Exception as e:
raise HTTPException(
status_code=500,
detail=InternalServerError(message=str(e)).model_dump()
)
ここでdatamodel-code-generatorで生成したスキーマを利用しています。このようにスキーマを先に定義しておくことで、ドキュメントの乖離を防ぐことができ、ロジックの実装に集中することができます。また、FastAPIではPydantic modelをネイティブにサポートしており、バリデーションなどはPydantic modelの機能を活用することで冗長なコードを書くことなく実装することができます。
--disable-timestampを使う
datamodel-code-generatorにコード生成時にいくつかのオプションを指定できます。デフォルトではコード生成の度にファイルの先頭にtimestampが書き込まれる仕様となっているのですが、コードレビュー時に不要な差分が生まれるため、私はこのオプションを有効化しています。
コードはGit管理しているので、コミットログから更新履歴を確認することができます。このオプションを有効化しても特に困らないという判断です。
OpenAPIの記法について、ルールを明文化する
これは本件に関わらず大事なことですが、策定したルールはきちんとドキュメント化しておくことが重要です。OpenAPIの分割手法や、Schemaの定義方法について紹介しましたがこうしたルールを明文化することで後のプロセスをスムーズに進めることができます。私は各リポジトリ毎にMkDocsを使ってドキュメントを作成してGitHub PagesやRead the Docsで開発ドキュメントを公開するようにしています。
まとめ
本記事ではdatamodel-code-generatorとFastAPIを使ったAPIサーバーの開発とTipsについて紹介しました。OpenAPIをベースにしたスキーマ駆動開発はAPIの仕様変更に伴うコードの修正を最小限に抑えることができるため、中~大規模なプロジェクトにおいて有用です。また、datamodel-code-generatorを使うことで、型ベースの開発を行うことができ、開発効率を向上させることができます。
本記事を通じて、PythonにおけるAPIサーバーの開発の選択肢の一つとしてdatamodel-code-generatorを使った開発を検討していただければ幸いです。
Discussion