【Python】FirestoreのCRUD処理を行う Web API を Cloud Run Functions でさくっと実装

:::note warn
[Cloud Functions は2024年8月頃に Cloud Run Functions へと変更されたようです。](https://cloud.google.com/blog/products/serverless/google-cloud-functions-is-now-cloud-run-functions?hl=en)
:::

# 目的

Webアプリのモックアップを開発する際にバックエンドとデータベースをさくっと手軽に用意したいと思ったので実装してみました。
Google Cloud Platform の無料枠を存分に使ってやりましょう💰

# さくっと構築

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/9c966795-3c63-0c70-15f0-2a6979f6b6d0.png)

GCPプロジェクト配下にFirestoreのデータベース&コレクションと Cloud Run Functions の関数（ランタイム: Python 3.12）を作成していきます。

たぶん5分あれば終わります。

## Firestoreにデータベースを作成

モードはネイティブを選択してます。
データベースIDを `(default)` で作成すると無料枠の対象になるのでそのままにします。
なお今回はさくっと動作確認することが目的なので、セキュリティルールはテストルールです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/f4121cd7-a448-cf3f-29dc-0b7b7f33e2f5.png)

データベースが作成できたらコレクションと最初のドキュメントを作っておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/16d57c71-5137-164f-965b-2e08b46324f6.png)

これにてFirestoreは準備完了。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/91731cad-a761-21bd-6303-c9e59c605aef.png)

## Cloud Run Functions を作成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/47dbd9ec-08f4-ebe1-5471-0cb081222f15.png)

### 作成するコード

以下4つのメソッドを持つ関数を作成します。

| メソッド | CRUD | 処理 |
|-|-|-|
| POST | Create | リクエストされたJSONのキーと値を使ってドキュメントを作成。 |
| GET | Read | コレクション直下のドキュメントを全件取得。 |
| PUT | Update | リクエストされたJSONから `id` の値を取得し、該当するドキュメントを更新。 |
| DELETE | Delete | リクエストされたJSONから `id` の値を取得し、該当するドキュメントを削除。 |

:::note info
- GCPプロジェクトのIDは意味のある情報か否かが紛らわしいと思ったのでマスクしました、読み替えてください
- データベース名の指定が無いのは多分未指定だと `(default)` として扱われてるんじゃないかと思います（調べてないですごめんなさい）
- コレクション名はこの手順で作成した `myCollection` を指定してるのでこれも適宜読み替えてください
:::

```python:main.py
import functions_framework
from flask import jsonify
from google.cloud import firestore
from werkzeug.exceptions import MethodNotAllowed

db_ref = firestore.Client(project='********')  # MASKED
collection_ref = db_ref.collection('myCollection')

@functions_framework.http
def api(request):
    """Firestore CRUD Function.
    """
    if request.method == 'POST':  # Create
        _, doc_ref = collection_ref.add(request.get_json())
        return jsonify({'id': doc_ref.id}), 201

    elif request.method == 'GET':  # Read
        docs = [{d.id: d.to_dict()} for d in collection_ref.stream()]
        return jsonify(docs)
    
    elif request.method == 'PUT':  # Update
        params = request.get_json()
        doc_id = params.pop('id')
        collection_ref.document(doc_id).update(params)
        return jsonify({}), 204
    
    elif request.method == 'DELETE':  # Delete
        collection_ref.document(request.get_json().get('id')).delete()
        return jsonify({}), 204

    # Other methods are not allowed
    return jsonify({'message': MethodNotAllowed.description}), MethodNotAllowed.code
```

:::note warn
今回は単一のエンドポイントでCRUD処理を実装することを目的としています。
実際のバックエンド開発の場面では `PUT` と `DELETE` はパスで `id` を指定してリソースの階層と一致させたいところです。
:::

```text:requirements.txt
functions-framework==3.*
google-cloud-firestore
```

:::note info
- Cloud Run Functions はFlaskアプリケーションとして動作しているため `Flask` パッケージの指定は不要
- `Werkzweug` パッケージは `Flask` パッケージが依存しているため指定は不要
    - https://flask.palletsprojects.com/en/3.0.x/installation/#dependencies
:::

これをデプロイしたらシンプルなCRUD処理を行う Web API の完成です。

# 動作確認

:::note info
- リクエスト先はマスクしているので自身の Cloud Run Functions のURLを適用してください
- パスの `/api` は関数のエントリポイントなのでこれも適宜変更
:::

まずはCreateしてきます。

```shell
$ curl -X POST -H "Content-Type: application/json" -d '{"title":"foo"}' https://********.********.run.app/api
{"id":"nzNWqien4pzAjM72LY30"}
```
ちゃんと作成されていました。
リクエストパラメータやレスポンス( `id` )と一致してます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/f8760f38-8781-459a-cdef-957a456b9ebe.png)

次はRead。
これも上記のスクショと一致してるのでOK。

```shell
$ curl -X GET https://********.********.run.app/api
[{"FirstDoc":{"foobar":"bazqux"}},{"nzNWqien4pzAjM72LY30":{"title":"foo"}}]
```

3つめはUpdate。
Createで作成したドキュメントの `title` を更新します。 

```shell
$ curl -X PUT -H "Content-Type: application/json" -d '{"id":"nzNWqien4pzAjM72LY30","title":"bar"}' https://********.********.run.app/api
```

ちゃんと更新されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/c4c8ea6a-f590-61e0-8180-632ebda2714b.png)

最後はDelete。
作成、更新したドキュメントを削除します。

```shell
$ curl -X DELETE -H "Content-Type: application/json" -d '{"id":"nzNWqien4pzAjM72LY30"}' https://********.********.run.app/api
```

消えました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203013/f8bf4ef5-766f-4821-b8ee-d0947b45eb11.png)

おまけでそれ以外のメソッド。
ちゃんとエラーメッセージが応答してます。

```shell
$ curl -X OPTIONS https://********.********.run.app/api
{"message":"The method is not allowed for the requested URL."}
```

# おしまい

触った所感、簡単なモックアップとか作るときにバックエンドとデータベースが必要な場面で力を発揮しそうです。無料枠で使えるし。

あと今回の実装の過程で [`google-cloud-firestore`](https://pypi.org/project/google-cloud-firestore/) パッケージのソースコードを結構読み込んだので、コードリーディングと各クラスで使えるメソッドもそのうち記事にまとめようと思います。（「思います」なんで多分思うだけで終わります）

さいごに、画面キャプチャでGCPプロジェクトのIDが見えてたり関数のURLが見えてたりしてますが、この記事が世に出ることにはいずれも抹消しているのでマスクせず公開してます。
みなさん情報管理には十分気をつけてくださいね。