FJCT Tech blog

富士通クラウドテクノロジーズ公式エンジニアブログです

富士通クラウドテクノロジーズ

FJCT/Tech blog

【CI戦術編 その8】OAS(OpenAPI Specification)で仕様書を自動生成しよう

ネットワークサービス部の田上(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を使うことでさまざまなツールからのサポートを受けることができます。 以下はエコシステム内のツールが果たしてくれる役目の例です。

  • APIサーバからのOASの自動生成
  • OASビューア
  • OASを元にしたAPIクライアントの自動生成

具体的な一覧は 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()))

実行すると、標準出力にOASJSONとして出力されます。 これは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フロントエンドでどう活用しているかをご紹介する予定です。 お楽しみに。

連載バックナンバー