pythonの型定義を使ってOpenAPI docを出力してみる。

今回は以前のprotobufの出力を試した記事のOpenAPI doc版。

近年のOpenAPIの状況

近年では、openAPIとの付き合い方もだいぶ落ち着いてきて、夢を見た機能を実装し切るというような気持ちのところは減ってきている気がする。何かの連携のときに引数として渡すときに使われると言う程度。熱狂のあとの廃墟と閑古鳥。よくある風景という気もしないでもない(?)。

かつては「コードからspecへ」と「specからコードへ」の2者で争われてきた勢力争いもあったが、現在ではある程度勝敗がついてきてしまっていて、「コードからspecを出力する」勢が優勢という結論になっているような気がしている(?)。

specを見て実装したりすることはなくなり、実装からspecを生成して連携先にわたすという使い方が一般的なってきた。例えばfastAPIなどはそう。そして例えばFaaSでなどで管理するときのinterfaceとしてopenAPI docを渡すと言う感じで使われる。例えばAWSAPI Gatewayなどがそう。

逆にgo-swaggerのようなspecからコードを出力するような勢力は存在感を失ってきている。これはまぁ単純な話で、リソースが足りていない。流行りがそのまま延長するかのような気持ちで作られたOSSが燃え尽きと共にメンテナンスモードになっておしまい。これもまたよくある話かもしれない。

実際には「specからコード」のスタイルが完全に悪い方針かというと、理想的にはそうではないと擁護したい部分もあるものの、まぁコストも掛かるし難しいよねーという感覚はある(例えば、理想的には、コミュニケーションを行うフロントエンドとバックエンドが両方同時に開発を進められるというような擁護の仕方もできなくはない1)。

現実的には、全ての互換性部分を実装し切る必要があったり、openAPI docを記述しきったとして、動くかどうかわからないコードが生成されるだけと言う状況なので、実装し切る必要がある範囲が広い。リソースが必要なのはそれはそう2

古くなったツールチェインからの脱却

specからのコード出力勢が勢力を失ってきているとはいえ、自分たちのコードに限ってはメンテし続けなくてはいけない。もちろん、upstreamに貢献するための奉仕活動などをするという道もありはするが、これを取り除くための試行錯誤というような道もある。こちらのほうが実際楽なのではと言う気持ちもあったりする。

楽だと感じる理由は、利用するのが仕様のsubsetの対応で済む面が多いため。自分たちの内部表現からのマッピングだけを考えれば済むのであれば、いろいろなものを暗黙の前提とした組み込みのものとして考えることができる。

とはいえ、いきなり全てを自作のツールチェインに書き換える時間も余裕もないのだとするのが普通だとすると、とりあえずは既存のspecからのコード出力を使いつつ、内部表現での定義に移行していきたい。そう考えると内部表現からOpenAPI docを生成しておくというのは、はじめの第一歩としては、悪くない選択なように感じる(未だ目論みや企みのレベルでまだ実践してはいないのだけれど)。

例えば内部表現としてPOPO (Plain Old Python Object)で定義することを考えてみる。

OpenAPI docの手書きはだるい

その上で、OpenAPI docを手書きするのはだるい。

YAMLを手書きするのがだるい

まずYAMLを手書きするのがだるい。タイポなどをしてしまうと結構気づきにくかったりする。もちろんエディタ上でlintなどを動かしはするものの、YAMLの形式的なlintを除けば、jsonschemaなどのようなschemaでのlintということになる。これらのlintのエラーメッセージは1つの指摘が長大なメッセージになる傾向があり、すこぶる読みにくい。

YAMLで書くとこのようになる定義が、

# だるい

Person:
  description person object
  type: object
  properties:
    name:
      type: string
    age:
      type: integer
  required:
    - name

たとえば、pythonで書くならこういう感じで済む3

class Person:
    """person object"""

    name : str
    age: Optional[int]

加えて、定義したオブジェクトのフィールドをループすることもできなければ、定義したオブジェクトを利用して一部を書き換えるというようなこともできない。せいぜい可能なのがallOfとrefでつなげたり参照したりする程度。

モジュールシステムのようなものが存在しない

続いて、モジュールシステムのようなものが存在しない。builderのようなコマンドを用意して、相対的なパスをいい感じに読み込むようにしたとしても、補完や絶対パス的なnamespaceを指定して読み込むようなことができない。

例えば、以下のようにファイルを分けることはできる。

$ tree
.
└── src
    ├── api
    │   ├── api_foo.yaml
    │   └── main.yaml
    └── common
        ├── auths.yaml
        └── primitives.yaml

ここで api_foo.yaml から auths.yaml に定義されている Authentication を読み込みたいとする。可能なのは、以下のどちらか。

  • 絶対パスでの参照 https://github.com/<resources>/src/common/auths.yaml#/components/schemas/Authentication
  • 相対パスでの参照 ../common/auths.yaml

絶対パスでの参照は長めの指定になるし、ファイルを取得可能な場所に置いておかないと不便になる。相対パスでの参照は、これは一定便利ではあるが、現在のファイルとの相対的な位置が変わりうるようなリファクタリングが行いづらくなる。

欲しいのはそう from common.auths import Authentication のような何かですね。特定の手軽なrootの名前空間からの指定で読み込みたい。もちろん既存の言語なら大抵の場合LSP経由で補完も効く。

加えて、YAMLをparseしてアレコレするコマンドの実装では、これらの参照失敗のエラーメッセージが不親切なことは多い4。既存のプログラミング言語のモジュールシステムとエラーメッセージは100倍良くできてますね。それ自体がその言語の基礎となる機能の内のひとつなので。

YAMLマークアップ言語なので、いい感じにつなげることがめんどくさい

その上、結局どんなに頑張っても、YAMLマークアップ言語なので、操作を直接記述することができない。

もちろん自作の評価器のようなようなものを設けて、勝手にマクロのような何かを付け足すこともできるものの、そのような独自の文法を説明したり共有したりするコストが大きい。加えて他の入力ソースは勝手に追加した独自文法など知っているわけがないので、これをした瞬間に標準だけの機能を使って定義していたことの価値が失われてしまう。

既存の、標準だけの機能を使って定義するとなると、JSON Referenceのような記法で参照を許すと言う形にするのが自然ということになる(一部はYAMLのaliasなどの機能を使っても対応できるかもしれない)。

するとエントリーポイントとしてのmain.yamlの記述がだるくなる。必ず二箇所のファイルにpathを書かなくてはいけなくなる。そして/エスケープがだるい。リリース前の開発中の状態であっても、一度定義したAPIのpathを書き換えるような変更をする気力が失われる。

main.yaml

paths:
  /api/foo/{foo_id}:
    $ref: "./api_foo.yaml#/paths/~1api~1foo{foo_id}"

api_foo.yaml

paths:
  /api/foo/{foo_id}:
    parameters:
      in: path
      name: foo_id
      required: true
    get:
      operationId: get_foo
      # 一部descriptionが必須の箇所もある。だるい。
      description: get foo
      ...

加えて、例えば、APIGroupのようなものを考えて、複数のそれをimportというようなことが事実上できない(できるが、元の自作のコマンド/マクロを作ると言う話になってしまう)。

普通に考えるなら、pathの定義は一回だけで済ませたい。例えば以下の様に。

api_foo.py

@router.get("/api/foo/{foo_id}")
def get_foo() -> Foo:
    """get foo"""
    ...

(実はデコレータースタイルを考えるなら、sub router的なオブジェクトをいい感じに定義する必要があったりするが)

main = API()

import api.foo
main.mount(api.foo.router)

あるいは、以下のように参照を切れるので、静的な言語と比べて動的な言語が便利という面もあるかもしれない。どのように実装するかによる。

main.include("api.foo")

OpenAPI の定義は resourceとpathが密結合

あとYAML故ではなくOpenAPI故の面倒くささもあったりする。例えばの例であげるとすると、resourceとpathの関係が密結合なのがめんどくさい。

これはどういうことかというと、例えば、1つ前の例で出てきた以下のようなresourceについて考えてみるとすると。

GET /api/foo/{foo_id}

この {foo_id} というparameterがresourceと癒着している。例えば別のパスに同様の表現を返すようなAPIを定義するのがめんどくさい。例えば以下のような。

GET /api/bar/{bar_id}

なぜなら、parameter objectがpathの定義に含まれているので、このように書かなくてはいけないため。

paths:
  /api/foo/{foo_id}:
    parameters:
      in: path
      name: foo_id
      required: true
    get:
      operationId: get_foo
      ...

  /api/bar/{bar_id}:
    parameters:
      in: path
      name: bar_id
      required: true
    get:
      operationId: get_bar
      ...

これがOpenAPIの仕様故といえる理由は、自分たちの選んだ内部表現がこれをそのままマッピングしたものなら、この密結合もそのまま一緒に引きづられて保持することになるため。

例えば、Pathという型の引数という風な表現の場合には、引きづられる。

@api.get("/api/foo/{foo_id}")
def get_foo(foo_id: Path[str]) -> Foo:
    ...

そんなわけで良い内部表現を考えるとしたら、foo_idのようなパス変数はdecoratorでくっつけられたタイミングで自動で挿入できるような形になっている方が望ましい。proxyのような定義をすることができる

@api.get("/proxy/internal/foo/{foo_id}")
@api.get("/api/foo/{foo_id}")
def get_foo() -> Foo:
    ...

# 型の指定をしたくなるだろうから、以下の省略形ということにする
# @api.get("/api/foo/{foo_id:str}")

場合によっては operation_id を指定できたほうが良いかもしれないし。genericsを受け取れるようにしたほうが良いかもしれない。もちろん、不要な複雑さをあえて持ち込む必要もない。どの機能を追加したほうが良いかを勝手に決められるのも、内部表現を自分で決めることの利点ではある。subsetで良いというのは入力にも出力にも二重に働く。

あるいは、逆に、tagsの指定がめんどくさいというのであれば、これを内部表現のモジュール名から自動で指定できるようにしても良いかもしれない。

例えばの例

例えばの例として、以下のようなpythonコードからOpenAPI docを出力できるようにするというのも良いかもしれない(これはpet storeの例)。

protobufのように、関数の中でoptionalなメタデータの追加をするのは便利な記法だなーと思っていたりする。

あとは、あえて、openAPIに閉じないような形でschema部分の定義は使えないか?とか考えたりしている。以前に考えたことではあったけれど。grpc用のprotobufを吐くようにするのはそう大変でもない。はず。

from __future__ import annotations
import typing as t
import typing_extensions as tx
from emit import emit
from runtime import API, Query, DefaultStatus, ErrorResponse
from metashape.outputs.openapi.types import int32, int64

# https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml

# for autotags

__TAGS__ = ["pets"]


# definitions
class Pet:
    id: int64
    name: str
    tag: t.Optional[str]


Pets = t.List[Pet]


class Error:
    code: int32
    message: str


api = API()


# paths
@api.get("/pets")
def listPets(limit: Query[int32]) -> t.List[Pet]:
    """List all pets"""
    c = api.get_current_context()

    c.limit.description = "How many items to return at one time (max 100)"

    c.return_.description = "A paged array of pets"
    c.return_.extra_data = {
        "headers": {
            "x-next": {
                "description": "A link to the next page of responses",
                "schema": {"type": "string"},
            }
        }
    }


@api.post("/pets")
def createPets() -> tx.Annotated[None, DefaultStatus(201)]:
    """Create a pet"""
    c = api.get_current_context()
    c.return_.description = "Null response"


@api.get("/pets/{petId}")
def showPetById() -> Pet:
    """Info for a specific pet"""
    c = api.get_current_context()

    c.petId.description = "The id of the pet to retrieve"

    c.return_.description = "Expected response to a valid request"


if __name__ == "__main__":
    from dictknife import loading

    d = emit(
        api,
        title="Swagger Petstore",
        version="1.0.0",
        license="MIT",
        servers=["http://petstore.swagger.io/v1"],
        default_error_response=ErrorResponse(Error),
        autotags=True,
    )
    loading.dumpfile(d, format="yaml")

  1. 現実的には、2者間で共有されてはいるものの、結局、どちらか一方しか触らないという結果になる事が多いし。機能の不足のコミュニケーションはできるものの、不要な機能の削除のコミュニケーションはopenAPI docではし辛い。そういう意味ではdemand drivenなgraphQLのほうが理想的にはAPIを小さくしやすいというような話もある。

  2. 「コードからspec」では、必ず1つは動作するコードが手に入る。そして、出力するspecは、openAPIのsubsetで良い場合がある。

  3. とはいえ、こういう表現の綺麗さならTypeScriptの方が上。あるいは簡単なものならprotobufでも良いのかもしれない

  4. 自作したりもしていた時期はあったものの、自分で対応するのもけっこうめんどくさい