SPECIALIST

多様な専門性を持つNRIデジタル社員のコラム、インタビューやインサイトをご紹介します。

BACK

Amazon API Gateway で nullable 付きのAPI定義をインポートしたい

みなさんこんにちは。NRIデジタルの松村です。今回はかなりポイントを絞った記事ではありますが、Amazon Web Services(以下AWS)の提供する、Amazon API Gateway と OpenAPI3 についてお話したいと思います。具体的には、インタフェース仕様に null を許容するような定義をする場合にどうすればよいか、といった観点で掘り下げていきます。これらの API定義を併用する上では非常に重要な要素が含まれているので、私が調査した結果を元に丁寧に紐解いていきます。

本記事では、以下のステップに分けて記載します。

  1. 原因追求編
    本件にかかわらず、API Gateway と OpenAPI の関係性について整理します。結論を先に言ってしまうと「インポートできない」なのですが、何故なのか、他にどういうパターンが考えられるのか、という整理をしていきます。まずは基本となる、API Gateway, OpenAPI, そして JSON Schema についての説明から入り、どこに問題があるのかを探索していきます。
  2. 解決アプローチ編
    キレイに解決する方法はないのですが、一方でやりたいことがあるわけで、どのように解決するのかというアプローチを探索します。ここでは具体的なCLI/コンソール操作やスクリプトなどには踏み込みません。
  3. 具体的なプロセス編
    実際に定義やスクリプトなどを織り交ぜて、How to 的な説明を行います。ここは、皆様の環境次第でそのまま通じる場合もそうでない場合もあるかと思いますので、環境に合わせて取捨選択してご利用ください。
  4. 1.原因追求編

    OpenAPI3とJSON Schema

    まずは基礎知識からおさらいしていきましょう。

    Tech Blog 松村 AWS API Gateway 01

    Amazon API Gateway はフルマネージドなAPI管理サービスです。API処理だけで考えるのであれば、例えば AWS Lambda などの処理実行インフラがあり、ここがHTTPなどでアクセスできれば対応できるわけです。しかし、API群をサービスとして公開するために共通的に欲しくなる機能、例えば、認可とアクセスコントロール、スロットリング、モニタリング、API バージョン管理、などを個別で用意することは面倒であり、これらを一括して管理・提供したいというニーズがあります。他にもいくつかの種類のニーズがありますが、いずれにせよAPI群を管理するサービスとして API Gateway は存在しており、これを AWS がマネージドな形で提供しています。

    Tech Blog 松村 AWS API Gateway 02
    出典)OpenAPI Initiative

    インフラから入りましたが、当然ながら、 API とは Application Programming Interface であり、 Interface としての定義、およびその実装が必要となります。広義に API というとライブラリなどを含めて非常に多岐にわたりますが、本稿で、および Amazon API Gateway が対象としている API は WebAPI と呼ばれる、ネットワーク越しに呼び出されるインタフェースを指しますので、これに限定するとします。この WebAPI のインタフェース定義を行う規格のうち代表的なものが Open API Initiative が提供する OpenAPI (旧 swagger を含む)となります。 OpenAPI は執筆時点では 3.1.0 版が最新の安定バージョンとして公開されています。API開発者は OpenAPI の仕様書としてインタフェース仕様を定義し、API利用者へ公開することで、標準的な方法で定義を公開することができます。 OpenAPI には様々な言語でライブラリが開発されてもいますので、この定義方法はAPIを利用するアプリケーション開発者にとってもメリットがあります。

    Amazon API Gateway には、 OpenAPI で定義されたAPI定義をインポートする機能があります。対応しているOpenAPIのバージョンは、2.0系、および3.0系(厳密には3.0.1)になります。3.1系には対応していません(これが後ほど重要になります)。この機能を利用することで、 OpenAPI で定義されたAPI群を、コンソールやCLIで一つずつ入力することなく、一括で取り込むことができます。

    これは素晴らしいことなのですが、1点問題があります。 API定義の中で、各APIのIN/OUTで使用するデータの形式を、 Amazon API Gateway ではモデルというカテゴリで取り扱います。この、モデルの定義は JSON Schema draft 4 に準拠する、というのが仕様になります。

    What is JSON Schema ?

    Tech Blog 松村 AWS API Gateway 03
    出典)JSON Schema

    JSON Schema は、 JSON データの仕様を定義する方法の一つです。 JSON は、かなり自由にデータを表現することができる優れた表記方法です。一方で、非常に汎用的なため、どんな風にも作れてしまいます。例えば、CSVファイルの場合は

    • 各行が1つのデータレコードを表す
    • 各カラムは , で区切られる

     
    というように決まっており、そのために急に項目を増減させたり、入れ子にしたり、といったことは(特殊な解釈のルールを導入しない限りは)不可能です。しかし、 JSON の場合はテキストで表現しうるおよそほぼ全てのデータを表現することができる、と言っても過言ではないかと思います。

    しかし、その自由さ故に、扱う側からすると「どのようなデータが来るかわからない」こととなります。これは処理をする側からすると著しく不便です。そのため、JSONのデータ形式を定義する方法が作り出されました。これが JSON Schema です。初版は2013年頃に提示されたようです。

    それでは、 OpenAPI と JSON Schema はどのような関係性にあるのでしょうか。 OpenAPI のトップページには Compatible with JSON Schema という記述があります。なんだ、大丈夫そうですね! と思いましたかね。そうは問屋がおろしません。

    Compatible は、日本語でいうと「互換性がある」になります。実は、厳密には、 OpenAPI は JSON Schema の定義に一部従っていません。しかし、 OpenAPI で定義をした内容を JSON Schema で表現することは可能となっています。これが、 Compatible の正体になります。そして、 nullable は、この「一部従っていない」に該当することになります。

    何故従わないのか?

    まず、初めに、 OpenAPI と JSON Schema は別の規格です。何を当たり前のことを、と思うかもしれませんが、少しだけお付き合いください。

    別の規格、というのは、「同じものを別の表現で表している」というわけではありません。表している対象が異なります。 OpenAPI は、API定義全体を表します。これには、例えばAPI群を表す名前だったり、パス定義だったりなども含まれます。これらは JSON Schema を使って表すことはできません。JSON Schema は、 JSONオブジェクトの定義をする規格です。つまり、 JSON の定義を表します。なので、 OpenAPI の仕様は JSON で表現することができるので、 OpenAPI の仕様自体を JSON Schema で表すこともできます。双方、規格化したい対象が異なるのです。

    その中で、競合する部分が、前述する、 Amazon API Gateway で言う「モデル」部分になります。ここは、APIの定義としても「どういう JSON が送られる/戻されるのか」1)APIのリクエスト/レスポンスがデータとして保持するのはJSONとは限りませんが、ここでは簡便化のためにそのように表現しています。という定義をすることになりますし、それは JSON オブジェクトの定義をしていることに等しくなります。なので、別の定義方法を用いることは不自然な話ではありません。しかし、双方全く異なる定義をしても利用者が混乱するだけですので、なるべく寄せるように努力をしています。その結果として、かなり近しい表現となっているのが現状です。

    そして、懸案の nullable ですが、2019年に OpenAPI コミュニティ内で、その存在のあやふやさについて明確化するための提案がなされています。これが整理としてはわかりやすいので、参照することとします。
    OpenAPI-Specification/2019-10-31-Clarify-Nullable.md at main · OAI/OpenAPI-Specification

    このように、歴史的経緯の結果、 nullable の表記方法については、

    OpenAPI 3.0:

    
    {
      type: string
      nullable: true
    }
    

     
    JSON Schema:

    
    {
      type: ["string", "null"]
    }
    

     
    というように表記方法が分かれています。以下が仕様根拠になります。

    OpenAPI 3.0:
    • type では文字列のみが許容されている → OpenAPI-Specification/3.0.3.md at 3.0.3 · OAI/OpenAPI-Specification
      type – Value MUST be a string. Multiple types via an array are not supported.
    • nullable については、同項目内で Other than the JSON Schema subset fields, the following fields MAY be used for further schema documentation:という形で、拡張項目として定義されている
    JSON Schema(draft 4):

    それぞれの対応バージョン関係

    Amazon API Gatewayでは、OpenAPI 3.0 のインポート/エクスポート対応が 2018/9/27 にリリースされています。この時点での最新バージョンは OpenAPI 3.0.1 であり、これに準拠した形の機能となっているようです。バージョン 3.0.1 が 2017/12/7 リリースバージョン 3.0.2 が 2018/10/9 リリースになります。実際にAWSの該当ドキュメントにあるリンクでは、バージョン 3.0.1 へのリンクとなっています。

    一方、本件等の議論を元に、OpenAPIではバージョン 3.1.0 でJSON Schema Specification Draft 2019-09 へ完全に準拠するようになりました。これに伴い、 3.1.0 では nullable の廃止、 type のarray許容など、 JSON Schema との差異がある部分に対して破壊的変更を含めた修正が加えられています。なお、バージョン 3.1.0 では、これまでの Schema Object の定義と異なり、「準拠してるよ」とだけ言っており、ドキュメント上では拡張部分にしか触れていません。そのため少しわかりにくくなっています。

    ちなみに、 nullable と同様の話が example(s) にも存在します。OpenAPI 3.0 には example という項目が拡張項目として定義されています。これは、APIを利用する利用者から取ってみると、使用例があるかどうかでイメージの湧きやすさが大きく異なるため、必要だったからでしょう。一方で JSON Schema としては「型の定義をする」ことが主目的のため、優先度が低かったと考えられます。事実、 draft 4 には同種の定義が含まれません。しかし、その後 JSON Schema でも取り組みが行われ、現在では examples という項目が定義されています(現時点最新版の 2020-12 draft における定義はこちら)。OpenAPI 3.1.0 では JSON Schema に追随する形で examples に定義変更しています。この項目も、Amazon API Gateway において OpenAPI 3.0 のデータ読込時に設定されているとエラーとなるため、要注意です。

    Amazon API Gateway で nullable ありの OpenAPI を取り込んだ際の挙動

    それでは、 nullable をつけた状態で OpenAPI 形式のデータを Amazon API Gateway へインポートするとどのような挙動になるのでしょうか。

    結論から言うと、 nullable の項目は無視されます。例えば

    
    type: string
    nullable: true
    

     
    という定義をしていた場合、読み込むと

    
    type: [string]
    

     
    というように、単に文字列の型として定義されることになります。
    ちなみに、

    
    type: [string, null]
    

     

    のように、 draft 4 形式で読み込ませようとするとフォーマットエラーとなります。対象としている OpenAPI 3.0.1 の形式としては受け付けないフォーマットのため、エラーは正しいのですが、少々釈然としないものがありますよね。ですが、それが仕様です。

    原理的に言えば、 Compatible な部分の変換をして頂くか、もしくは OpenAPI 3.1.0 へ準拠する形でインポート機能が拡張されることが望ましいです。とはいえ、 OpenAPI 自身も言っているように、 OpenAPI 3.0 → 3.1 は JSON Schema への準拠という大きな舵取りをしていることもあり、仕様変化がかなり大きいです。内部議論の結果、セマンティックバージョニングには従わず、破壊的変更であるがマイナーバージョンの更新としたようです。つまり、実質的メジャーバージョン更新となるので、対応して頂くのは今しばらく時間がかかるかもしれませんね。

    2.解決アプローチ編

    なにをすればよいか

    前節で原因は見えてきたかと思います。「大上段の説明はいいから何したらいいか教えて」と、もしかして思ってるかもしれない本節からの読者向けに一言でいうと、 Amazon API Gateway は一部 OpenAPI 3.0 に仕様準拠していないので nullable はインポート時に無視されるし、 Amazon API Gateway の準拠する JSON Schema draft 4 形式だと OpenAPI 3.0 形式と異なるのでそもそもインポートエラーとなる、ということですね。

    そのため、インポート時になんとか工夫をして読み込んでもらう、ということは不可能ということになります。つまり、 OpenAPI インポート機能を使わない、などの大胆な方法を採るのでなければ、インポート後に追加で設定する必要がある、ということになります。

    インポート後に nullable を設定する方法

    インポート後に設定をすることとなる場合、当然ながら「どの項目が nullable なのか」を何らかの方法で定義しておく必要があります。もちろん、 OpenAPI の定義とは別に持っておくこともできますが、結果的に nullable を設定していても無視されるだけですので、 OpenAPI として正しい nullable の定義を入れておき、後からスクリプトで定義を読み直して設定対象へ設定していく、という方法を筆者は取りました。

    ここで、再設定にあたり、 Amazon API Gateway およびそのモデルに関するいくつか気をつけるべき事項があります。

    • モデルはスキーマを JSON の文字列として保持している
      CLIなどでモデル情報を更新する場合、モデルのスキーマ情報は、その構造ではなく JSON の文字列として保持されています。空白や改行も文字列の中に含まれます。そのため、特定の type 部分だけを差し替える、などということができず、 JSON 文字列を再現して再設定する必要があります。
    •  

    • OpenAPI の path オブジェクトなどに含まれているレスポンスなどの実体は、読み込み時に自動的にランダムな名前のモデルとして変換される
      components として定義している名前付きモデルは指定した名前で登録されますが、そうではなく responsesなどに直接登録している情報は、読み込み時に自動的にモデルに変換されます。そのため、どのような名前で登録されているのかは、API 側のパスから探索しなければわかりません。

     
     
    Tech Blog 松村 AWS API Gateway 03

    そのため、筆者は
    • OpenAPI のオブジェクトを読み込み、 nullable が含まれているモデルを特定する
    • 読み込んだモデルについて、 nullable を type での表記に変換する
    • 対象モデルを文字列に変換し、モデルのスキーマを全置換する

     
    という方法を採り、稼働確認まで進めることができました。

    手法採用時の諸注意

    この方式は、インポートの後で内容を変更しています。そのため、CloudFormation / AWS CDK などの IaC の仕組みを利用する場合、ドリフトが発生することになります。 IaC 側を変更するたびに初期化されるリスクがあること、 CloudFormation におけるドリフト検知機能などを利用する場合には何らかの対策が必須となること、についてご留意ください。

    3.具体的なプロセス編

    以下の順に実行することで、筆者の検証を再現することが可能です。

    1. API Gatewayで新しいREST APIを作成します。openapi3ファイルをインポートする機能を利用します。
      インポートは、後述の openapi3.yaml を使用します。paths, components 双方に nullable 要素が含まれています。
    2. pythonスクリプトを実行します。対象となるスクリプトは set_nullable.py になります。
      実行時には、pyyaml, boto3 が pip にインストールされている環境であること、あらかじめAWS環境に boto3 から接続できるように準備されていること、をご留意ください。
      スクリプト内では以下の順番に実施しています。
      a. openapi3.yaml ファイルの読み込み(ここは各環境に合わせてパスを修正して下さい)
      b. nullable 要素が含まれている要素の抽出及び JSON Schema 形式への変更
      c. 対象となるAPIのID特定
      d. components 側のモデルスキーマ変更
      e. paths 側のモデルスキーマ変更

     
    サンプルとなる openapi3.yaml 、および python スクリプトを記載します。pythonスクリプトにしたのは、構造が少々複雑なのでCLIなどで対応するのが辛く、 boto3 を使う方法が一番問題を起こしにくかったからです。

    ちなみに、本当はスキーマへ文字列を入れる前に pprint モジュールを使用して読みやすくしたかったのですが、プロジェクトで使用した定義において description 内に空白文字を含む定義がありまして、誤作動したので残念ながら諦めました。( python では “hoge” “fuga” のように文字列を2つ並べたものと “hogefuga” のように結合したものは等価として取り扱われます。そのため、空白文字部分で文字列が分割されたのですが、 JSON としてはこの文字列は正しくない形式のため、取り込みエラーとなりました)

    openapi3.yaml

    
    openapi: 3.0.1
    info:
      title: test api
      description: test api for nullable
      version: 1.0.0
      contact:
        name: NRI Digital
        url: https://www.nri-digital.jp/
        email: test@example.com
    servers:
      - url: https://localhost:8080/test/v1
    x-amazon-apigateway-request-validators:
      params-only:
        validateRequestParameters: true
        validateRequestBody: false
    x-amazon-apigateway-request-validator: params-only
    paths:
      /sample:
        get:
          # APIの概要説明
          summary: sample get
          operationId: sampleGET
          parameters:
            - name: var1
              in: query
              schema:
                type: string
          responses:
            "200":
              description: 成功
              content:
                application/json:
                  schema:
                    type: object
                    required:
                      - id
                    properties:
                      id:
                        type: string
                      name:
                        type: string
                        nullable: true
        post:
          summary: sample post
          operationId: samplePOST
          requestBody:
            content:
              application/json:
                schema:
                  $ref: "#/components/schemas/Sample"
          responses:
            "200":
              description: 成功
              content:
                application/json:
                  schema:
                    type: object
    components:
      schemas:
        Sample:
          title: sample
          type: object
          properties:
            id:
              type: string
            name:
              type: string
              nullable: true
    

     
    set_nullable.py (もう少し最適化のしようもあると思いますが、ご容赦下さい)

    
    import functools
    from typing import Union
    import yaml
    import boto3
    
    TARGET_API_NAME = "api-name"  # API Gateway におけるAPIの名前を指定
    
    
    def read_openapi(file_path):
        with open(file_path) as f:
            return yaml.safe_load(f.read())
    
    def search_path(target: Union[list, dict], key: str, base: list = []) -> list:
        results = []
        if isinstance(target, dict):
            for k, v in target.items():
                if k == key:
                    results.append(base)
                if isinstance(v, dict) or isinstance(v, list):
                    results.extend(search_path(v, key, base + [k]))
    
        elif isinstance(target, list):
            for res in [
                    search_path(item, key, base + [i])
                    for i, item in enumerate(target)
            ]:
                results.extend(res)
    
        return results
    
    def convert_types(openapi, target_paths):
        for path_chain in target_paths:
            target = functools.reduce(lambda x, y: x.get(y), path_chain, openapi)
            target['type'] = [target.get('type'), 'null']
            target.pop('nullable')
        return openapi
    
    def extract_named_models(target_paths: list):
        """components/schemas に入っているもののみ対象とする
    
        Args:
            target_paths (list): nullable 要素までのパス履歴が含まれたリスト群
        """
        return set([
            x[2] for x in target_paths
            if x[0] == 'components' and x[1] == 'schemas'
        ])
    
    def update_named_models(client, api_id, named_models, openapi):
        for model in named_models:
            client.update_model(
                restApiId=api_id,
                modelName=model,
                patchOperations=[{'op': 'replace', 'path': '/schema',
                    'value':
                    str(openapi.get('components').get('schemas').get(
                        model)).replace('"', '\\"').replace('\'', '"')
                }])
    
    def unique(lst: list):
        """extract unique lists in list
        unique([[1,2], [1,3], [2,1], [1,2], [2,1]])
        => [[1,2], [1,3], [2,1]]
    
        Args:
            lst (list): list of list
        """
        seen = []
        return [x for x in lst if x not in seen and not seen.append(x)]
    
    def extract_model_ids(client, api_id, target_paths) -> dict:
        results = {}
    
        resources = client.get_resources(restApiId=api_id)
    
        for paths in unique([x[0:8] for x in target_paths if x[0] == 'paths']):
            # example: ['paths', '/resource_path', 'get', 'responses', '200', 'content', 'application/json', 'schema']
            _, path, method, _, rescode, _, ctype, _ = paths
            resource_id = [
                x.get('id') for x in resources['items'] if x.get('path') == path
            ][0]
            method_data = client.get_method(restApiId=api_id,
                                            resourceId=resource_id,
                                            httpMethod=method.upper())
            model_id = method_data.get('methodResponses').get(rescode).get(
                'responseModels').get(ctype)
            if not model_id:
                raise Exception('no model of ' + path + ' ' + method +
                                ' ... method_data is: ' + str(method_data))
            results[model_id] = paths
    
        return results
    
    def update_generated_models(client, api_id, generated_model_ids, openapi):
        for model_id, paths in generated_model_ids.items():
            client.update_model(
                restApiId=api_id,
                modelName=model_id,
                patchOperations=[{'op': 'replace', 'path': '/schema',
                    'value':
                    str(functools.reduce(lambda x, y: x.get(y), paths,
                                         openapi)).replace('"', '\\"').replace(
                                             '\'', '"')
                }])
    
    
    if __name__ == '__main__':
        openapi = read_openapi("path/to/file")
        target_paths = search_path(openapi, 'nullable')
        openapi = convert_types(openapi, target_paths)
    
        # モデルスキーマを API Gateway のモデル定義に直接上書きする
        # API Gateway の定義では Model のスキーマは文字列として定義されているため、特定要素のみ変換することができない
        # そのため、該当するモデルを順番に入れ替える
        # モデルは schemas 上で定義しているものは定義名で登録されるが、 responses などに直接書いている場合は
        # インポート時に自動的にモデルが生成される
        # これらはネーミングルールが異なるため、別々に処理する
        apig = boto3.client('apigateway')
        api = [
            x for x in apig.get_rest_apis().get('items')
            if x.get('name') == TARGET_API_NAME
        ]
        if len(api) != 1:
            print(api)
            raise Exception("api object cannot be detected")
        api_id = api[0].get('id')
        named_models = extract_named_models(target_paths)
        update_named_models(apig, api_id, named_models, openapi)
        generated_model_ids = extract_model_ids(apig, api_id, target_paths)
        update_generated_models(apig, api_id, generated_model_ids, openapi)
    
        # モデル適用後に再デプロイが必要
        apig.create_deployment(restApiId=api_id, stageName='target-stage')
    
    

     

    本当は CloudFormation などの実行に組み込めればよいのですが、 IaC は「状態を定義する」方式を採るため、この手のプロセスを記述することは不得手とします。なので、パイプラインに組み込むなど、確実に実行される仕組み化を行うことで実行漏れを防ぐことが肝要です。

    さいごに

    いかがだったでしょうか。 AWS のサービス提供・発展スピードはすさまじいものがありますが、それでも様々な場所で「もっとこうなっていれば!」ということもあります。もちろん、機能リクエストを出すことも可能ですが、エンジニアとしては既存機能と自分の実装をうまく組み合わせることで効果的にサービスを活用していくということも醍醐味になるのではないでしょうか。

    それではみなさま、最後はご一緒に。 Go Build !!!

    References   [ + ]

    1. APIのリクエスト/レスポンスがデータとして保持するのはJSONとは限りませんが、ここでは簡便化のためにそのように表現しています。