- FastAPI 向けのソースコードを OpenAPI Spec から生成する際、実装済み機能のコードが消えてしまわないよう、外だしにする仕組みを考えた
FastAPIとOpenAPI
先日、以下の記事で楽に自作 API を作れる FastAPI が動く環境を整備した。
kwatanabe.hatenablog.jp
さらに調べているうちに OpenAPI Spec から FastAPI 向けのコードジェネレータである「fastapi-code-generator」があることを知った。
OpenAPI ( 別名 Swagger ) は、API 仕様を策定するための規格で、この規格に準じた spec ファイルを作成するとドキュメントの自動生成や、フロントエンド、バックエンドのソースコードの自動生成ができる。

github.com
swagger.io
これはすこぶる便利だが、少し使ってみると困ったことが起こることに気づく。
OpenAPI Spec からのコード生成で困ったこと
fastapi-code-generator は OpenAPI Spec を与えると、ソースコードのスケルトンを生成してくれる。以下のように使う。
$ fastapi-codegen -i <APISpecファイル> -o <出力先ディレクトリ>
その結果、スケルトン本体の main.py
と、クラス化したパラメータを集めた models.py
が生成される。例えば、/token
への POST メソッドを用いる API を定義した OpenAPI Spec を与えると以下のようになる。
from __future__ import annotations
from typing import Optional, Union
from fastapi import FastAPI, Header
from .models import TokenPostRequest
app = FastAPI(
...(省略)...
)
@app.post('/token', response_model=TokenPostResponse)
def post_token(body: TokenPostRequest = None) -> TokenPostResponse:
"""
トークンの取得
"""
pass
ここの pass
のところに実際のAPI処理を記述していくことになるのだけども、実装した後に新たなAPIを追加して fastapi-codegen
すると、また pass
に逆戻りしてしまう。また、グローバルなスコープに別のライブラリやDBアクセスのコードを足すと、それも消えてしまう。
仕様が変更された場合には致し方ないが、全く無関係な API が追加された場合にも、手作業でコピペする羽目になるのはいただけない。main.py
にはインタフェースのみ記述して、実際の処理は別ファイルに外出ししたいし、グローバルなスコープのコードは維持したい。
コードテンプレートを使う
fastapi-code-generator では、-t
オプションでコードテンプレートを指定できる。コードテンプレートは jinja2 形式で記述し、生成するコードのフォーマットを任意に定義できる。公式のドキュメントページに使える変数の一覧と、デフォルトテンプレートが記載されている。
koxudaxi.github.io
この仕組みを使えば、グローバルなコードはテンプレートに直書きすればいいし、APIの処理ルーチンは pass
を do_post_token()
みたいなメソッドにし、実体を別ファイルに作成して import
すればよい。
何も考えずに使うと失敗する
メソッド名は operation.function_name
、引数は operation.snake_case_arguments
で取れる。そこで、デフォルトテンプレートの pass
に相当する箇所を素直に変更する。
return do_{{operation.function_name}}( {{operation.snake_case_arguments}} )
その結果は以下。
return do_post_token( body: TokenPostRequest = None )
期待しているモノと違う。operation.snake_case_arguments
は、型ヒントやデフォルトパラメータまで含まれる様子。リクエストヘッダが付くと更に複雑になる。
return do_post_token(
authorization: str = Header(..., alias='Authorization'),
body: TokenPostRequest = None
)
このままでは Syntax Error で動かなくなる。
jinja2の中で引数をパースする
素直に使うと失敗するので、変数名だけ抜き出してくる必要がある。単純に ,
で split すると、ヘッダの Header(...,
が悪さをしてうまく拾えない。力業だけど以下の方法で回避する。
,
で split してイテレーションする
- 要素に
:
が含まれるか調べ、無ければ次の要素に移る
- 要素に
:
が含まれるなら :
で split して第一要素を変数名として扱う
実装するとこんな感じ。
return do_{{operation.function_name}}(
{% for arg in operation.snake_case_arguments.split(',') %}
{% if ':' in arg %}
{{ arg.split(':')[0] }}={{ arg.split(':')[0] }},
{% endif %}
{% endfor %}
)
先の例だとこんな感じになる。
return do_get_token(
authorization=authorization,
body=body,
)
実体を作成する
後は、処理ルーチンの実体を実装する。例えば handler.py
として以下のように用意する。
from models import *
def do_get_token (**kwargs):
return TokenPostResponse( token = 'XXXX' )
上記の例では、手抜のために可変長引数をつかっている。もちろん、下記のように真面目に書いてもいい。
from models import *
def do_get_token (authorization, body):
return TokenPostResponse( token = 'XXXX' )
そしてこれを import
するコードをテンプレートの冒頭に追記する。
from handler import *
この from~
と同じように、DBアクセスなどのグローバルなコードがあるのなら、テンプレートの任意の箇所に直接 Python コードを記述する。
検証
検証環境
テンプレート
templ/main.jinja2
を以下のように作成。殆どは、デフォルトのテンプレートのまま。
from __future__ import annotations
from fastapi import FastAPI
{{imports}}
from handler import *
app = FastAPI(
{% if info %}
{% for key,value in info.items() %}
{{ key }} = {{ value }},
{% endfor %}
{% endif %}
)
{% for operation in operations %}
@app.{{operation.type}}('{{operation.snake_case_path}}', response_model={{operation.response}})
def {{operation.function_name}}({{operation.snake_case_arguments}}) -> {{operation.response}}:
{%- if operation.summary %}
"""
{{ operation.summary }}
"""
{%- endif %}
return do_{{operation.function_name}}(
{% for arg in operation.snake_case_arguments.split(',') %}
{% if ':' in arg %}
{{ arg.split(':')[0] }}={{ arg.split(':')[0] }},
{% endif %}
{% endfor %}
)
{% endfor %}
----- 2021/12/13 21:50 追記 -----
ドキュメントページのサンプルにあるものをそのまま使うと、AttributeError
が起こる。理由は FastAPI
コンストラクタを定義する以下の箇所。
{{ key }} = "{{ value }}",
テンプレート指定なしでコードを生成すると、この value
はリストとなるところらしく "
で囲って str にしてしまうと処理に失敗する。なので、以下が正しい。
{{ key }} = {{ value }},
----- 追記終わり -----
----- 2023/09/13 21:50 追記 -----
上記の場合、title
や description
に多バイト文字がある場合などで SyntaxError
などが生じる場合がある。文字列は "
囲うために以下とする方がよい。
{% for key,value in info.items() %}
{% if value is string %}
{{ key }} = "{{ value }},"
{% else %}
{{ key }} = {{ value }},
{% endif %}
{% endfor %}
----- 追記終わり -----
処理の実態を記述した外部ファイル
from models import *
def do_get_token (**kwargs):
return TokenPostResponse(token = 'XXXX' )
コードを生成
$ fastapi-codegen -t ./templ -i <OpenAPI Spec> -o .
生成されたコード
main.py
from __future__ import annotations
from typing import Optional, Union
from fastapi import FastAPI, Header
from handler import *
from .models import TokenPostRequest, TokenPostResponse
app = FastAPI(
...(省略)...
)
@app.post('/token', response_model=TokenPostResponse)
def get_token(body: TokenPostRequest = None) -> TokenPostResponse:
"""
トークンの取得
"""
return do_get_token(
body=body,
)
models.py
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class TokenPostRequest(BaseModel):
name: str = Field(..., description='username')
key: str = Field(..., description='password')
class TokenPostResponse(BaseModel):
token: str = Field(..., description='token')
動作確認
Gunicorn/Uvicorn で動かして、curl で叩いてみる。
$ curl -s -X POST http://localhost:8000/token
{"token":"XXXX"}
うん。とりあえず動いている感じ。