mongoengineでmongodbにデータを作成する

- [初めに](#初めに)
- [概要](#概要)
- [この記事のゴール](#この記事のゴール)
- [使用するツール](#使用するツール)
- [実行環境](#実行環境)
- [ディレクトリ構成](#ディレクトリ構成)
- [テストの作成](#テストの作成)
- [MongoDBのドキュメントモデルを作成する](#MongoDBのドキュメントモデルを作成する)
- [ユーザー作成時の入力値の型を設定する](#ユーザー作成時の入力値の型を設定する)
- [ユーザー情報をDBに作成する機能を実装する](#ユーザー情報をdbに作成する機能を実装する)
- [DBに接続する際に必要な値を設定ファイルに記載する](#dbに接続する際に必要な値を設定ファイルに記載する)
- [テストに必要な処理を作成する](#テストに必要な処理を作成する)
- [動作確認用のコンテナを作成する](#動作確認用のコンテナを作成する)
- [テストの実行](#テストの実行)
- [パスワードのハッシュ化](#パスワードのハッシュ化)
- [備考](#備考)

## 初めに

普段APIの開発や運用をしていますが、APIがWebアプリでどのように利用されているかを全くイメージできませんでした。
そのため、APIをいくつか作成し、簡単なWebページをhtmlで実装して連携させてみたいと思います。

## 概要

下記のようなwebページを作りたいです。

- ユーザー登録ページ
- ログインページ
- マイページ

そのために、数回に分けて下記のAPIと、それらを使用したwebページを作成していきます。

- ユーザーAPI
  - 作成機能    <-**今ここ**
  - 情報取得機能
  - 情報削除機能
- cookieAPI
  - 作成機能
- 現在のユーザーAPI
  - 取得機能

今回はユーザー作成APIのための、ユーザー情報をDBに作成する機能を作成していきます。

## この記事のゴール

ユーザー情報をDBに作成する機能を実装したいです。
ユーザー情報をMongoDBで管理することを考えています。
DBの操作はmongoengineを使用します。
ユーザーのパスワードはハッシュ化したいです。

完了条件は下記の通りです。

- ユーザー情報をDBに作成できる
- DBに作成されるユーザードキュメントにおけるパスワードはハッシュ化されている

## 使用するツール

- pipenv
<https://pipenv.pypa.io/en/latest/>

pipenv で python 3.10の仮想環境を作成します。

```sh
pipenv --python 3.10
```

## 実行環境

OS : macOS Monterey 12.6
使用言語 : python 3.10

## ディレクトリ構成

```sh
.
├── Pipfile
├── Pipfile.lock
├── app
│   ├── __init__.py
│   ├── core
│   │   ├── __init__.py
│   │   ├── config.py
│   │   └── security.py
│   ├── crud
│   │   ├── __init__.py
│   │   └── crud_user.py
│   ├── models
│   │   ├── __init__.py
│   │   └── user.py
│   ├── schemas
│   │   ├── __init__.py
│   │   └── user.py
│   └── tests
│       ├── __init__.py
│       ├── conftest.py
│       └── crud
│           ├── __init__.py
│           └── test_user.py
├── container_setup.sh
├── docker-compose.yaml
└── requirements.txt
```

## テストの作成

ユーザー情報をDBに作成する機能を実装するために、テストを作成します。

```python: app/tests/crud/test_user.py
from app import crud
from app.schemas.user import UserCreate


def test_create_user(random_email, random_lower_string) -> None:
    email = random_email()              # ランダムなemailアドレスを作成する関数
    password = random_lower_string()    # ランダムな文字列を作成する関数
    user_in = UserCreate(email=email, password=password)
    user = crud.user.create(obj_in=user_in)
    assert user.email == email
    assert hasattr(user, "hashed_password")
```

## MongoDBのドキュメントモデルを作成する

ユーザードキュメントが持つことができるフィールドと、格納するデータの種類を定義します。

```python: app/models/user.py
from mongoengine import Document
from mongoengine.fields import StringField, BooleanField, EmailField


class User(Document):
    uuid = StringField(unique=True, required=True)
    email = EmailField(unique=True, required=True)
    hashed_password = StringField(required=True)
    is_active = BooleanField(required=True)
    is_superuser = BooleanField(required=True)

    meta = {
        'db_alias': 'mongodb',          # ドキュメントのエイリアスを定義
        'collection': 'my_collection',  # ドキュメントを作成するコレクションを定義
    }
```

## ユーザー作成時の入力値の型を設定する

BaseModelを使用して、ユーザー作成時の入力値の型を設定します。

```python: app/schemas/user.py
from typing import Optional
from pydantic import BaseModel, EmailStr


# ユーザーを操作する際の共通の項目と型
class UserBase(BaseModel):
    email: Optional[EmailStr] = None
    is_active: Optional[bool] = True
    is_superuser: bool = False


# ユーザーを作成する際の入力項目の型
class UserCreate(UserBase):
    email: EmailStr
    password: str
```

## ユーザー情報をDBに作成する機能を実装する

uuidは情報をDBに登録する際に自動生成します。

```python: app/crud/crud_user.py
from uuid import uuid4

from app.models.user import User
from app.schemas.user import UserCreate


class CRUDUser:
    def create(self, obj_in: UserCreate) -> User:
        db_obj = User(
            uuid=uuid4().hex,
            email=obj_in.email,
            hashed_password=obj_in.password,
            is_active=obj_in.is_active,
            is_superuser=obj_in.is_superuser,
        )
        db_obj.save()

        return db_obj


user = CRUDUser()
```

```python: app/crud/__init__.py
from .crud_user import user  # noqa
```

## DBに接続する際に必要な値を設定ファイルに記載する

DBに接続する際に必要な値を設定ファイルに記載します。

```python: app/core/config.py
from pydantic import BaseSettings


class Settings(BaseSettings):
    # datastore settings
    DB_USER: str
    DB_PASSWORD: str
    DB_HOST: str
    DB_PORT: int
    DB_NAME: str = 'my_db'


settings = Settings()
```

## テストに必要な処理を作成する

テストに必要な処理を`conftest.py`に記載します。

```python: app/tests/conftest.py
import random
import string
import pytest

from mongoengine import connect, disconnect

from app.core.config import settings

# ランダムな文字列を生成する関数
def _random_lower_string():
    return "".join(random.choices(string.ascii_lowercase, k=32))


@pytest.fixture(scope='function')
def random_lower_string():
    return _random_lower_string()


@pytest.fixture(scope='function')
def random_email():
    return f"{_random_lower_string()}@{_random_lower_string()}.com"


# dbへの接続と切断を行うpytest fixture
@pytest.fixture(scope='session', autouse=True)
def init_db():
    connect(
        host=settings.DB_HOST,
        port=settings.DB_PORT,
        username=settings.DB_USER,
        password=settings.DB_PASSWORD,
        db=settings.DB_NAME,
        uuidRepresentation="standard",
        alias='mongodb'
    )
    yield
    disconnect(alias='mongodb')
```

## 動作確認用のコンテナを作成する

作成した機能の動作確認をするために、MongoDBとそのデータを見るためのツールのコンテナを作成します。
`${}`の値は`.env`ファイルから読み込まれます。

```yaml: docker-compose.yaml
version: "3.8"

services:
  mongo:
    image: mongo:5.0
    restart: always
    ports:
      - "${DB_PORT}:${DB_PORT}"
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${DB_USER}
      MONGO_INITDB_ROOT_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mongo-database:/database

  mongo_express:
    image: mongo-express:1.0.0-alpha.4
    restart: always
    ports:
      - "8081:8081"
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: ${DB_USER}
      ME_CONFIG_MONGODB_ADMINPASSWORD: ${DB_PASSWORD}
      ME_CONFIG_MONGODB_SERVER: mongo
    depends_on:
      - mongo # mongoコンテナが起動してから起動させる
volumes:
  mongo-database:
```

コンテナの作成と`.env`ファイルの作成を行うshellスクリプトを作成します。

```sh: container_setup.sh
#!/bin/sh

echo DB_USER=root >.env
echo DB_PASSWORD=password >>.env
echo DB_HOST=localhost >>.env
echo DB_PORT=27017 >>.env

docker-compose up -d
```

schellスクリプトを実行します。

```sh
sh container_setup.sh
```

## テストの実行

仮想環境を作成します。

```sh
pipenv --python 3.10
```

テスト実行に必要なパッケージを記載します。

```txt: requirements.txt
mongoengine
pydantic[email]
typing

# test package
pytest
```

テスト実行に必要なパッケージをインストールします。

```sh
pipenv install -r requirements.txt
```

作成したテストを実行します。

```sh
% pipenv run pytest
Loading .env environment variables...

~~~

app/tests/crud/test_user.py . 
```

テストが成功しました。
dbに作成されたデータを確認してみます。

ブラウザで<http://localhost:8081>にアクセスすると
mongo-express(MongoDBのデータを表示するツールに)のUIが表示されます。
この`my_db`の`my_collection`を確認するとデータが登録されていることがわかります。

## パスワードのハッシュ化

パスワードのハッシュ化を実装するためにテストを変更します。

```diff python: app/tests/crud/test_user.py
from app import crud
from app.schemas.user import UserCreate


def test_create_user(random_email, random_lower_string) -> None:
    email = random_email                # ランダムなemailアドレスを作成する関数
    password = random_lower_string      # ランダムな文字列を作成する関数
    user_in = UserCreate(email=email, password=password)
    user = crud.user.create(obj_in=user_in)
    assert user.email == email
    assert hasattr(user, "hashed_password")
+    assert user_in.password != user.hashed_password
```

入力したパスワードが平文でDBに保存されていないことをテストします。
もちろんテストは失敗します。

```sh
% pipenv run pytest
Loading .env environment variables...

~~~

app/tests/crud/test_user.py F 
```

ハッシュ化する機能を実装するために、文字列のハッシュ化を行う関数を作成します。

```python: app/core/security.py
from passlib.context import CryptContext


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)
```

パスワードをハッシュ化する機能を、ユーザー情報作成機能に実装します。

```diff app/crud/crud_user.py
from uuid import uuid4

from app.models.user import User
from app.schemas.user import UserCreate
+from app.core.security import get_password_hash


class CRUDUser:
    def create(self, obj_in: UserCreate) -> User:
        db_obj = User(
            uuid=uuid4().hex,
            email=obj_in.email,
+            hashed_password=get_password_hash(obj_in.password),
            is_active=obj_in.is_active,
            is_superuser=obj_in.is_superuser,
        )
        db_obj.save()

        return db_obj


user = CRUDUser()
```

動作に必要なパッケージを追加し、インストールします。

```diff txt: requirements.txt
mongoengine
pydantic[email]
typing
+passlib[bcrypt]

# test package
pytest
```

```sh
pipenv install -r requirements.txt
pipenv run pytest
```

テストが成功したので実装は完了です。

## 備考

作成した仮想環境やコンテナ、コンテナイメージは下記のコマンドで削除できます。

```sh
pipenv --rm
docker-compose down --rmi all --volumes --remove-orphans
```