Serverless/FlaskでSwagger付きサーバレスAPIを作る

## はじめに

この記事は [2020 年の RevComm アドベントカレンダー](https://qiita.com/advent-calendar/2020/revcomm) 23 日目の記事です。
 日目は @mpayu2 さんの「 [【入門・React・TypeScript・Enzyme】Create React AppからEnzyme導入まで](https://qiita.com/mpayu2/items/d2c86e006d93b7cc3127) 」でした。

こんにちは。株式会社RevCommで社内向けシステム開発・運用を担当している @zoetaka38 です。

みなさん、サーバレスシステムでサービスを開発されることはありますか？
私は今社内システムを担当しているのですが、社内システムは様々な機能が求められますが、一方で低コストで、高可用なシステムを求められることが多いです。そして、24時間ずっと使われるわけでもないことも多々あります。
そういった背景から、社内関係のシステムをサーバレスで作成しちゃうことが多いです。

しかし、サーバレスでAPIを作成していくと、DBとの接続にコネクションプール気をつけないといけないとか、ドキュメントたくさん作らないといけなくてめんどくさいとか、色々問題が出てきます。

そこで、[Serverless Framework](https://www.serverless.com/)と、[Flask](https://flask.palletsprojects.com/en/1.1.x/)を使って、サーバレスで、DBコネクションプールを気にしないですみ、かつドキュメントも勝手にできてくれるようなフレームワークを紹介したいと思います。

## 前提

以下のクラウド環境やモジュールバージョンで動作することは確認済みです。

- 開発環境はおそらくなんでも大丈夫ですが、macOS Big Sur と Ubuntu 20.04 desktopで動くことは確認しました
- Serverless Framework 2.11.1
- Python3.8
- インフラはAWSを使うことを前提としています。

## やってみる

それでは、早速作っていきます。

### 環境の構築

pipenv派なので、pipenvで環境作っていきます。

```sh
$ pip install pipenv
$ pipenv --python 3.8
$ pipenv install flask
```

ローカルで開発するときは、Dockerでやりたいので、Dockerfileとdocker-compose.ymlを用意しておきましょう。
Dockerfileは、Pythonが実行できて、Pipfileからパッケージインストールされるようなものを使ってます。

```sh:Dockerfile
FROM python:3.8.0

# set working directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# install environment dependencies
RUN apt-get update -yqq \
  && apt-get install -yqq --no-install-recommends \
    openssl \
  && apt-get -q clean

# add requirements (to leverage Docker cache)
COPY Pipfile ./
COPY Pipfile.lock ./

# install requirements
RUN pip install -U --no-cache-dir pipenv
RUN pipenv install

COPY ./api/ /usr/src/app

CMD pipenv run python -B -u app.py run -h 0.0.0.0
```

内部で開発するときにAWSのクレデンシャルが共有されるようにすることと、環境変数を`./envs`フォルダ以下の`.env.{環境名}`ファイルから読み込むようにしていることが、工夫点です。

```yml:docker-compose.yml
version: '3.4'

services:
  restapi:
    build: .
    volumes:
      - './:/usr/src/app'
      - $HOME/.aws/credentials:/root/.aws/credentials:ro
      - $HOME/.aws/config:/root/.aws/config:ro
    ports:
      - "5000:5000"
    env_file:
      - ./envs/.env.${STAGE}
    environment:
      - FLASK_DEBUG=1
      - TZ=Asia/Tokyo
      - LC_CTYPE=C.UTF-8
      - PYTHONDONTWRITEBYTECODE=1
    command: pipenv run python -B -u /usr/src/app/api/app.py run -h 0.0.0.0
```

次に、Flaskの開発ファイルと、環境変数ファイルを作成します。

```sh
# 環境変数ファイルを作成
$ mkdir ./envs
$ touch ./envs/.env.dev
# Flask用のファイルを作成
$ mkdir ./api
$ touch ./api/app.py
```

ここまでで、以下のようなフォルダ構造になっていると思います。

```sh
$ tree . -a
.
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── api
│   └── app.py
├── docker-compose.yml
└── envs
    └── .env.dev
```

FlaskでREST APIを開発することができて、Swaggerのドキュメントを自動生成してくれるツールとして、今回は[Flask-RESTX](https://flask-restx.readthedocs.io/en/latest/)を使うことにしました。
ですので、これをインストールしておきます。
また、[Flask-CORS](https://flask-cors.readthedocs.io/en/latest/)も、CORS対策が必要なAPIとして、入れておきます。 

```sh
$ pipenv install flask-restx
$ pipenv install flask_cors
```

一度ここまでで、Flaskを実行してみます。
以下のように、`./api/app.py`を編集します。

```py:./api/app.py
import os

from flask import Flask, make_response, jsonify
from flask_cors import CORS
from flask_restx import Api, Resource

app = Flask(__name__)
api = Api(app)
CORS(app, resources={r'/*': {'origins': '*'}})

ns = api.namespace("api/v1", description="Flask Test")

@ns.route('/hello')
class Hello(Resource):

    def get(self):
        resp = {
            'status': 'ok',
            'response': "hello"
        }
        return make_response(jsonify(resp))

if __name__ == "__main__":
    app.run(host='0.0.0.0', debug=True)
```

そして、Docker Composeで、コンテナを立ち上げます。

```sh
# 頭のSTAGE変数で、環境変数ファイルを切り替えます。
STAGE=dev docker-compose up
```

そうすると、 http://localhost:5000/ にアクセスしてSwaggerドキュメントが自動生成されているのが確認できると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119813/1e0331fb-abc0-4d80-fb41-4a79d7b844f9.png)

あとは、Serverless Flameworkを導入して、APIを開発していけば良さそうです。

## セキュリティ面を考えたときの壁

セキュリティ面を考えると、APIに対してはAPIキーにて、認証をかけることができるとはいえ、Swaggerドキュメントにも認証をかけたいです。

よく使われるのが、Basic認証であり、調べると[API Gatewayに認証をかける記事](https://qiita.com/diaphragm/items/b87d700ef36fa62c1aa8)がいくつか見つかります。
これは、外部のLambdaをAPI呼び出しのときに実行するという方法ですが、本体のAPIとしては、APIキーでの認証が基本となるのでBasic認証は不要です。呼び出されたLambda側でAPIキーがある場合は無視するということも考えられますが、不要な呼び出しは実行したくありません。

そうなると、ドメインか、API Gatewayで本体のAPIとSwaggerドキュメントを分けてあげる必要があります。ドメインは分けたくないので、API Gatewayレベルで分けてあげるのが良さそうです。
API Gatewayレベルで切り分けるには、Serverless Frameworkのファイルを2つ作成して、API Gatewayのベースパスマッピングを分けてあげるといけそうです。※[参考](https://qiita.com/wata_miki/items/13f83f9e5fd84113a365)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119813/9decedd6-5f03-6811-82e6-de085ecfb969.png)


しかし、ここで1つの問題に直面しました。
なんと、[Flask-RESTX](https://flask-restx.readthedocs.io/en/latest/)には、Swaggerドキュメントを吐き出すパスを修正することができないのです！
[ここで](https://github.com/noirbizarre/flask-restplus/blob/1fe65ddae4c04315bd88535e3c2eaff381436270/flask_restplus/api.py#L253)、絶対パスで指定されており、 http://localhost:5000/swagger.json となっているのを、http://localhost:5000/swaggerui/swagger.json と変更することができません。
こうなると、API Gatewayでベースパスマッピングを変えても、Swaggerドキュメントの表示ができません。。。

そこで、無理やり変えてしまえばいいんじゃないか、ということで、上書きしちゃうことにしました。同じような話は、[ここ](https://github.com/noirbizarre/flask-restplus/issues/223)でもずっと議論されているみたいです。
放置されているようですし、修正の期待もなかなかできないので、なおさら無理やり変えるしかなさそうです。

以下のようにFlaskのファイルを書き換えてあげると、 http://localhost:5000/swaggerui/apidocs/ からSwaggerファイルが出力されるようになります。

```py:./api/app.py
import os

from flask import Flask, make_response, jsonify
from flask_cors import CORS
from flask_restx import Api, Resource
from flask_restx.api import SwaggerView
from flask_restx.apidoc import url_for

class Custom_API(Api):
    def _register_specs(self, app_or_blueprint):
        if self._add_specs:
            endpoint = str("specs")
            self._register_view(app_or_blueprint,SwaggerView,
                self.default_namespace,"/swaggerui/swagger.json",
                endpoint=endpoint,resource_class_args=(self,),
            )
            self.endpoints.add(endpoint)

app = Flask(__name__)
api = Custom_API(app, doc='/swaggerui/apidocs/', )
app.config.from_object(__name__)
CORS(app, resources={r'/*': {'origins': '*'}})

ns = api.namespace("api/v1", description="Flask Test")

@ns.route('/hello')
class Hello(Resource):

    def get(self):
        resp = {
            'status': 'ok',
            'response': "hello"
        }
        return make_response(jsonify(resp))

if __name__ == "__main__":
    app.run(host='0.0.0.0', debug=True)
```

## Basic認証用のLambdaを用意しておく

Serverless Flameworkに進む前に、Basic認証用のLambdaを準備しておきます。Swaggerドキュメント用のAPI Gatewayからは、このLambdaを呼び出して、Basic認証をかけます。
これは、普通にAWSのコンソールからLambdaを作成して、直接コードを編集しちゃっていいと思います。

```py:serverless-basic-authorizer
import json
import base64

accounts = [
    # dict形式のリストで複数のUser,Passwordを設定できます。
    {
        "user": "basic-user",
        "pass": "basic-path"
    }
]

def lambda_handler(event, context):
    
    print(event)

    authorization_header = event['headers']['authorization']

    if check_authorization_header(authorization_header):
        return {
            'principalId': 'user',
            'policyDocument': {
                'Version': '2012-10-17',
                'Statement': [
                    {
                        'Action': 'execute-api:Invoke',
                        'Effect': 'Allow',
                        'Resource': event['methodArn']
                    }
                ]
            }
        }
    else:
        raise ValueError('Unauthorized')

def check_authorization_header(authorization_header: str) -> bool:
    if not authorization_header:
        return False

    for account in accounts:
        encoded_value = base64.b64encode(f"{account['user']}:{account['pass']}").encode('utf-8'))
        check_value = "Basic {}".format(encoded_value.decode(encoding='utf-8'))

        if authorization_header == check_value:
            return True

    return False
```

## Serverless Frameworkの導入

ここまでできたら、API本体をデプロイするための`serverless.yml`と、Swaggerドキュメントをデプロイするようの`serverless-swagger.yml`を作成して、実際にデプロイしてみるのみです。

本体API用のymlです。FlaskをAPI Gateway＋Lambdaで提供するための、`serverless-python-requirements`、`serverless-wsgi`が入ってます。
また、`serverless-domain-manager`はドメインをServerless Frameworkで準備するために、`serverless-add-api-key`はAPIキー認証を入れるためです。
`serverless-dotenv-plugin`は、Dockerで開発するときにも使っていた、環境変数ファイルをAWS上でも反映するために使っています。

APIのエンドポイントを増やしたら、`functions > application > events`配下のエンドポイントを増やします。関数そのものを分けたい場合は、`functions`配下の`application`の部分から別名で分けて作成します。

```yml:serverless.yml
service: flask-serverless-api-template

plugins:
  - serverless-python-requirements
  - serverless-wsgi
  - serverless-domain-manager
  - serverless-add-api-key
  - serverless-dotenv-plugin

custom:
  stage: ${opt:stage, self:provider.stage}
  domains:
    dev: flask-serverless-api.test.com
    prod: flask-serverless-api.test.com
  pythonRequirements:
    dockerizePip: non-linux
  apiKeys:
    dev:
      - name: FlaskServerlessApiKey
        value: hogehoge
    prod:
      - name: FlaskServerlessApiKey
        value: fugafuga
  wsgi:
    app: api.app.app
    packRequirements: false
  customDomain:
    domainName: ${self:custom.domains.${self:custom.stage}}
    certificateName: ${self:custom.domains.${self:custom.stage}}
    basePath: "api"
    endpointType: regional
    stage: ${self:provider.stage}
    createRoute53Record: true
  dotenv:
    basePath: envs/

provider:
  name: aws
  runtime: python3.8
  region: ap-northeast-1
  stage: ${opt:stage, 'dev'}
  timeout: 60

package:
  exclude:
    - .vscode/**
    - envs/**
    - node_modules/**
    - notebooks/**
    - Dockerfile
    - docker-compose.yml
    - terraform/**


functions:
  application:
    handler: wsgi_handler.handler
    events:
      - http:
          path: /v1/hello
          method: get
          cors: true
```

次に、Swaggerドキュメント用のものも作成します。これはエンドポイント増やしても、中身を変える必要はありません。apidocsの認証部分に、先程作成したBasic認証用のLambdaを指定して、Basic認証を実現します。

```yml:serverless-swagger.yml
service: flask-serverless-api-template-swagger

plugins:
  - serverless-python-requirements
  - serverless-wsgi
  - serverless-domain-manager
  - serverless-dotenv-plugin

custom:
  pythonRequirements:
    dockerizePip: non-linux
  wsgi:
    app: api.app.app
    packRequirements: false
  customDomain:
    domainName: "flask-serverless-api.test.com"
    certificateName: "flask-serverless-api.test.com"
    basePath: "swaggerui"
    endpointType: "regional"
    stage: ${self:provider.stage}
    createRoute53Record: false
  dotenv:
    basePath: envs/

provider:
  name: aws
  runtime: python3.8
  region: ap-northeast-1


package:
  exclude:
    - .vscode/**
    - envs/**
    - node_modules/**
    - notebooks/**
    - Dockerfile
    - docker-compose.yml
    - terraform/**


functions:
  openapidoc:
    handler: wsgi_handler.handler
    events:
      - http:
          path: /apidocs
          method: get
          authorizer:
            name: basic-authentication
            arn: arn:aws:lambda:ap-northeast-1:000000000000:function:serverless-basic-authorizer
            type: request
      - http:
          path: /swagger.json
          method: get


resources:
  Resources:
    GatewayResponse:
      Type: 'AWS::ApiGateway::GatewayResponse'
      Properties:
        ResponseParameters:
          gatewayresponse.header.WWW-Authenticate: "'Basic realm=\"Enter username and password.\"'"
        ResponseType: UNAUTHORIZED
        RestApiId:
          Ref: 'ApiGatewayRestApi'
        StatusCode: '401'
```

最終的なファイルやフォルダの構成は以下です。
```sh
$ tree . -a
.
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── api
│   └── app.py
├── docker-compose.yml
├── serverless.yml
├── serverless-swagger.yml
└── envs
    └── .env.dev
```

ここまでできたら、Serverless Frameworkの各コマンドを実行して、ドメインの作成〜Flaskのデプロイを実施します！

```sh
$ sls create_domain --stage dev --aws-profile flask-serverless
$ sls deploy --config serverless.yml --stage dev --aws-profile flask-serverless
$ sls deploy --config serverless-swagger.yml --stage dev --aws-profile flask-serverless
```

これで、自身のカスタムドメイン配下で、APIの実行と、Swaggerドキュメントの確認ができればOKです！

- API本体 -> https://xxxx.xxxx.xxxx/api/vi/<endpoint>
- Swagger -> https://xxxx.xxxx.xxxx/swaggerui/apidocs

## 最後に

いかがでしたでしょうか？APIドキュメントをマニュアルで作成するのは非常に手間なので、自動でドキュメント生成ができると嬉しいなということで、以上のようなものを考えました。
DB周りはここには書いてませんが、PartiQLでDynamoDBを使ったり、RDS Proxyなどを使うと、Serverless Flameworkでも、安心してDB接続ができるAPI開発ができると思いますので、是非チャレンジしてみてください。
明日は @qii-purine さんの記事「 python でのアーキテクチャを考える 」です。お楽しみに！