kWatanabe 記事一覧へ

kWatanabe の 技術帖

某企業でOSや仮想化の研究をやっているインフラ系エンジニア。オンプレとクラウドのコラボレーションなど、興味ある技術を綴る。

fastapi-code-generatorで任意のフォーマットのコードを生成する

  • 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の処理ルーチンは passdo_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(..., が悪さをしてうまく拾えない。力業だけど以下の方法で回避する。

  1. , で split してイテレーションする
  2. 要素に : が含まれるか調べ、無ければ次の要素に移る
  3. 要素に : が含まれるなら : で 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 追記 -----

上記の場合、titledescription に多バイト文字がある場合などで 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"}

うん。とりあえず動いている感じ。