FastAPI * MySQL * DockerでCRUDを行うAPIをサクッと作ってみた（初学者向け）


# 目次
[1.はじめに](#1-はじめに)
[2.DB構築](#2-db構築)
[3.アプリ構築](#3-アプリ構築)
[4.動作確認](#4-動作確認)
[5.まとめ](#5-まとめ)


# 1. はじめに

https://qiita.com/yota_dev/items/ab8dea7f71c8a130d5bf

[以前の記事](https://qiita.com/yota_dev/items/ab8dea7f71c8a130d5bf "PythonでAPIを爆速で構築してみた")でPythonのフレームワーク(FastAPI)を用いて超シンプルなREST APIの作り方を記載した。そこではDockerコンテナの作成まで記したが、機能としてはHTTPリクエストを受け付けるだけの内容となっている。

本記事では、DBへ接続しSQL実行を行う処理とローカル環境での検証用にDBコンテナの構築をし、ローカルPC内で動作確認ができる状態までについて掲載する。

Docker Containerを複数立ち上げて疎通をする場合、他の方の記事だとdocker-composeを利用する内容が多いが、本記事では利用しない。***知らないことを調べたら別の知らない内容が出てきた***という再帰的サイクルはどこかでbreakしないと学ぶことを苦に感じてしまうからだ。

RDBMSはMySQLを採用。
個人的にはOracleやpostgresを使ってみたい所だが、AWSのRDB(Amazon RDS)のなかで無料で使えるものはMySQLぐらいしか無かったため、今後AWS上に構築する事を想定してローカル環境に閉じた本記事でもMySQLを使用している。

FastAPI自体にはRDB接続用のライブラリなどは無さそうなため、SQLAlchemyを採用。公式にもSQLAlchemyを利用したexampleがあるためこれを参考にした。

*[公式](https://fastapi.tiangolo.com/tutorial/sql-databases/#sql-relational-databases "SQL (Relational) Databases")より引用*
>FastAPI doesn't require you to use a SQL (relational) database.
But you can use any relational database that you want.
Here we'll see an example using SQLAlchemy.
You can easily adapt it to any database supported by SQLAlchemy, like:
> - PostgreSQL
> - MySQL
> - SQLite
> - Oracle
> - Microsoft SQL Server, etc.

本記事ではSQLAlchemyやMySQL自体の解説はしておらず、「とりあえずDBと繋げたい！」というゴールに向けた手順書にも似た記載なため、初学者向けかもしれない。


# 2. DB構築

まずは構成の説明。

```
.
└── db/
    ├── init.sql
    └── Dockerfile
```

## 2-1. initialze sql
DBコンテナを立ち上げた際に、テーブル作成や初期データ投入を行うために`init.sql`を作成しておく。

*よくある*内容を記載。

```sql:init.sql
USE sample_db;

DROP TABLE IF EXISTS item;
CREATE TABLE item
(
  item_id SERIAL PRIMARY KEY,
  name VARCHAR(50),
  price INT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

INSERT INTO item (name, price) VALUES ('apple', 100);
INSERT INTO item (name, price) VALUES ('orange', 200);
```

順に解説する。「*知ってるよ！*」という方は是非読み飛ばして頂きたい。

MySQL用の内容のため他RDBでは記述が異なることに注意。

- `sample_db`という名のデータベースを利用する。
    ```sql
    USE sample_db;
    ```
- 既に`item`というテーブルが存在すれば削除する。
    ```sql
    DROP TABLE IF EXISTS item;
    ```
- `item`テーブルを作成する。
    ```sql
    CREATE TABLE item
    (
        item_id SERIAL PRIMARY KEY,
        name VARCHAR(50),
        price INT,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );
    ```
    - `SERIAL` は`BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE`のこと。insertする時にVALUEを指定しなくとも自動で連番を振ってくれる。嬉しい。
    - `DEFAULT CURRENT_TIMESTAMP`はinsertするときにVALUEを指定しなくとも`CURRENT_TIMESTAMP`を格納してくれる。便利。
    - `ON UPDATE CURRENT_TIMESTAMP`はupdateするときにVALUEを指定しなくとも`CURRENT_TIMESTAMP`を格納してくれる。便利で嬉しい。
   
:::note warn
注意
現在日時の取得を、DB側で行うかアプリ側で行うかは要件や設計次第。
必ずしもDB側で現在日時を取得することをオススメしているわけではないのでご注意。
クラウド上の無料のDBを使用する場合はZoneの指定などできない可能性があり、アプリ側で現在日時を持ち回る方が良い場合もある。複数のアプリサーバーを使用するケースで全てのサーバーで全く同じ現在時刻を持ち回れない場合など、タイムサーバーとしてDBの現在日付を持ち回る方が良い場合もある。
:::

- データを格納する
    ```sql
    INSERT INTO item (name, price) VALUES ('apple', 100);
    ```
    - `item_id`と`created_at`と`updated_at`は自動で値が格納されるために値を指定していない。


## 2-2. MySQL Container

続いてDockerfileについて。

```Dockerfile:Dockerfile
FROM mysql:latest

ENV MYSQL_DATABASE sample_db
ENV MYSQL_USER sample_user
ENV MYSQL_PASSWORD sample_password
ENV TZ "Asia/Tokyo"
ENV MYSQL_ROOT_PASSWORD p@assw0rd

COPY ./init.sql /docker-entrypoint-initdb.d/init.sql
```

細かな環境変数についての説明は[公式のEnvironment Variables](https://hub.docker.com/_/mysql "Environment Variables")を参照してください。

ここで定義している内容をまとめると下記。rootアカウントについては割愛。

|項目|値|
|----|----|
|DB名|`sample_db`|
|ユーザ名|`sample_user`|
|パスワード|`sample_password`|
|タイムゾーン|`Asia/Tokyo`|

`/docker-entrypoint-initdb.d`にinit.sqlを格納することで、起動時に実行してくれる。

では早速imageをbuild。

```sh
cd db # Dockerfileのあるディレクトリまで移動
docker build -t sample-mysql:1.0.0 .
```

確認。

```sh
docker images sample-mysql:1.0.0    
REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
sample-mysql   1.0.0     95380b8fbf50   6 minutes ago   519MB
```

いらっしゃる。次にコンテナも立ち上げてみる。

```sh
docker run -d --name sample-mysql-container -p 3306:3306 sample-mysql:1.0.0
```

確認

```sh
docker ps -f "name=sample-mysql-container" 
CONTAINER ID   IMAGE                COMMAND                  CREATED          STATUS          PORTS                                                  NAMES
3c8f3c7429ba   sample-mysql:1.0.0   "docker-entrypoint.s…"   32 seconds ago   Up 31 seconds   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   sample-mysql-container
```

いた。次にコンテナ内に入り、`init.sql`内で記載したinsert通りにデータが存在するか確認する。

1. コンテナ内に入る。
    ```sh
    docker exec -it $(docker ps -q -f "name=sample-mysql-container") /bin/bash
    ```
2. dbに接続。
    ```sh
    mysql sample_db -h localhost -u sample_user -psample_password
    ```
3. `sample_db`が存在するか確認。
    ```sql
    mysql> show databases;
    +--------------------+
    | Database           |
    +--------------------+
    | information_schema |
    | sample_db          |
    +--------------------+
    2 rows in set (0.00 sec)
    ```
4. `item`テーブルが存在するか確認。
    ```sql
    mysql> show tables from sample_db;
    +---------------------+
    | Tables_in_sample_db |
    +---------------------+
    | item                |
    +---------------------+
    1 row in set (0.00 sec)
    ```
5. `item`テーブルの定義を確認。
    ```sql
    mysql> SHOW COLUMNS FROM item FROM sample_db;
    +------------+-----------------+------+-----+-------------------+-----------------------------------------------+
    | Field      | Type            | Null | Key | Default           | Extra                                         |
    +------------+-----------------+------+-----+-------------------+-----------------------------------------------+
    | item_id    | bigint unsigned | NO   | PRI | NULL              | auto_increment                                |
    | name       | varchar(50)     | YES  |     | NULL              |                                               |
    | price      | int             | YES  |     | NULL              |                                               |
    | created_at | timestamp       | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED                             |
    | updated_at | timestamp       | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
    +------------+-----------------+------+-----+-------------------+-----------------------------------------------+
    5 rows in set (0.01 sec)
    ```
6. レコードを確認
    ```sql
    mysql> select * from item;
    +---------+--------+-------+---------------------+---------------------+
    | item_id | name   | price | created_at          | updated_at          |
    +---------+--------+-------+---------------------+---------------------+
    |       1 | apple  |   100 | 2022-02-26 18:29:08 | 2022-02-26 18:29:08 |
    |       2 | orange |   200 | 2022-02-26 18:29:08 | 2022-02-26 18:29:08 |
    +---------+--------+-------+---------------------+---------------------+
    2 rows in set (0.00 sec)
    ```

無事構築が完了したので、`exit`でmysql/containerのコンソールを抜けておこう。

後にアプリの構築が完了した後、`sample-mysql-container`に接続するため立ち上げっぱなしでOK。


# 3. アプリ構築

まずは構成の説明。

```
.
├── db/
│   ├── init.sql
│   └── Dockerfile
└── api/
    ├── requirements.txt
    ├── Dockerfile
    ├── .env
    └── app/
        └── main.py
```

apiディレクトリを追加し、ここにアプリケーションを構築する、

## 3-1. requirements.txt

パッケージ一括管理用に`requirements.txt`を(使用する内容は少数だが)作成しておく。

```text:requirements.txt
sqlalchemy==1.4.31
mysqlclient==2.1.0
```

「`fastapi`と`uvicorn`は要らないの？」と思われるかもしれないが、今回使用するbaseimage(`uvicorn-gunicorn-fastapi`)の中には既にインストールされているため記載不要。


## 3-2. App

早速コーディングしてみる。
順に説明を記載するが、一旦全量を見たい方は[ソースコード全量](#ソースコード全量)をどうぞ。

### db接続

db接続周りの記述。

- db engineの作成。
    ```python:main.py
    import os
    from sqlalchemy import create_engine

    SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}:{}/{}'.format(
            os.environ.get("DB_USER"), 
            os.environ.get("DB_PASSWORD"),
            os.environ.get("DB_HOST"), 
            os.environ.get("DB_PORT"), 
            os.environ.get("DB_NAME")
        )

    engine = create_engine(SQLALCHEMY_DATABASE_URI)
    ```
    - 環境変数から接続情報を取得している。
      - 開発環境と本番環境でDBの接続情報が異なる場合がほとんど。
      - 「*開発で動いたのに本番で動かない！*」を避けるために全ての環境でソースコードが同じだという保証が欲しい。

- sessionの作成。
    ```python:main.py
    from sqlalchemy.orm import sessionmaker
    Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)

    def session():
        db = Session()
        try:
            yield db
        finally:
            db.close()
    ```
    - engineは上記で定義したもの。
    - sessionはAPI実行後に都度閉じたいため、finallyにcloseするmethodを定義。

- modelの作成
    ```python:main.py
    from sqlalchemy import Column, TIMESTAMP, Integer, String
    from sqlalchemy.ext.declarative import declarative_base
    from sqlalchemy.schema import FetchedValue

    Base = declarative_base(bind=engine)

    class Item(Base):
        __tablename__ = "item"
        __table_args__ = {"autoload": True}
        item_id = Column(Integer, primary_key = True, nullable=False)
        name = Column(String(50), nullable=False)
        price = Column(Integer, nullable=False)
        created_at = Column(TIMESTAMP, FetchedValue())
        updated_at = Column(TIMESTAMP, FetchedValue())
    ```
    - `Base` : モデルのベースクラスを定義。
    - `class Item(Base)`はORMで扱えるモデルクラスを定義。Itemテーブルに対してSQL文を書かずにCRUD処理を実現するために利用。
    - sqlalchemyではテーブルの*作成*も可能なようだが、既に作成済みなテーブルと連携するため`__table_args__ = {"autoload": True}`を設定している。
    - `FetchedValue()` : default valueが設定されている項目に対して、DB側の値を利用する場合に指定。この設定を行わないと insert時などに Nullが設定されてしまう。

### Controller(logic)

HTTPリクエストを受け付け、CRUD処理を行いレスポンスを返す処理を記述する。

細かい設定については[以前の記事](https://qiita.com/yota_dev/items/ab8dea7f71c8a130d5bf "PythonでAPIを爆速で構築してみた")の参照をオススメ。

- GET : SELECTする
    ```python:main.py
    from fastapi import Depends, Query, status
    from fastapi.responses import JSONResponse
    from fastapi.encoders import jsonable_encoder

    @app.get("/item")
    def get_item(id: int = None, name: str = Query(None, max_length=50), db: Session = Depends(session)):
        if id is not None:
            result_set = db.query(Item).filter(Item.item_id == id).all()
        elif name is not None:
            result_set = db.query(Item).filter(Item.name == name).all()
        else:
            result_set = db.query(Item).all()    
        response_body = jsonable_encoder({"list": result_set})
        return JSONResponse(status_code=status.HTTP_200_OK, content=response_body)
    ```
    - `name`は受付可能な最大文字数を50に設定。
    - `db: Session = Depends(session)` : api実行時にdb sessionを生成する。
    - `db.query(Item)` : `item`テーブルへのQuery実行。
      - `.filter(Item.COL == vaule)` : COL列の値がvalueと一致するものを対象とする。
      - `.all()` : 全件取得。
    - `jsonable_encoder({"list": result_set})` : json形式へ変更。
        ```json:sample
        {
            "list" : [
                {
                    //検索結果の1行
                },
                {
                    //検索結果の1行
                }
            ]
        }
        ```

- POST : INSERTする。
    ```python:main.py
    from pydantic import BaseModel
    from fastapi import Depends, Query, status
    from fastapi.responses import JSONResponse
    from fastapi.encoders import jsonable_encoder
    
    class ItemRequest(BaseModel):
        name: str = Query(..., max_length=50)
        price: float
    
    @app.post("/item")
    def create_item(request: ItemRequest, db: Session = Depends(session)):
        item = Item(
                    name = request.name,
                    price = request.price
                )
        db.add(item)
        db.commit()
        response_body = jsonable_encoder({"item_id" : item.item_id})
        return JSONResponse(status_code=status.HTTP_200_OK, content=response_body)
    ```
    - `class ItemRequest(BaseModel)` : postの際のrequest bodyを定義。
    - `db.add(item)` : insert処理定義。
    - `db.commit()` : commitする。ここで実際にinsertされる。また、`item`にinsert結果が反映される。自動生成された`item_id`などを取得することができる。

- PUT : UPDATEする。
    ```python:main.py
    from pydantic import BaseModel
    from fastapi import Depends, Query, status
    from fastapi.responses import JSONResponse
    from fastapi.encoders import jsonable_encoder
    
    class ItemRequest(BaseModel):
        name: str = Query(..., max_length=50)
        price: float
    
    @app.put("/item/{id}")
    def update_item(id: int, request: ItemRequest, db: Session = Depends(session)):
        item = db.query(Item).filter(Item.item_id == id).first()
        if item is None:
                return JSONResponse(status_code=status.HTTP_404_NOT_FOUND)
        item.name = request.name
        item.price = request.price
        db.commit()
        return JSONResponse(status_code=status.HTTP_200_OK)
    ```
    - `.first()` : 最初の1件をselectする。
    - selectした内容を`item`に格納する。
    - update対象が存在しなければ404を返すように設定。
    - itemの値を更新後、`db.commit()`を行いDB側に反映することでupdateする。


- DELETE : DELETEする。
    ```python:main.py
    from fastapi import Depends, Query, status
    from fastapi.responses import JSONResponse

    @app.delete("/item/{id}")
    def delete_item(id: int, db: Session = Depends(session)):
        db.query(Item).filter(Item.item_id == id).delete()
        db.commit()
        return JSONResponse(status_code=status.HTTP_200_OK)
    ```
    - `.delete()` : 文字通りdeleteする。

以上で、CRUD処理のAPIの実装が完了した。
下にソースコードの全量を記す。

### ソースコード全量
```python:main.py
import os
from fastapi import FastAPI, Depends, Query, status
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy import Column, TIMESTAMP, Integer, String, create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import FetchedValue


app = FastAPI()

SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}:{}/{}'.format(
        os.environ.get("DB_USER"), 
        os.environ.get("DB_PASSWORD"),
        os.environ.get("DB_HOST"), 
        os.environ.get("DB_PORT"), 
        os.environ.get("DB_NAME")
    )

engine = create_engine(SQLALCHEMY_DATABASE_URI)
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def session():
    db = Session()
    try:
        yield db
    finally:
        db.close()

Base = declarative_base(bind=engine)

# Entity Item
class Item(Base):
    __tablename__ = "item"
    __table_args__ = {"autoload": True}
    item_id = Column(Integer, primary_key = True, nullable=False)
    name = Column(String(50), nullable=False)
    price = Column(Integer, nullable=False)
    created_at = Column(TIMESTAMP, FetchedValue())
    updated_at = Column(TIMESTAMP, FetchedValue())

# Request Body
class ItemRequest(BaseModel):
    name: str = Query(..., max_length=50)
    price: float

# GetItemByName
@app.get("/item")
def get_item(id: int = None, name: str = Query(None, max_length=50), db: Session = Depends(session)):
    if id is not None:
        result_set = db.query(Item).filter(Item.item_id == id).all()
    elif name is not None:
        result_set = db.query(Item).filter(Item.name == name).all()
    else:
        result_set = db.query(Item).all()    
    response_body = jsonable_encoder({"list": result_set})
    return JSONResponse(status_code=status.HTTP_200_OK, content=response_body)

# CreateItem
@app.post("/item")
def create_item(request: ItemRequest, db: Session = Depends(session)):
    item = Item(
                name = request.name,
                price = request.price
            )
    db.add(item)
    db.commit()
    response_body = jsonable_encoder({"item_id" : item.item_id})
    return JSONResponse(status_code=status.HTTP_200_OK, content=response_body)

# UpdateItem
@app.put("/item/{id}")
def update_item(id: int, request: ItemRequest, db: Session = Depends(session)):
    item = db.query(Item).filter(Item.item_id == id).first()
    if item is None:
            return JSONResponse(status_code=status.HTTP_404_NOT_FOUND)
    item.name = request.name
    item.price = request.price
    db.commit()
    return JSONResponse(status_code=status.HTTP_200_OK)

# DeleteItem
@app.delete("/item/{id}")
def delete_item(id: int, db: Session = Depends(session)):
    db.query(Item).filter(Item.item_id == id).delete()
    db.commit()
    return JSONResponse(status_code=status.HTTP_200_OK)
```

## 3-4. 環境変数

次にコンテナに渡す環境変数を定義する。

```env:.env
DB_HOST=host.docker.internal
DB_PORT=3306
DB_NAME=sample_db
DB_USER=sample_user
DB_PASSWORD=sample_password
```

「*DBのホスト名はlocalhostじゃないの？*」と思うかも知れないが、上記の値はアプリコンテナ内で参照されるためlocalhostだと外に出れない。つまるところ、コンテナの中でのlocalhostは同コンテナ自身である。

`host.docker.internal`はdocker for Macでサポートされているコンテナからホスト上のサービスに対して接続するドメイン。Windowsでの利用、いつまでサポートされているか不明なため要確認。

*[公式](http://docs.docker.jp/v19.03/docker-for-mac/networking.html "コンテナからホスト上のサービスに対して接続したい")より引用*
>ホストの IP アドレスは変動します（あるいは、ネットワークへの接続がありません）。18.03 よりも前は、特定の DNS 名 host.docker.internal での接続を推奨していました。これはホスト上で内部の IP アドレスで名前解決します。これは開発用途であり、Docker Desktop forMac 外の本番環境では動作しません。

最新のversionだとオススメされていないのかしら？大人しくdocker composeを使用すれば明示的にホスト名を定義・指定できるが、主題ではないため割愛。


## 3-5. Api Container
次にDockerfile。

```Dockerfile:Dockerfile
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.9-slim

COPY ./requirements.txt /app/requirements.txt

RUN apt-get update && \
    apt-get -y install gcc libmariadb-dev && \
    pip install --no-cache-dir --upgrade -r /app/requirements.txt

COPY ./app /app
```

「`gcc`と`libmariadb-dev`は何？」という細かい話は割愛するが、これが無いと`mysqlclient`をインストール際にエラーとなってしまう。

実際に直面したエラーはこちら。

```
3.044 ERROR: Could not find a version that satisfies the requirement mysqlclient
```

docker container化せずローカルで遊んでみたいという方で、上記のようなエラーが出る場合も`gcc libmariadb-dev`の導入を試して欲しい。

では実際に動かしてみる。


```sh
cd ../api # Dockerfileのあるディレクトリまで移動
docker build -t sample-api:1.0.0 . 
```

確認する。

```sh
docker images sample-api:1.0.0               
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
sample-api   1.0.0     7ccc69b2d334   11 minutes ago   426MB
```

いた。次にコンテナを立ち上げる。

コンテナ立ち上げの際に、環境変数定義ファイルを渡す。

```sh
docker run -d --env-file=.env --name sample-api-container -p 80:80 sample-api:1.0.0
```

しっかりと確認。

```sh
docker ps -f "name=sample-api-container" 
CONTAINER ID   IMAGE              COMMAND       CREATED        STATUS                  PORTS                               NAMES
bcb5939e0146   sample-api:1.0.0   "/start.sh"   1 second ago   Up Less than a second   0.0.0.0:80->80/tcp, :::80->80/tcp   sample-api-container
```

いらっしゃる。ログをtailしてみる。


```sh
docker logs --tail=-1 -f $(docker ps -q -f "name=sample-api-container") 
#省略
[2022-02-26 11:30:36 +0000] [12] [INFO] Application startup complete.
```

起動している！

最後に、動作確認をする。


# 4. 動作確認

curlで動作確認を行う。

まずは全検索。

```sh
curl -v -X GET 'http://localhost:80/item'
# 省略
< HTTP/1.1 200 OK
< date: Sat, 26 Feb 2022 12:03:15 GMT
< server: uvicorn
< content-length: 233
< content-type: application/json
< 
* Connection #0 to host localhost left intact
{"list":[{"created_at":"2022-02-26T21:02:50","item_id":1,"updated_at":"2022-02-26T21:02:50","price":100,"name":"apple"},{"created_at":"2022-02-26T21:02:50","item_id":2,"updated_at":"2022-02-26T21:02:50","price":200,"name":"orange"}]}* Closing connection 0
```

とれた。jsonが見づらい場合はjqコマンドを併用すると便利。

```sh
curl -s -X GET 'http://localhost:80/item' | jq
{
  "list": [
    {
      "created_at": "2022-02-26T21:02:50",
      "item_id": 1,
      "updated_at": "2022-02-26T21:02:50",
      "price": 100,
      "name": "apple"
    },
    {
      "created_at": "2022-02-26T21:02:50",
      "item_id": 2,
      "updated_at": "2022-02-26T21:02:50",
      "price": 200,
      "name": "orange"
    }
  ]
}

```

綺麗、便利。

jqコマンドないよという方は `| jq` がなくてもOK。ただ見やすい方がいいので導入しておくことをオススメする。

さて、init.sqlで実行されたレコードが2件存在することが確認できた。さらにinsert文では指定していない`item_id`と`created_at`、`update_at`が自動で生成されていることも確認できた。


次に id指定で検索してみる。

```sh
curl -s -X GET 'http://localhost:80/item?id=1' | jq
{
  "list": [
    {
      "created_at": "2022-02-26T21:02:50",
      "item_id": 1,
      "updated_at": "2022-02-26T21:02:50",
      "price": 100,
      "name": "apple"
    }
  ]
}
```

絞り込めた。

次に存在しないidを指定してみる。

```sh
curl -s -X GET 'http://localhost:80/item?id=999' | jq
{
  "list": []
}
```

特にエラーがなく、空リストが返ってきた。REST設計としては404 Not Foundにしても良いかしら？とも考えたが、ここでは統一して200を返している。


名前での検索も同様に確認してみる。

```sh
curl -s -X GET 'http://localhost:80/item?name=orange' | jq
{
  "list": [
    {
      "created_at": "2022-02-26T21:02:50",
      "item_id": 2,
      "updated_at": "2022-02-26T21:02:50",
      "price": 200,
      "name": "orange"
    }
  ]
}
```

こちらも問題ない。

次に文字数を増やして50文字を超えてみる。

```sh
curl -s -X GET 'http://localhost:80/item?name=123456789022345678903234567890423456789052345678901' | jq
{
  "detail": [
    {
      "loc": [
        "query",
        "name"
      ],
      "msg": "ensure this value has at most 50 characters",
      "type": "value_error.any_str.max_length",
      "ctx": {
        "limit_value": 50
      }
    }
  ]
}
```

エラーが返ってきた。しっかりvalidation checkされているようだ。

しかし、クライアントに対してデフォルトのエラーを返してしまうと「ははーん50文字以内なのだな？」と悪い人にバレるため（バレても良い場合もあるが）、共通処理としてValidationErrorの際のresponse定義をすることをオススメする。実装方法は別記事で紹介予定。


次に新規登録をする。

```sh
curl -X POST 'http://localhost:80/item' -H 'Content-Type: application/json' -d '{"name":"banana","price":123}'
{"item_id":3}
```

idは3が採番されたことを確認。


再び取得してみる。

```sh
curl -s -X GET 'http://localhost:80/item?name=banana' | jq
{
  "list": [
    {
      "created_at": "2022-02-26T21:04:39",
      "item_id": 3,
      "updated_at": "2022-02-26T21:04:39",
      "price": 123,
      "name": "banana"
    }
  ]
}
```

ばっちり取得できた。


次に更新してみる。

```sh
curl -s -X PUT "http://localhost:80/item/1" -H 'Content-Type: application/json' -d '{"name":"super apple","price":500}' 
null
```

response bodyを定義していないためnullが返ってきた。あえて消したい場合は要修正。

再び取得してみる。

```sh
curl -s -X GET 'http://localhost:80/item?id=1' | jq
{
  "list": [
    {
      "created_at": "2022-02-26T21:02:50",
      "item_id": 1,
      "updated_at": "2022-02-26T21:09:21",
      "price": 500,
      "name": "super apple"
    }
  ]
}
```

無事に指定した値と合わせて`updated_at`も更新されたことを確認。


次に存在しないidに対して更新してみる。

```sh
curl -v -X PUT "http://localhost:80/item/999" -H 'Content-Type: application/json' -d '{"name":"super hoge","price":12345}' 
# 省略
< HTTP/1.1 404 Not Found
< date: Sat, 26 Feb 2022 12:12:52 GMT
< server: uvicorn
< content-length: 4
< content-type: application/json
< 
* Connection #0 to host localhost left intact
null* Closing connection 0
```

定義した通り、404が返ってきた。

次に削除してみる。

```sh
curl -s -X DELETE 'http://localhost:80/item/2'
null
```

次に全件取得してみる。


```sh
curl -s -X GET 'http://localhost:80/item' | jq
{
  "list": [
    {
      "created_at": "2022-02-26T21:02:50",
      "item_id": 1,
      "updated_at": "2022-02-26T21:09:21",
      "price": 500,
      "name": "super apple"
    },
    {
      "created_at": "2022-02-26T21:04:39",
      "item_id": 3,
      "updated_at": "2022-02-26T21:04:39",
      "price": 123,
      "name": "banana"
    }
  ]
}
```

消えている。

コンテナを止めて終了！

```sh
docker kill sample-mysql-container                                      
docker kill sample-api-container 
```

# 5. まとめ

下記を実施した。

- ローカル上にMySQLのDockerContainerを構築
  - 初期化sqlを定義
- ローカル上にAPIのDockerContainerを構築
  - SQLAlchemyを用いてCRUD処理を実装
    - DB接続情報は環境変数から取得
  - FastAPIを用いてHTTPリクエストを受け付けるように実装


この次のステップは何をすればいいの？という方に。
- 環境変数を書き換えて、クラウド上のDBなどに接続してみる。
- API RequestのValidation Errorの際の共通Responseを定義してみる。
- API実行時にExceptionが発生した場合のhandlingを実装してみる。
- logginのformat変更やfile出力をしてみる。

この辺りの記事を記載予定。

以上。