ネットワークサービス部の田上(id:rtagami)です。 現物合わせのための試作や3Dプリンタの調整をしているとフィラメントが想定よりも早いペースで消費されていくので困っています。
この記事では、OAS(OpenAPI Specification)とは何かをご紹介した上で、 わたしたちがOASを生成する理由をご説明します。 また、OASをファイルとして生成してリポジトリにコミットし、 それを常に最新の状態を保つサンプルコードを実装します。
なお、前回の記事は 【CI戦術編 その7】 pyupgradeを使って最新の記法に対応してみた でした。
OAS(OpenAPI Specification)とは
OAS(OpenAPI Specification)とは、RESTFul APIの仕様を記述するドキュメントの仕様です。 以下では便宜上、OASの仕様に則って書かれたドキュメント自体をOASと呼ぶことにします。
OASを読めばプロダクションコードを読まなくても、 以下のような情報がひと目でわかります。
- どのようなエンドポイントがあるか
- どのようにリクエストすればよいか
- レスポンスをどのように解釈すればよいか
OASの実体はJSONあるいはYAMLなどの構造化されたテキストファイルであり、 機械だけでなく人間も読み書きできますし、 ビューアーを通して見ることで体裁の整ったドキュメントにもなります。 特定のプログラミング言語に依存していませんので、 サーバ側と違う言語でクライアントを実装する場合でも問題ありません。
エコシステムも発展しており、 OASを使うことでさまざまなツールからのサポートを受けることができます。 以下はエコシステム内のツールが果たしてくれる役目の例です。
具体的な一覧は OpenAPI Tooling をご参照ください。
わたしたちがOASを生成する理由
連載初回の 記事 でご紹介した通り、 わたしたちは プライベートブリッジ を支えるバックエンドサービスを開発しています。 このバックエンドサービスは、 別のチームが開発しているアプリケーションから呼び出されたり、 わたしたちが開発しているWebフロントエンドから呼び出されたりします。
OASを生成することは別のチームが開発しているアプリケーションに組み込んでもらう際には大きなメリットになります。
OASを別チームに渡すだけでそれなりに高い精度でAPIの使い方を伝えることができます。 またOASの仕様に従っていれば、 OASを渡す側はOASを生成する方法を自由に選択できますし、 OASを受け取る側もOASを利用する方法を自由に選択できます。
OASをファイルとして生成してリポジトリにコミットする
わたしたちは FastAPI をWebフレームワークとして利用しています。 FastAPIには、自身に定義されているエンドポイントの情報をOASとして公開する機能がついています。
以下は FastAPIのドキュメント から引用したミニマムなAPIサーバの例です。
# main.py from fastapi import FastAPI from pydantic import BaseModel, Field app = FastAPI() class Item(BaseModel): name: str = Field(example="Foo") description: str | None = Field(default=None, example="A very nice Item") price: float = Field(example=35.4) tax: float | None = Field(default=None, example=3.2) class UpdateItemResponse(BaseModel): item_id: int = Field(example="123") item: Item = Field(...) @app.put("/items/{item_id}") async def update_item(item_id: int, item: Item) -> UpdateItemResponse: results = UpdateItemResponse(item_id=item_id, item=item) return results
このFastAPIアプリケーションを uvicorn main:app --port 12345
などとして起動した上で、
APIの /items/123
などのパスにアクセスすると通常のAPIサーバとして期待通りに機能します。
なお、以下の例ではHTTPクライアントとして HTTPie を使用しています。
$ http PUT http://127.0.0.1:12345/items/123 name=foobar description="A foobar item." price=123.45 tax=12.3 HTTP/1.1 200 OK content-length: 97 content-type: application/json date: Mon, 27 Mar 2023 14:03:03 GMT server: uvicorn { "item": { "description": "A foobar item.", "name": "foobar", "price": 123.45, "tax": 12.3 }, "item_id": 123 }
この状態でAPIの /docs
をGETすると、
OASをビューアーでレンダリングしたものが見られます。
これだけでも十分便利ですが、OASを見るためにはアプリケーションサーバを起動する必要があります。 OASを見る人全員にアプリケーションサーバの実行環境を整えてもらうのはあまり現実的とは言えません。 OASをファイルとしてエクポートしておけば、それを静的ページとして公開するなど活路が広がります。
以下のようなヘルパーを書いてみましょう。
# dump.py from json import dumps from main import app print(dumps(app.openapi()))
実行すると、標準出力にOASがJSONとして出力されます。
これはAPIの /openapi.json
をGETしたときの結果と同じものであり、
見た目は違いますが /docs
と内容は同じです。
$ python dump.py > openapi.json $ jq . openapi.json | head { "openapi": "3.0.2", "info": { "title": "FastAPI", "version": "0.1.0" }, "paths": { "/items/{item_id}": { "put": { "summary": "Update Item",
このOASをファイルに保存してリポジトリにコミットしておけば、 ファイルとなったOASをCIで簡単に活用できます。
また、弊社では開発プラットフォームとしてGitLabを活用していることは 以前ご紹介した通り ですが、 GitLabはOASであると判断したJSONを自動的にレンダリングして表示してくれます。 以下は、上記のようなJSONをコミットしたリポジトリをGitLabで閲覧した例です。
生成されるOASに手を加えたい場合は、 FastAPIにカスタマイズ済みのOASを生成してもらうことで、 OASを生成するたびに手動で手を加えなくても済むようになります。 詳細は Modify the OpenAPI schema をご確認ください。
リポジトリにコミットされているOASを常に最新に保つ
OASをファイルとしてリポジトリにコミットする場合、 プロダクションコードは変更したがOASを生成し忘れたという問題が発生しえます。 以前の記事 【CI戦術編 その1】 alembic check コマンドを活用したマイグレーションスクリプト生成忘れ防止 でご紹介した問題と同じ問題であり、 問題の解決にも同じような方法が使えますが、 alembicと違いFastAPIにはそのような問題を検知する機構は用意されていません。
幸いなことに、この検知機構はとても簡単に作れますので、作ってみましょう。 以下のようなpytestのテストモジュールを用意します。
# test_openapi.py from json import load from main import app def test_openapi(): with open("openapi.json") as stream: expected = app.openapi() actual = load(stream) assert actual == expected
先ほど作成した openapi.json
がカレントディレクトリに残っていると仮定します。
テストモジュールを pytest test_openapi.py
として実行してみます。
$ pytest test_openapi.py | cat ============================= test session starts ============================== platform linux -- Python 3.10.7, pytest-7.2.2, pluggy-1.0.0 rootdir: /home/rtagami/work/temp plugins: anyio-3.6.2 collected 1 item test_openapi.py . [100%] ============================== 1 passed in 0.22s ===============================
特に怒られませんでした。
openapi.json
を生成してからAPIサーバの定義を変更していないためです。
ここでAPIサーバの定義を変更したけど openapi.json
の再生成を忘れてしまったとします。
これが、CIに防いでもらいたい状況です。
from fastapi import FastAPI from pydantic import BaseModel, Field app = FastAPI() class Item(BaseModel): name: str = Field(example="Foo") description: str | None = Field(default=None, example="A very nice Item") price: float = Field(example=35.4) tax: float | None = Field(default=None, example=3.2) @app.put("/items/{item_id}") async def update_item(item_id: int | str, item: Item): # item_idがintに加えてstrも受け取るようになった results = {"item_id": item_id, "item": item} return results
先ほどのテストモジュールを pytest test_openapi.py
としてもう一度実行してみます。
============================= test session starts ============================== platform linux -- Python 3.10.7, pytest-7.2.2, pluggy-1.0.0 rootdir: /home/rtagami/work/temp plugins: anyio-3.6.2 collected 1 item test_openapi.py F [100%] =================================== FAILURES =================================== _________________________________ test_openapi _________________________________ def test_openapi(): with open("openapi.json") as stream: expected = app.openapi() actual = load(stream) > assert actual == expected E AssertionError: assert {'components'...or'}}, ...}}}} == {'components'...or'}}, ...}}}} E Omitting 3 identical items, use -vv to show E Differing items: E {'paths': {'/items/{item_id}': {'put': {'operationId': 'update_item_items__item_id__put', 'parameters': [{'in': 'path'...': {...}, 'description': 'Successful Response'}, '422': {'content': {...}, 'description': 'Validation Error'}}, ...}}}} != {'paths': {'/items/{item_id}': {'put': {'operationId': 'update_item_items__item_id__put', 'parameters': [{'in': 'path'...': {...}, 'description': 'Successful Response'}, '422': {'content': {...}, 'description': 'Validation Error'}}, ...}}}} E Use -v to get more diff test_openapi.py:12: AssertionError =========================== short test summary info ============================ FAILED test_openapi.py::test_openapi - AssertionError: assert {'components'..... ============================== 1 failed in 0.28s ===============================
想定通りエラーになりました。 CIでテストとして実行される場所にこのテストモジュールを置いておけば、 APIの定義と生成済みのOASの同期が取れている事が確認できます。
まとめ
今回の記事では、OASを生成することにどのようなメリットがあるかをご紹介しました。
別記事 【FJCTQualityUp #3 】マイクロサービス開発への取り組み でも書かれているように、 弊社ではAPIの仕様を共有する際にOASを使うことが事実上のスタンダードになっています。 組織の中で遠い関係にあるチームと一緒に開発をする際は、 知識移転のプロセスにOASを組み込むことによりプロセスが大幅に簡略化され、 APIの使い方についての認識のズレがうまれにくくなります。
次回は、生成したOASをわたしたちが開発しているWebフロントエンドでどう活用しているかをご紹介する予定です。 お楽しみに。
連載バックナンバー
- 連載始めます
- CI戦術編
- 【CI戦術編 その1】 alembic check コマンドを活用したマイグレーションスクリプト生成忘れ防止
- 【CI戦術編 その2】コードレビューを充実したものにする方法、あるいは一生残る恥ずかしい履歴を作らないように
- 【CI戦術編 その3】Pythonのimportのことならまかせろ isort
- 【CI戦術編 その4】Blackを利用したコーディングスタイルの統一
- 【CI戦術編 その5】Pythonで明示的に型を書く理由
- 【CI戦術編 その6】Python開発の強い味方 Pylint
- 【CI戦術編 その7】 pyupgradeを使って最新の記法に対応してみた
- OAS ←イマココ
- openapi-typescript (予定)