GAE Standard で API Server を作ってみる with Python3.7, Flask

# 0. 前準備
※Google Cloud SDKの導入まで済んでいる前提です。
未導入な場合、拙記事で申し訳ないですが以下が参考になるかもしれません。

- https://qiita.com/leo1109/items/285b898ecf73621cbc17

また、作業を進めるにはPython3.7の環境が必要です。pyenvについては以下の記事がとても参考になります。

- https://qiita.com/Kodaira_/items/feadfef9add468e3a85b

## 1. 雛形の作成


```shell
$ ls
LICENSE                 deploy.sh               main.py
app.yaml                gunicorn.conf.py        requirements.txt
```

以下を参考に、ファイル構成が上記のようになるようにします。

- クイックスタート
  - 
https://cloud.google.com/appengine/docs/standard/python3/quickstart?hl=ja
- ソースコード
  - https://github.com/GoogleCloudPlatform/python-docs-samples/tree/master/appengine/standard_python37/hello_world

### 1-2. ファイルの中身

main.pyはサーバのコードです。
単純なJSONレスポンスを返すだけのルートを作っておきます。

```main.py
from flask import Flask
import json

app = Flask(__name__)


@app.route('/')
def hello():
    return json.dumps({'key': 'value'})


if __name__ == '__main__':
    app.run()
```

app.yamlはデプロイ用の設定ファイル。

- serviceはデプロイする際のサービス名（ネームスペースのようなもの）を指します。
- 任意の文字列を指定できますが、defaultサービスのインスタンスが1個以上生成されていることが条件になります。
- max_instancesはスケールアウト時の最大インスタンス数です。推奨設定では何も書かなくて良いですが、意図しない課金を防ぎたい場合は1や0 (0は最大1になるが、アイドル時は0になる） にしておきます。

```app.yaml
runtime: python37
service: default
entrypoint: gunicorn -b :$PORT main:app --config gunicorn.conf.py

automatic_scaling:
  max_instances: 1
```

gunicornを利用するので、gunicorn用の設定ファイルを用意。

```gunicorn.conf.py
import multiprocessing

# Worker Processes
workers = 2
worker_class = 'sync'

# Logging
loglevel = 'info'
logconfig = None
```

必要なライブラリリスト。 
`pip install Flask gunicorn` としてから、 `pip freeze > requirements.txt` としても良いです。

```requirements.txt
Flask
gunicorn
```

デプロイ用のスクリプト。
`--project プロジェクト名` は省略しても構いません。
`--version` を指定するとデプロイされたインスタンスのバージョン名を指定できます。
これにより、複数のバージョンがAppEngine上で作られないようにできます（インスタンスを最大1に保つため）。

```deploy.sh
gcloud app deploy --project <PROJECT_NAME: プロジェクト名> --version main
```

### 1.3 デプロイ

上記で作成した `deploy.sh` を実行すればデプロイができます。
`gcloud app browse` を実行すると、gcloudの設定情報に読み込まれたプロジェクト名を使ってページを開くことができます。


```bash
$ ./deploy.sh
Services to deploy:

descriptor:      [/path/to/server-root/app.yaml]
source:          [/path/to/server-root]
target project:  [<プロジェクト名>]
target service:  [default]
target version:  [main]
target url:      [https://<プロジェクト名>.appspot.com]
Do you want to continue (Y/n)?  y

Beginning deployment of service [default]...
╔════════════════════════════════════════════════════════════╗
╠═ Uploading 4 files to Google Cloud Storage                ═╣
╚════════════════════════════════════════════════════════════╝
File upload done.
Updating service [default]...done.
Setting traffic split for service [default]...done.
Deployed service [default] to [https://<プロジェクト名>.appspot.com]

You can stream logs from the command line by running:
  $ gcloud app logs tail -s default

To view your application in the web browser run:
  $ gcloud app browse
```

以下のように表示されたら、デプロイが成功しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195490/dd16bd6e-ae3d-84d2-7dba-d046987d55f2.png)

GCPコンソールの AppEngineで、サービス名 `default` 、バージョン名 `main` でインスタンスが作られていることを確認します。
- https://console.cloud.google.com/appengine/versions

## 2. 認証、エンドポイントの追加
上記でAPIサーバの雛形は完成していますが、実際に利用する際に必要な手順も進めておきます。

### 2-1. Digest認証
https://qiita.com/msrks/items/7de68cde6c3ab9d5e177 を参考にさせていただきました。

ライブラリを追加します。 `requirements.txt` の更新を忘れずに。

```bash
pip3 install flask_httpauth
pip3 freeze > requirements.txt
```

そして、サーバのソースコードを以下のように書き換えます。

```main.py
import json
import os

from flask import Flask
from flask_httpauth import HTTPDigestAuth

app = Flask(__name__)

app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
auth = HTTPDigestAuth()

# キーがID, バリューがパスワードになります。書き換え推奨
users = {
    'user': 'passwowrd'
}


@auth.get_password
def get_pw(username):
    if username in users:
        return users.get(username)
    return None


@app.route('/')
@auth.login_required
def index():
    return json.dumps({'authenticate': 'success'})


if __name__ == '__main__':
    app.run()
```

`os.environ['SECRET_KEY']` は、環境変数 `SECRET_KEY` から値を取得するという意味です。
app.yamlに `env_variables` キーを追加することで、環境変数を読ませることができます。

```app.yaml
runtime: python37
service: default
entrypoint: gunicorn -b :$PORT main:app --config gunicorn.conf.py

automatic_scaling:
  max_instances: 1

env_variables:
  SECRET_KEY: <SECRET_KEY>
```

また、ローカルで起動する場合、対象の環境変数がない場合はエラーになってしまうので、以下のようなシェルスクリプトを使って、起動する際に `SECRET_KEY` がセットされている状態にしておきましょう。

```run_local.sh
export SECRET_KEY=<SECRET_KEY>
gunicorn -b :8000 main:app --config gunicorn.conf.py
```

ローカル、あるいはデプロイされたURLを開いて、ID/PASSを求められるウィンドウが出たら成功です。

- パスワードは `main.py` で設定したものを入力して下さい。
- 認証のID、パスワードについても環境変数にしておくのがおすすめです。

## 2-2. POSTメソッドに対応する

GET以外のメソッドに対応するには、 `@app.route` に `methods` キーを追加します。（省略するとGETメソッドのみになります）
`main.py` を以下のように書き換えます。
`app.route` で指定している関数名がかぶってしまうとエラーになるので、違う名前をつけましょう。（以下では `index_get, index_post` に変更している）

```main.py
import json
import os

from flask import Flask
from flask_httpauth import HTTPDigestAuth

app = Flask(__name__)

app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
auth = HTTPDigestAuth()

users = {
    'user': 'password',
}


@auth.get_password
def get_pw(username):
    if username in users:
        return users.get(username)
    return None


@app.route('/get')
@auth.login_required
def index_get():
    data = {
        'authenticate': 'success',
        'requestType': 'get',
    }
    return json.dumps(data)

@app.route('/post', methods=['POST'])
@auth.login_required
def index_post():
    data = {
        'authenticate': 'success',
        'requestType': 'post',
    }
    return json.dumps(data)


if __name__ == '__main__':
    app.run()
```

作成した `index_post` に実際にアクセスできるかを確認します。
コマンドラインからでも確認できますが、このような場合は `Postman` というツールが非常に便利です。

- https://www.getpostman.com/downloads/

postのエンドポイントに対してGETを試みると、 `405` エラーが取得できました。これは `Method Not Allowed` エラーで、文字通り「このエンドポイントでは `GET` は許可されていないよ、という意味です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195490/c46e654b-9fad-5d35-c492-c55681e827fb.png)

`methods` キーには複数のメソッドも追加できるので、例えば以下のようにすると同じエンドポイントでもHTTPメソッドによって処理を変更できます。

```python
# requestのインポートを追加
from flask import Flask, request, abort

@app.route('/post', methods=['POST', 'GET', 'PUT', 'DELETE', 'PATCH'])
def restful():
    if request.method == 'GET':
        _get()
    elif request.method == 'POST':
        _post()
    elif request.method == 'PUT':
        _put()
    elif request.method == 'DELETE':
        _delete()
    elif request.method == 'PATCH':
        _patch()
    # (書かなくても良い) それ以外はMethod not allowed
    abort(405)
```

HTTPメソッドをPOST（URL入力バーの左側にあるボタン）に変更してSendを押すと、今度は想定するレスポンスが得られました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195490/bd7cf089-1b92-d18b-98d4-ff06af5557e3.png)

## 3. パラメータの処理

### 3-1. クエリパラメータ

`https://..../?a=b` のようなパラメータを取得する場合、 `request.args` を利用します。
importに `request` を追加するのを忘れずに。


```python
from flask import request

def index_get():
    # "?q=b"のbを取得, 存在しなかったら空文字として対処する
    param = request.args.get('q') or ''
```


### 3-2. リクエストボディ
リクエストボディを処理する場合、 `request.get_data()` を利用します。

- 参考にさせていただきました。 https://qiita.com/fetaro/items/97c4054dafa8aac10541

```python
import json

@app.route('/post', methods=['POST'])
@auth.login_required
def post():
    param = request.get_data().decode()
    body = json.loads(param)
    # 辞書として扱える
    key = body.get('key') or ''
```

Postman側で試すと、取得できていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195490/55a89aec-58da-7f82-c8cf-d6435b64daef.png)

## まとめ
Flaskを利用してAPIサーバを構築し、GAEのデプロイまで実施できるようにしました。