冥冥乃志

ソフトウェア開発会社でチームマネージャをしているエンジニアの雑記。アウトプットは少なめです。

follow us in feedly

Serverless Frameworkでデプロイしたサービスからswagger.jsonを作りSDKやドキュメントを生成する

先日の続きです。

前回はServerless Frameworkを使ってAPI Gatewayでサービスを公開してみる、というところまでやりました。

個人や自分のチームだけが使ってメンテナンスするシングルサービスで構成されるアプリケーションならこれでガンガン開発すれば良いと思います。ただ、ものによってはチームで複数のサービスから構成される複雑なシステムを作る、といったケースや、外部に公開するAPIをもつサービスの一部としてこれらのサービスを開発する、というケースも想定されます。このような場合に必要なのが、開発者向けのドキュメントやクライアントライブラリを提供することです。

今回はそのあたりの整備をやっていきたいと思います。例によってリポジトリはここです。

github.com

github.com

一つ増えていますが、これはクライアントライブラリなどの開発者アセットの生成と配備を行うLambdaファンクション群をまとめたプロジェクトです。複数のプロジェクトから使い回せるように別プロジェクトに分けてあります。

最終的には、Github(merge)-> CodeBuild -> CodeDeploy -(API Gateway)-> クライアントライブラリの生成とSwagger UIの更新、みたいな流れををCodePipelineで作りたいわけですが、長くなったので最後の「クライアントライブラリの生成とSwagger UIの更新」の部分にフォーカスしています。

とりあえず、何をするにしてもswaggerファイルがあれば勝てると思うのでそこを目指すことにしましょう。

API GatewayからSwaggerファイルをエクスポートする

API GatewayAPIにはSwaggerファイルの形式でエクスポートするための get-export があるのでそれを使います。REST APIもありますが、Lambdaで叩こうと思うのでboto3を使っています。

import boto3
import os
import logging

apigateway = boto3.client('apigateway')
s3 = boto3.resource('s3')


def generate_swagger(event, context):
    try:
        staged_api = [api for api in apigateway.get_rest_apis()['items']
                      if api['name'] == os.environ['REST_API_NAME']].pop()

        exported_api = apigateway.get_export(
            restApiId=staged_api['id'],
            stageName=os.environ['REST_API_STAGE_NAME'],
            exportType="swagger"
        )

        swagger_bucket = s3.Bucket(os.environ['SWAGGER_FILE_BUCKET'])

        swagger_file = swagger_bucket.Object('swagger.json')
        swagger_file.put(
            Body=exported_api['body'].read().decode('utf-8'),
            ContentEncoding='utf-8',
            ContentType='text/plane',
            ACL='public-read'
        )
        return "/".join(["https://s3-ap-northeast-1.amazonaws.com",
                         os.environ['SWAGGER_FILE_BUCKET'],
                         "swagger.json"])
    except Exception as e:
        logging.error("Generate Swagger-File Failed. Detail:{0}".format(e))
        raise Exception("Coudn't create swagger file. Detail:{0}".format(e))

apigateway.get_export をキックする前に apigateway.get_rest_apis を叩いているのは、 get_export でのAPI指定にIDが必要で、 serverless.yml に書かれている設定だけではまかなえなかったからです。これについてはデプロイする環境名など、アプリケーションの情報を使うのでアプリケーション内に含まれるLambdaファンクションとしています。

ちなみに、これは後々ElasticBeanstalkで立てるSwagger GeneratorがURLでアクセスする必要があるのでアクセス権を public-read にしています。

(当たり前だけど)ローカル実行時のユーザとデプロイ時のユーザが違うので注意

Serverless Frameworkにはローカル実行オプションがあって、リソースへのアクセスさえできればデプロイせずにも実行できるのですが、実行ユーザに関する注意点があることに今更気づきました。ローカル実行時のユーザが割と強い権限なので、 serverless.yml に権限つけ忘れても動いてしまうんですよね。で、デプロイして実行たら権限が足りなくてこける、という(デプロイ自体は成功する)。

今回は、API Gateway関連の権限をつけ忘れていたので、以下を参考にしました。

docs.aws.amazon.com

APIドキュメントを充実させる

ちなみに、普通にServerless Framework使っているだけの状態でSwaggerファイルを生成しても、連携するもしくはユーザになる開発者に有用なアセットを作ることができません。エンドポイントの説明とかモデルの定義とかが空の状態のままなんですね。というわけで、APIドキュメントを充実させるために以下のプラグインを導入します。

www.npmjs.com

基本的な使い方はドキュメントに書いてある通りなんですが、statusCodeはstringを要求されるみたいなので気をつけましょう。

Swaggerの定義では、リクエスト/レスポンスのモデルをJsonSchemaで定義します。このプラグインでは、それらの定義を別ファイルに切り出しておくことができます。それが以下の部分です。

    models:
      - name: "CreateLifeLogRequest"
        description: "ライフログを作成する時のリクエスト"
        contentType: "application/json"
        schema: "${file(models/create_life_log_request.json)}"
      - name: "LifeLog"
        description: "ライフログのモデル"
        contentType: "application/json"
        schema: "${file(models/lie_log.json)}"

実際のJsonSchemaの定義は以下。

{
  "type": "object",
  "properties": {
    "event": {
      "description": "ライフイベントの内容を記載してください。",
      "type": "string"
    },
    "insight": {
      "description": "ライフイベントが起こった時の感情を記載してください。(任意)",
      "type": "string"
    }
  },
  "required": ["event"]
}

で、モデルをJsonSchemaで表せるということは、Lambdaファンクション内でスキーマをベースにしたチェックができるということなんですね。

import json
import logging
import os
import time
import uuid
import jsonschema

from commons import aws_resources


def create_life_log(event, context):
    try:
        dynamodb = aws_resources.get_dynamodb(event)
        timestamp = int(time.time() * 1000)

        data = json.loads(event['body'])
        request_schema = json.load(open(os.environ['REQUEST_MODEL_SCHEMA']))
        jsonschema.validate(data, request_schema)
        table = dynamodb.Table(os.environ['LIFE_EVENT_TABLENAME'])

        item = {
            'id': str(uuid.uuid1()),
            'event': data['event'],
            'createdAt': timestamp,
            'updatedAt': timestamp
        }

        if 'insight' in data:
            item['insight'] = data['insight']

        table.put_item(Item=item)

        response = {
            "statusCode": 200,
            "body": json.dumps(item)
        }

        return response
    except KeyError as e:
        logging.error("Validation Failed")
        raise Exception("Coudn't create life log.Detail:{0}".format(e))
        return

jsonschema.validate(data, request_schema) の部分がそれです。ちなみに、Lambdaの実行環境には jsonschema は含まれていないので、プロジェクトにインストールしてデプロイ時のzipファイルに含まれるようにしてあげる必要があります。どうしようか迷ったんですが、とりあえずインストールした外部パッケージ自体はgitignoreには追加してます。

と、ここまで書きながら調べたら、 lambda-uploderというプラグインがあるんですね、今度使ってみたいと思います。

dev.classmethod.jp

SwaggerファイルからSDKを作る

さて、ここからは生成したSwaggerファイルを活用していきます。Swaggerファイルから各種コード生成を行うためのSwagger Codegeneratorというツールがあるので、活用します。

https://swagger.io/swagger-codegen/swagger.io

これは、Dockerhubにある公式イメージで、ジェネレータをWebサービスとして起動可能なので、Elastic Beanstalkを使ってサービスを立てます。その時Dockerrun.aws.jsonは以下のような感じ。

{
  "AWSEBDockerrunVersion": "1",
  "Image": {
    "Name": "swaggerapi/swagger-generator",
    "Update": true
  },
  "Ports": [
    {
      "ContainerPort": "8080"
    }
  ]
}

例えばJavaのクライアントライブラリを生成したい場合は以下のようにリクエストしてください。

curl -X POST --header 'Content-type: application/json' --header 'Accept: application/json' -d '{"swaggerUrl": "swagger.jsonのURL"}' EBのURL/api/gen/clients/java

このAPIが請け負うのはライブラリの生成要求のみで、ダウンロードは別のAPIが用意されています。先のAPIの結果から code を取得して以下のように投げます。

curl -X GET "EBのURL/api/gen/download/取得したcode" -H "accept: application/octet-stream" > library.zip

なお、これで生成されるライブラリのダウンロードはワンタイムな模様で、ダウンロードに失敗したら生成からやり直しが必要です。

これをLambdaファンクションとして書いたものが以下です。これは割と使い回せるので、別に配布用のサービスを作っています。

import json
import os
import boto3
import urllib.request
s3 = boto3.resource('s3')


def generate_client_api(swagger_url, language):
    api_base = {
        "url": "/".join([os.environ['SWAGGER_API_URL'],
                        os.environ['GENERATE_CLIENT_URL'],
                        language]),
        "method": "POST",
        "headers": {"Content-Type": "application/json"}
    }
    obj = {"swaggerUrl": swagger_url}
    json_data = json.dumps(obj).encode("utf-8")

    return urllib.request.Request(api_base['url'],
                                  data=json_data,
                                  method=api_base['method'],
                                  headers=api_base['headers'])


def download_client_api(code):
    return "/".join([os.environ['SWAGGER_API_URL'],
                     os.environ['DOWNLOAD_CLIENT_URL'],
                     code])


def distribute_client(event, context):
    request = generate_client_api(event['swaggerUrl'], event['language'])

    with urllib.request.urlopen(request) as res:
        code = json.loads(res.read().decode('utf-8'))['code']
        with urllib.request.urlopen(download_client_api(code)) as client_res:
            client_bucket = s3.Bucket(os.environ['CLIENT_SDK_BUCKET'])
            client_file = client_bucket.Object(
                event['serviceName'] +
                '/{0}/{0}-generated-client.zip'.format(event['language']))
            client_file.put(Body=client_res.read())

    response = {
        "statusCode": 200
    }

    return response

Swaggerファイルの生成とクライアントの生成をStepFunctionsで繋ぐ

Swaggerファイルとクライアントの生成を個別にLambdaファンクションで行うことには成功しました。どうせならこの辺繋げたいですよね?

というわけでStep Functionsを使って繋げましょう。StepFunctionsとは、視覚的なワークフローを使って、コンポーネントやLambdaファンクションを繋ぐ分散アプリケーションを構築するためのサービスです。

aws.amazon.com

前職時代にSOAPでこういうソリューションを使った記憶があって、大規模SIerが好きそうな感じがしすね。時代は巡る糸車。

ステートマシンという実行ワークフローを構築し、その中のタスクでLambdaの実行などをします。ステートマシンはjsonベースのAmazon State Languageで書かれるようです。

docs.aws.amazon.com

ドキュメントに日本語はありませんが、東京リージョンにもちゃんときているので、安心して使いましょう。なお、当然といえば当然ですが、さすがに同じアカウント内のLambdaファンクションじゃないとワークフローに使えません。

というわけで、こんな感じのStep Functionsを作りました。

f:id:mao_instantlife:20170721212911p:plain

{
  "Comment": "Swaggerファイルの生成に成功したら、各言語向けにライブラリを生成する。",
  "StartAt": "SetServiceName",
  "States": {
    "SetServiceName": {
      "Comment": "サービス名の設定",
      "Type": "Pass",
      "Result": "my-assistant",
      "ResultPath": "$.serviceName",
      "Next": "GenerateSwaggerFile"
    },

    "GenerateSwaggerFile": {
      "Type": "Task",
      "Comment": "Swaggerファイルの生成",
      "Resource": "Swaggerファイル生成のLambdaファンクションARN",
      "Catch": [{
        "ErrorEquals": ["UnhandledError"],
        "Next": "GenerateErrorFallback"
      }],
      "ResultPath": "$.swaggerUrl",
      "Next": "GenerateClientParallel"
    },

    "GenerateErrorFallback": {
         "Type": "Pass",
         "Result": "This is a fallback from a generate swagger file Lambda function exception",
         "End": true
    },

    "GenerateClientParallel": {
      "Type": "Parallel",
      "End": true,
      "Branches": [
        {
          "StartAt": "SetLanguageJava",
          "States": {
            "SetLanguageJava": {
              "Comment": "言語設定にJavaを追加する",
              "Type": "Pass",
              "Result": "java",
              "ResultPath": "$.language",
              "Next": "GenerateClientJava"
            },

            "GenerateClientJava": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        },
        {
          "StartAt": "SetLanguageRuby",
          "States": {
            "SetLanguageRuby": {
              "Comment": "言語設定にRubyを追加する",
              "Type": "Pass",
              "Result": "ruby",
              "ResultPath": "$.language",
              "Next": "GenerateClientRuby"
            },

            "GenerateClientRuby": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        },
        {
          "StartAt": "SetLanguageJavascript",
          "States": {
            "SetLanguageJavascript": {
              "Comment": "言語設定にJavaScriptを追加する",
              "Type": "Pass",
              "Result": "javascript",
              "ResultPath": "$.language",
              "Next": "GenerateClientJavaScript"
            },

            "GenerateClientJavaScript": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        },
        {
          "StartAt": "SetLanguageScala",
          "States": {
            "SetLanguageScala": {
              "Comment": "言語設定にScalaを追加する",
              "Type": "Pass",
              "Result": "scala",
              "ResultPath": "$.language",
              "Next": "GenerateClientScala"
            },

            "GenerateClientScala": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        }
      ]
    }
  }
}

Swaggerファイルの生成に成功したら、JavaRubyJavaScriptScalaのクライアント生成をパラレルで実行するようにしています。Swaggerファイルの生成に失敗した場合はLambdaファンクションから例外を発生させて、それをキャッチしてStateMachinを止めるように作ってあります。カスタムエラーもドキュメントにはあるのですが、うまく動きませんでした。

ドキュメントを読む限りは、他には分岐やリトライなどもあり、基本的なワークフローは問題なく実装できそうです。

ただし、StateMachineのinputとoutputは特殊で慣れが必要だな、と感じました。StateMachine自体のInputとOutputを全てのタスクでコンテキストとして共有して、やりとりしている感じです。全てのタスクにはStateMachineのInputが渡されます。そのため、あるタスクの結果を後続タスクに曳き回したいときは、Inputの構成にそれを追加してあげる必要が出てきます。 GenerateSwaggerFile タスクの "ResultPath": "$.swaggerUrl", となってる箇所がその箇所です。このタスクの処理結果をInputの swaggerUrl という属性に放り込んでいます。

ちなみに、Parallelで並列実行する場合は各ブランチごとにInput領域がコピーされるため、ブランチ内では独立してInput領域を扱うことができ、名前が被っても問題ありません。実行時に実際に渡されたInputを見てみましょう。Java生成ブランチでのものとRuby生成ブランチでのものです。

f:id:mao_instantlife:20170721212930p:plain

f:id:mao_instantlife:20170721212943p:plain

上記のように、同じInputの属性でも別々の値がセットされています。

StepFunctionsをServerlessフレームワークの設定として書く

ここまでくると、アプリケーションのサービスをデプロイしたらStep FunctionsのStateMachineも作って欲しいですよね?というわけで、こんなプラグインを導入しました。

github.com

このプラグインを導入すれば、 serverless.yml にStep Functionsの設定をかけるようになります。基本的にはjson形式がyaml形式になるだけです。

github.com

ただ、StateMachineの登録される名前がちょっとあれでですね、今回の設定でデプロイしたらこんな感じになりました。

MyDashassistantDashgenerateDashclientDashdev-HJGGH29DKCX7

ちょっと待って、 -Dash に変換されるとか望んでない。。。

SwaggerファイルからSwagger UIを作る

で、最後にSwagger UIなわけですが、これもswagger-uiもdockerイメージとして起動可能なのがわかったので、今回はちょっと省略させてもらいます。

https://hub.docker.com/r/swaggerapi/swagger-ui/hub.docker.com

基本的なEBの構成はイメージの変更だけでSwagger Codegenと同じでいいはず。環境変数でSwaggerファイルを参照するので、更新されたら念のためアプリケーションサーバの再起動をすれば問題なさそうです。

まとめ

長くなりましたが、これで開発者向けの各種アセットをワンアクションで生成、配備することができるようになりました。次は、コードの更新に合わせてそれを実行する、というプロセスができれば、Serverless Frameworkでマイクロサービスを作る上での基盤というかリファレンスアーキテクチャ的なものができるかな、と。次はその辺りに取り組んでみたいと思います。