FastAPIとOpenAPI
先日、以下の記事で楽に自作 API を作れる FastAPI が動く環境を整備した。
さらに調べているうちに OpenAPI Spec から FastAPI 向けのコードジェネレータである「fastapi-code-generator」があることを知った。
OpenAPI ( 別名 Swagger ) は、API 仕様を策定するための規格で、この規格に準じた spec ファイルを作成するとドキュメントの自動生成や、フロントエンド、バックエンドのソースコードの自動生成ができる。
これはすこぶる便利だが、少し使ってみると困ったことが起こることに気づく。
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 形式で記述し、生成するコードのフォーマットを任意に定義できる。公式のドキュメントページに使える変数の一覧と、デフォルトテンプレートが記載されている。
この仕組みを使えば、グローバルなコードはテンプレートに直書きすればいいし、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"}
うん。とりあえず動いている感じ。