FastAPI で独自に定義した API エラーも仕様書に自動反映したかった話

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 最近は FastAPI という Python の Web フレームワークが社内で密かなブームとなっています。 今回はその FastAPI を使ったエラー定義まわりの話をしたいと思います。

FastAPI とは

FastAPI の概要については先日ちょうど社内勉強会用に資料を作ったのでこちらを見てもらえるのが早いです。 ざっくり言えばシンプルなインターフェースとドキュメント(OpenAPI)の自動生成が強力なフレームワークになります。

OpenAPI でドキュメント管理

今回注目したいのはドキュメントの自動生成のほうです。 開発チームでもドキュメントとAPIの実際の仕様が一致しない問題が時々発生していて、どうドキュメントを管理していくかが課題となっています。 KPTで振り返った結果、「人がドキュメントを書くからメンテが大変なのであって、ソースから仕様書が動的に生成されれば問題は解消するのではないか」という希望から FastAPI へと焦点があたりました。

このフレームワークでは入力(リクエスト)と出力(レスポンス)の形式を Python のモデルとして記述してあげることで、自動で OpenAPI 形式のドキュメントを出力してくれます。 OpenAPI はツールが充実しており、APIを試験的に叩くための Web コンソールを利用できたり、様々な言語から API へアクセスするときのソースを自動生成をしたりできます。

ちょうど小さめの開発プロジェクトが立ち上がったので、検証かねて FastAPI で作ってドキュメントが実用に耐えうるのかやってみました。

ちなみに、FastAPI は WSGI ではなく ASGI のフレームワークなので AWS のサーバレス構成で動かせるかは懸念だったのですが、ちょうどSREチームのたっちさんが AWS Lambda で動かすサンプルを検証してくれており、スムーズにプロジェクトに投入できました。

エラーレスポンス問題

実際 API を作って、フロントの開発メンバーに生成された OpenAPI を仕様書として渡してみたところ、正常系に関してはうまく機能することができました。 しかし どんなエラーをハンドリングしなくてはいけないのか という点に関しては生成された OpenAPI では判別できず、別途仕様を共有する必要がありました。

デフォルトだと正常系の 200 のドキュメントと、リクエストが指定したモデルと合致しているかの 422 エラーしか OpenAPI に記載されていませんでした。 認証が不正なのか、渡してるパラメータがおかしいのか、それとも権限・状態がおかしいのかという情報はクライアント側の挙動を適切にするためにも不可欠なものです。 5xx 系のサーバー内部のエラーは一旦置いておくとしても、4xx 系のエラーについてはドキュメントとして明記しておきたいです。

独自エラーを OpenAPI に記載する

FastAPI のドキュメントを調べてみたところ、Python の Exception をそのままレスポンスとして登録する手段は見つからず、responses という引数に OpenAPI の定義を付加情報として加えてあげる必要がありました。

# https://fastapi.tiangolo.com/advanced/additional-responses/ より
@app.get("/items/{item_id}", response_model=Item, responses={404: {"model": Message}})
    async def read_item(item_id: str):
    ...

responses は辞書になっていて、それぞれのステータスコードをキーに、レスポンスとなるモデルを記述(もしくは OpenAPI の 構造をそのまま記述)することで、生成されるドキュメントにエラー情報を付与することができます。

ただ実際のユースケースとして、

  • エラーレスポンスのデータ構造はほぼ共通で変わることがない
  • エラーで重要なのは構造よりエラーの中身
  • HTTP ステータスコードとしては同じだがエラーの理由が異なるケースがある

といったことがあげられます。

OpenAPI はインターフェースを記述するためのドキュメントなので、エラー内容まで関与しないというのは正しいのですが、そうするとエラー管理のための別のドキュメントを用意しなくてはなりません。 当初の目的としては API ドキュメントを1つにまとめて楽に管理することなので、なんとかこの OpenAPI 上でエラーの種別まで列挙するようにしたいです。

実際にやってみた

やっと本題になります。

上記のエラーの種別の列挙までを FastAPI で実現できないかをやってみました。

f:id:nsmr_jx:20200615134744p:plain

実現したい API ドキュメントとしては上記のようなイメージになります。

応答するステータスコードが列挙され、200以外のときはそのレスポンスの内容も網羅されているのが理想的です。 こうすることで API を叩く側がどんなエラーをハンドリングしなくてはならないのかが明確になり、連携を円滑に進めることが出来ます。 (HTTPステータスコードとは別にエラーコードを定義するのもよさそうです。)

併せて、Python 側のコードも複雑になりすぎないということを目標とします。 Open API の定義をそのままコードに埋め込めてしまうので、愚直に返しうるエラーを文字列で手動編集する、みたいなことは避けたいです。 認証のエラー文言が変わる度、全部のビューを編集するのはつらい…。

エラーを定義

まずは Python で API 独自のエラーを定義していきます。

# https://github.com/pistatium/fastapi_error_sample/blob/master/api/errors.py

class ApiError(Exception):
    """ エラーの基底となるクラス """
    status_code: int = 400
    detail: str = 'API error'  # エラー概要


class DontSetDummyParameter(ApiError):
    status_code = 403
    detail = 'ここに値をセットしないでください'


class InvalidFizzBuzzInput(ApiError):
    status_code = 400
    detail = 'input の値が不正です'


class WrongFizzBuzzAnswer(ApiError):
    status_code = 400
    detail = '不正解です'

ApiError という共通のエラーを定義してあげて、これを継承して具体的なエラーを列挙していきます。 同じエラーは同じ HTTP ステータスコードを吐くので、ここで一緒に定義してしまいます。

View でエラーを使う

# https://github.com/pistatium/fastapi_error_sample/blob/master/api/main.py

@app.post("/check_fizzbuzz", response_model=FizzBuzzRequest)
def check_fizzbuzz(req: FizzBuzzRequest):
    if req.dummy:  
        raise DontSetDummyParameter()  # # このパラメータを設定していたら強制的にエラーを発生。※実際は validator でやると綺麗
    try:
        expected = fizzbuzz(req.input)
    except ValueError:
        raise InvalidFizzBuzzInput()  # input の入力が不正であればエラー。※実際は validator でやると綺麗
    if expected != req.answer:
        raise WrongFizzBuzzAnswer()  # input と回答がマッチしなければエラー
    return FizzBuzzResponse(message='Good!')

例外を定義したら、FastAPI の View の中でその例外を利用するようにします。 ApiError を継承したエラーは HTTP のステータスコードをもってるので、View 関数の中でのみ利用するようにするとエラーの管理が簡単です。

エラーハンドラを登録する

@app.exception_handler(ApiError)
async def api_error_handler(request, err: ApiError):
    raise HTTPException(status_code=err.status_code, detail=f'{err.detail}')

独自のエラーを勝手に定義して raise するだけだと FastAPI がエラーとして認識できないので、500 エラーになってしまいます。 なので、FastAPI が ApiError を扱えるようエラーハンドラを追加します。 ここでは FastAPI の HTTPException にあわせて再度 raise しています。 こうすることで FastAPI 内部のエラーとも構造を共通化できるため、インターフェースとしてすっきりします。 もちろん独自の構造を定義して返してあげてもよいです。 HTTP ステータスコードとは別にユニークなエラーコードを振って返すようにすると、クライアントでもハンドリングしやすそうです。

ドキュメントにエラーを登録する

エラーハンドラを登録することでアプリケーションとしては問題なく動くのですが、OpenAPI の仕様書にはまだエラー情報が反映されてません。 自動でエラーを登録できると理想的なのですが、コードを静的に解析したりしない限りはどんな例外が発せられるかは把握できません。 なので半手動でエラーレスポンスを列挙することにします。

def error_response(error_types: List[Type[ApiError]]) -> dict:
    # error_types に列挙した ApiError を OpenAPI の書式で定義する
    d = {}
    for et in error_types:
        if not d.get(et.status_code):
            d[et.status_code] = {
                'description': f'"{et.detail}"',
                'content': {
                    'application/json': {
                        'example': {
                            'detail': et.detail
                        }
                    }
                }}
        else:
            # 同じステータスコードなら description へ追記
            d[et.status_code]['description'] += f'<br>"{et.detail}"'
    return d

このような ApiError を OpenAPI の書式に変換するユーティリティ関数を用意してあげます。 同じ HTTP ステータスコードのものが複数あると上書きされてしまうため、プログラム的に重複を確認し、ぶつかっていれば detail への追記を行うだけにしています。

そして先ほどの View 関数のルーティング部分を

@app.post("/check_fizzbuzz", response_model=FizzBuzzRequest,
          responses=error_response([DontSetDummyParameter, InvalidFizzBuzzInput, WrongFizzBuzzAnswer]))  # 追加
def check_fizzbuzz(req: FizzBuzzRequest):
   ...

のように変更します。 View 内で raise している ApiError をあつめて、error_response ユーティリティ関数へ渡しているだけです。 View の中で扱うエラーが増減したときは忘れずに反映する必要はありますが、View 以外で ApiError を発生させないみたいなルールとともに実装すれば管理しやすいかと思われます。

これにより、OpenAPI へのエラー定義を統一して扱えるようにできました。

OpenAPI で表示

FastAPI を動かしてみると上記のような OpenAPI の定義を生成することが出来ます。 開発サーバーの /docs にアクセスして UI ごと表示してもいいですし、 Python から定義を JSON として出力し外部のツールから利用することも可能です。

Swagger の Editor を利用するとこのような Web コンソールを出力できます。

GitLab だとデフォルトで OpenAPI に対応しているので、openapi.json をレポジトリに入れておくだけで勝手に UI 付きで表示してくれるので便利です。

おわりに

FastAPI を利用して API エラーも含めてドキュメント化することができました。 管理のコストを下げつついい感じの仕様書をつくれるので便利ですね。 コーディングする上でもはまりどころが少なく、FastAPI もっと活用していきたいと思いました。

説明に利用したコードのサンプルはこちら。