FastAPI + MongoDB のバックエンドAPIを構築し、ネストされたコレクションの読み書きをする

## 概要

前回と前々回を通して、FastAPI ＋　SQLAlchemyでRDSテーブルのリレーションいろいろ加味した
バックエンドAPIの構築を実装しました。

今回は、その続編という意味で、MongoDBとも繋げてみたので、
そのときの備忘録をまとめていきたいと思います。

この記事のプログラムは、

https://github.com/kenmaro3/fastapi_mongodb

こちらにアップロードしています。

## 今回の構成

今回は、
ルートにあるコレクションを`users`とし、
`users`が持つドキュメント（各ユーザの情報）の中に、
ネストした形で`files`というコレクションがある、
さらに、それぞれの `file` に対して、`results` というコレクションがある、
という構成にします。

つまり、

```
users -- uid1 -- name
       |        |- files --- uid1 -- results -- uid1
       |                  |--uid2 
       |-uid2 --name
               |- files --- uid1
                         | --uid2

```

みたいな感じで、firebase のfirestore で情報をどんどんネストさせていくような構成を、
FastAPI + MongoDBで実装したいと思い、やってみた、という構成です。

## Schemas 

MongoDBのNoSQLの構成でも、
pydantic を用いたschema の構成を行います。

https://github.com/kenmaro3/fastapi_mongodb/blob/main/api/schemas/user.py

こちらのファイルを見ていただくと、

`UserBase` と、`FileBase`、`ResultBase`　が存在します。
これらがそれぞれ先述のコレクションの中身になっています。

今回は、ファイルを分けるところまでやらなかったため、user.py に全てのスキーマを
書いていますが、これらをそれぞれ別のファイルに分けることが望ましいかと思います。


```api/schemas/user.py

class UserBase(BaseModel):
    id: id_model.PyObjectId = Field(default_factory=id_model.PyObjectId, alias="_id")
    name: str
    password: str
    files: Optional[List[dict]]

    class Config:
        allow_population_by_field_name = True
        arbitrary_types_allowed = True
        json_encoders = {ObjectId: str}
        schema_extra = {
            "example": {
                "name": "user",
                "password": "1234",
            }
        }

```

以上をみると、コレクションのネストについては、

`files: Optional[List[dict]]` の1行で定義しています。

同様に、`FilesBase`に対しても、

```api/schemas/user.py
class FileBase(BaseModel):
    id: id_model.PyObjectId = Field(default_factory=id_model.PyObjectId, alias="_id")
    path: str
    updated_ts: str
    results: Optional[dict]

    class Config:
        allow_population_by_field_name = True
        arbitrary_types_allowed = True
        json_encoders = {ObjectId: str}
        schema_extra = {
            "example": {
                "path": "1.png",
                "updated_ts": "2022-11-09",
            }
        }

```

というように `results`をネストしており、その対象はdictになっています。


## Cruds

https://github.com/kenmaro3/fastapi_mongodb/blob/main/api/routers/user.py

今回は、きちんと `router` と`cruds`に機能を分散せず、
多くを `router`に直接書いています。

全てのエンドポイントに対して解説するのもあまり意味がないので、
以下のエンドポイントに対して少し詳細を見てみます。

```api/routers/user.py
@router.put("/{id}/files", response_description="Add a file to user", response_model=user_schema.User)
async def update_user(id: str, user_add_file: user_schema.UserAddFileRequest = Body(...)):
    target_user = await db["users"].find_one({"_id": id})
    if target_user is None:
        raise HTTPException(status_code=404, detail=f"user {id} not found")
    file_to_be_added = jsonable_encoder(user_add_file)
    if target_user.get("files") is None:
        print("file is None")
        target_user["files"] = [file_to_be_added]
        update_result = await db["users"].update_one({"_id": id}, {"$set": target_user})
        changed_user = await db["users"].find_one({"_id": id})
        return changed_user
    else:
        assert(isinstance(target_user.get("files"), list))
        target_user.get("files").append(file_to_be_added)
        update_result = await db["users"].update_one({"_id": id}, {"$set": target_user})
        changed_user = await db["users"].find_one({"_id": id})
        return changed_user

```

読んだ通りのプログラムなのですが、
まず、ファイルを追加したいターゲットとなるユーザを取得し、
そのユーザ対して、Fileオブジェクトをエンコードしたもの（jsonable_encoderを用いています）
を追加したあと、アップデートをかけています。

次に、`result` を`file`に追加する時です。

```api/routers/user.py
@router.put("/{id}/files/{file_id}/results", response_description="Add a result to file", response_model=user_schema.User)
async def update_user(id: str, file_id: str, user_add_result: user_schema.UserAddResultRequest = Body(...)):
    target_user = await db["users"].find_one({"_id": id})
    if target_user is None:
        raise HTTPException(status_code=404, detail=f"user {id} not found")
    print("\n\nhere")
    print(target_user)
    result_to_be_added = jsonable_encoder(user_add_result)

    target_file = None
    if target_user.get("files") is None:
        raise HTTPException(status_code=404, detail=f"no file found for user {id}")
        print("file is None")
        target_user["files"] = [file_to_be_added]
        update_result = await db["users"].update_one({"_id": id}, {"$set": target_user})
        changed_user = await db["users"].find_one({"_id": id})
        return changed_user
    else:
        for i, file in enumerate(target_user.get("files")):
            if file.get("_id") == file_id:
                target_file = file
                break
        if target_file is None:
            raise HTTPException(status_code=404, detail=f"no such a file found for user {id}")
        else:
            if target_file.get("results") is None:
                #target_file["results"] = [result_to_be_added]
                #target_user["files"]["results"] = [result_to_be_added]
                target_user["files"][i]["results"] = [result_to_be_added]
            else:
                assert(isinstance(target_user["files"]["results"], list))
                target_user["files"][i]["results"].append(result_to_be_added)
        
        update_result = await db["users"].update_one({"_id": id}, {"$set": target_user})
        changed_user = await db["users"].find_one({"_id": id})
        return changed_user

```

無駄な書き方をしているかもしれないですが、
やっていることは単純です。

ターゲットとしているユーザが所持する`file`を取得し、
それらの`file`の中で、`result`を追加する`file`を選びます。

選んだ後は、`user`に`file`を追加した時と同じように、
`file`に`result`を追加し、DBをアップデートします。


データの挿入ができたら、SwaggerUIを使って実際にデータが挿入できているかを
GETリクエストをかけて確認してみましょう。

できていたら簡易的な実装はこれで完了です。お疲れ様でした。


## まとめ

今回は
FastAPI + Motor (Mongodb ドライバ） + MongoDB
の構成で、コレクションがネストされた構成のデータの読み書きを行ってみました。

案外このような構成はすぐには見つからなかったので、
MongoDBとFastAPIを組み合わせて見ようと思っている方がいらっしゃったら参考になれば幸いです。



これからもFastAPIなどのバックエンドを用いていろいろ開発したりできたらなと思います。
Prismaとかも聞いたことしかないので触ってみようかな。。




今回はこの辺で。


@kenmaro