FastAPI を使ってWEBアプリを作ってみる その10

Python,TIPSDocker,FastAPI

色々と忙しくなり4月になってしまいました。
前回の投稿ではユーザーのプロファイルを構築し必要に応じてAPIがユーザーのプロファイルを返すようにしました。
この投稿では店舗ユーザーが自身にハリネズミリソースを紐付けて管理できるようコードをリファクタリングをしていきます。

過去の投稿はこちらから辿ることができます。

FastAPI を使ってWEBアプリを作ってみる その1FastAPIとDockerでHelloWorld
FastAPI を使ってWEBアプリを作ってみる その2AlembicとPostgreSQLでDB Migrate
FastAPI を使ってWEBアプリを作ってみる その3APIエンドポイントをPostgreSQLに接続
FastAPI を使ってWEBアプリを作ってみる その4PytestとDockerでテスト構築
FastAPI を使ってWEBアプリを作ってみる その5RESTAPIエンドポイントを実装
FastAPI を使ってWEBアプリを作ってみる その6ユーザーモデルの作成
FastAPI を使ってWEBアプリを作ってみる その7パスワードの暗号化とJWT認証
FastAPI を使ってWEBアプリを作ってみる その8認証機能のDependencies化
FastAPI を使ってWEBアプリを作ってみる その9ユーザプロファイルの作成
FastAPI を使ってWEBアプリを作ってみる その10今ここ

ハリネズミリソースの修正

現在のハリネズミさん関連のデータベース構造はかなり単純なものになっています。
どこの店舗にハリネズミさんが紐づいているのかを追跡する方法がないのでここから修正しています。

マイグレーション

データベースをロールバックします。

$ alembic downgrade base
"""create_first_tables

Revision ID: 12056735bd4e
Revises:
Create Date: 2021-01-22 18:27:50.234519

"""

...省略

def create_hedgehogs_table() -> None:
    op.create_table(
        "hedgehogs",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("name", sa.Text, nullable=False, index=True),
        sa.Column("description", sa.Text, nullable=True),
        sa.Column("color_type", sa.Text, nullable=False),
        sa.Column("age", sa.Numeric(10, 2), nullable=False),
        # 追加
        sa.Column("owner", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE")),
        *timestamps(),
    )
    op.execute(
        """
        CREATE TRIGGER update_hedgehogs_modtime
            BEFORE UPDATE
            ON hedgehogs
            FOR EACH ROW
        EXECUTE PROCEDURE update_updated_at_column();
        """
    )

...省略

def upgrade() -> None:
    # 実行順を変更
    create_updated_at_trigger()
    create_users_table()
    create_profiles_table()
    create_hedgehogs_table()


def downgrade() -> None:
    # 実行順を変更
    op.drop_table("hedgehogs")
    op.drop_table("profiles")
    op.drop_table("users")
    op.execute("DROP FUNCTION update_updated_at_column")

hedgehogsテーブルの作成をusersとprofilesテーブルの作成後に移しました。
理由はhedgehogsテーブルにownerカラムを定義する際にusersテーブルを参照したいからです。
この変更は他のリソースに少し影響がありますが後ほど触れます。

upgrade headコマンドを実行してマイグレートしましょう。

$ alembic upgrade head

ハリネズミリソースの所有権をユーザーに与えたのでコードをリファクタリングします。
どのユーザーもハリネズミリソースにアクセスできますが作成したユーザーだけが更新や削除をできるようにします。
またハリネズミを一覧表示したいユーザーは、自分で作成したものだけを受け取るようにします。

Hedgehogリソースのリファクタリング

いつもならばテストから始めますがデータベース構造を変更したので今回は models/hedgehog.py ファイルから修正していきましょう。

ハリネズミモデルの修正

from enum import Enum
from typing import Optional, Union

from app.models.core import CoreModel, DateTimeModelMixin, IDModelMixin  # 更新
from app.models.user import UserPublic  # 追加

...省略

class HedgehogInDB(IDModelMixin, DateTimeModelMixin, HedgehogBase):
    name: str
    age: float
    color_type: ColorType
    owner: int  # 追加

# 更新
class HedgehogPublic(IDModelMixin, DateTimeModelMixin, HedgehogBase):
    owner: Union[int, UserPublic]

所有しているユーザID を表すintが格納される owner アトリビュートを HedgehogInDB モデルに追加し、HedgehogInDB モデルで DateTimeModelMixin を利用しタイムスタンプを活用できるようにします。HedgehogPublicモデルは、単にHedgehogInDBを継承していますが、ownerアトリビュートがユーザーIDの整数かUserPublicモデル自体になるよう指定しています。

この変更によりテストを実行するとほとんどがエラーになります。
テストが通るように今度はテストコードを更新していきましょう。

conftest.py ファイルの test_hedgehog フィクスチャから修正します。

...省略

@pytest.fixture
async def test_hedgehog(db: Database, test_user: UserInDB) -> HedgehogInDB:
    hedgehog_repo = HedgehogsRepository(db)
    new_hedgehog = HedgehogCreate(
        name="fake hedgehog name",
        description="fake description",
        age=2.2,
        color_type="SOLT & PEPPER",
    )
    return await hedgehog_repo.create_hedgehog(
        new_hedgehog=new_hedgehog, requesting_user=test_user
    )

...省略

test_userフィクスチャを引数に追加し新しいハリネズミが作成されるたびにHedgehogsRepositoryに送信しています。
このユーザーを取得してIDをownerアトリビュートとして渡します。

POST hedgehogの修正

HedgehogsRepositoryを以下のように変更します:

from typing import List

from app.db.repositories.base import BaseRepository
from app.models.hedgehog import HedgehogCreate, HedgehogInDB, HedgehogUpdate
from fastapi import HTTPException
from starlette.status import HTTP_400_BAD_REQUEST
import app.db.repositories.queries.hedgehogs as query
from app.models.user import UserInDB  # 追加


class HedgehogsRepository(BaseRepository):
    async def create_hedgehog(
        self, *, new_hedgehog: HedgehogCreate, requesting_user: UserInDB
    ) -> HedgehogInDB:
        hedgehog = await self.db.fetch_one(
            query=query.CREATE_HEDGEHOG_QUERY,
            values={**new_hedgehog.dict(), "owner": requesting_user.id},
        )
        return HedgehogInDB(**hedgehog)

...省略

続いてSQLにもownerアトリビュートを追加します:

CREATE_HEDGEHOG_QUERY = """
    INSERT INTO hedgehogs (name, description, age, color_type, owner)
    VALUES (:name, :description, :age, :color_type, :owner)
    RETURNING id, name, description, age, color_type, owner, created_at, updated_at;
"""

...省略

今回はCREATE_HEDGEHOG_QUERYを更新しただけですがSQLのほとんどを変更する必要があります。
現在テストを実行してもログインユーザーを HedgehogRepository に渡していないためエラーになります。

この対応よりも先に tests/test_hedgehogs.pyファイルを更新しますが既存のテストコードを殆ど捨てて新しく書き直すのと同義になります。

from typing import Dict, List, Union

import pytest
from app.db.repositories.hedgehogs import HedgehogsRepository
from app.models.hedgehog import HedgehogCreate, HedgehogInDB, HedgehogPublic
from app.models.user import UserInDB
from databases import Database
from fastapi import FastAPI, status
from httpx import AsyncClient

pytestmark = pytest.mark.asyncio


@pytest.fixture
def new_hedgehog():
    return HedgehogCreate(
        name="test hedgehog",
        description="test description",
        age=0.0,
        color_type="SOLT & PEPPER",
    )


@pytest.fixture
async def test_hedgehogs_list(db: Database, test_user2: UserInDB) -> List[HedgehogInDB]:
    hedgehog_repo = HedgehogsRepository(db)
    return [
        await hedgehog_repo.create_hedgehog(
            new_hedgehog=HedgehogCreate(
                name="test hedgehog {i}",
                description="test description",
                age=0.0,
                color_type="SOLT & PEPPER",
            ),
            requesting_user=test_user2,
        )
        for i in range(5)
    ]


class TestHedgehogsRoutes:
    async def test_routes_exist(self, app: FastAPI, client: AsyncClient) -> None:
        res = await client.post(app.url_path_for("hedgehogs:create-hedgehog"), json={})
        assert res.status_code != status.HTTP_404_NOT_FOUND


class TestCreateHedgehog:
    async def test_valid_input_creates_hedgehog_belonging_to_user(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_user: UserInDB,
        new_hedgehog: HedgehogCreate,
    ) -> None:
        res = await authorized_client.post(
            app.url_path_for("hedgehogs:create-hedgehog"),
            json={"new_hedgehog": new_hedgehog.dict()},
        )
        assert res.status_code == status.HTTP_201_CREATED
        created_hedgehog = HedgehogPublic(**res.json())
        assert created_hedgehog.name == new_hedgehog.name
        assert created_hedgehog.age == new_hedgehog.age
        assert created_hedgehog.description == new_hedgehog.description
        assert created_hedgehog.color_type == new_hedgehog.color_type
        assert created_hedgehog.owner == test_user.id

    async def test_unauthorized_user_unable_to_create_hedgehog(
        self, app: FastAPI, client: AsyncClient, new_hedgehog: HedgehogCreate
    ) -> None:
        res = await client.post(
            app.url_path_for("hedgehogs:create-hedgehog"),
            json={"new_hedgehog": new_hedgehog.dict()},
        )
        assert res.status_code == status.HTTP_401_UNAUTHORIZED

    @pytest.mark.parametrize(
        "invalid_payload, status_code",
        (
            (None, 422),
            ({}, 422),
            ({"name": "test_name"}, 422),
            ({"age": 2}, 422),
            ({"name": "test_name", "description": "test"}, 422),
        ),
    )
    async def test_invalid_input_raises_error(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        invalid_payload: Dict[str, Union[str, float]],
        test_hedgehog: HedgehogCreate,
        status_code: int,
    ) -> None:
        res = await authorized_client.post(
            app.url_path_for("hedgehogs:create-hedgehog"),
            json={"new_hedgehog": invalid_payload},
        )
        assert res.status_code == status_code

認証されていないユーザーがハリネズミを作成できないことを確認する新しいテストを追加し、新しく作成されたハリネズミのオーナーが現在ログインしているユーザーであることを確認しています。

続いてapi/routes/hedgehogs.pyを直しますがここでも殆ど初めから書き直していきます:

from typing import List

from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.database import get_repository
from app.db.repositories.hedgehogs import HedgehogsRepository
from app.models.hedgehog import HedgehogCreate, HedgehogPublic, HedgehogUpdate
from app.models.user import UserCreate, UserInDB, UserPublic, UserUpdate
from fastapi import APIRouter, Body, Depends, status

router = APIRouter()


@router.post(
    "/",
    response_model=HedgehogPublic,
    name="hedgehogs:create-hedgehog",
    status_code=status.HTTP_201_CREATED,
)
async def create_new_hedgehog(
    new_hedgehog: HedgehogCreate = Body(..., embed=True),
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> HedgehogPublic:
    created_hedgehog = await hedgehogs_repo.create_hedgehog(
        new_hedgehog=new_hedgehog, requesting_user=current_user
    )
    return created_hedgehog

以前の投稿で定義したdependencies.authを使用してログインしたユーザーをHedgehogsRepositoryに渡しています。
これにより新しいハリネズミには、現在認証されているユーザーがownerとしてアタッチされます。

テストを再度実行しTestCreatehedgehogのテストがすべてパスすることを確認してください。

GET hedgehogsの修正

POSTメソッドができたので次はGETメソッドをリファクタします。
例の如くテストコードから行います:

...省略

class TestGetHedgehog:
    async def test_get_hedgehog_by_id(
        self, app: FastAPI, authorized_client: AsyncClient, test_hedgehog: HedgehogInDB
    ) -> None:
        res = await authorized_client.get(
            app.url_path_for(
                "hedgehogs:get-hedgehog-by-id", hedgehog_id=test_hedgehog.id
            )
        )
        assert res.status_code == status.HTTP_200_OK
        hedgehog = HedgehogInDB(**res.json())
        assert hedgehog == test_hedgehog

    async def test_unauthorized_users_cant_access_hedgehogs(
        self, app: FastAPI, client: AsyncClient, test_hedgehog: HedgehogInDB
    ) -> None:
        res = await client.get(
            app.url_path_for(
                "hedgehogs:get-hedgehog-by-id", hedgehog_id=test_hedgehog.id
            )
        )
        assert res.status_code == status.HTTP_401_UNAUTHORIZED

    @pytest.mark.parametrize(
        "id, status_code",
        ((50000, 404), (-1, 422), (None, 422)),
    )
    async def test_wrong_id_returns_error(
        self, app: FastAPI, authorized_client: AsyncClient, id: int, status_code: int
    ) -> None:
        res = await authorized_client.get(
            app.url_path_for("hedgehogs:get-hedgehog-by-id", hedgehog_id=id)
        )
        assert res.status_code == status_code

    async def test_get_all_hedgehogs_returns_only_user_owned_hedgehogs(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_user: UserInDB,
        db: Database,
        test_hedgehog: HedgehogInDB,
        test_hedgehogs_list: List[HedgehogInDB],
    ) -> None:
        res = await authorized_client.get(
            app.url_path_for("hedgehogs:list-all-user-hedgehogs")
        )
        assert res.status_code == status.HTTP_200_OK
        assert isinstance(res.json(), list)
        assert len(res.json()) > 0
        hedgehogs = [HedgehogInDB(**item) for item in res.json()]
        assert test_hedgehog in hedgehogs
        for hedgehog in hedgehogs:
            assert hedgehog.owner == test_user.id
        assert all(c not in hedgehogs for c in test_hedgehogs_list)

基本的にはPOSTメソッドの改修と同じようなことをしています。
店舗ユーザーは認証されている場合のみハリネズミリソースを取得することができます。
また店舗ユーザーがすべてのハリネズミリストを要求した場合は自分に属するものだけを送り返します。

HedgehogsRepositoryを開き以下のように更新します:

...省略

class HedgehogsRepository(BaseRepository):
    async def create_hedgehog(
        self, *, new_hedgehog: HedgehogCreate, requesting_user: UserInDB
    ) -> HedgehogInDB:
        hedgehog = await self.db.fetch_one(
            query=query.CREATE_HEDGEHOG_QUERY,
            values={**new_hedgehog.dict(), "owner": requesting_user.id},
        )
        return HedgehogInDB(**hedgehog)

    # ----------- 以下更新 -----------
    async def get_hedgehog_by_id(
        self, *, id: int, requesting_user: UserInDB
    ) -> HedgehogInDB:
        hedgehog = await self.db.fetch_one(
            query=query.GET_HEDGEHOG_BY_ID_QUERY, values={"id": id}
        )
        if not hedgehog:
            return None
        return HedgehogInDB(**hedgehog)

    async def list_all_user_hedgehogs(
        self, requesting_user: UserInDB
    ) -> List[HedgehogInDB]:
        hedgehog_records = await self.db.fetch_all(
            query=query.LIST_ALL_USER_HEDGEHOGS_QUERY,
            values={"owner": requesting_user.id},
        )
        return [HedgehogInDB(**item) for item in hedgehog_records]

各メソッドでrequesting_userを要求しています。
get_hedgehog_by_idメソッドではこのパラメータを使用していませんが一貫性を保つためと後ほど使うことになるのでそのままにしておきます。

SQLクエリも更新します:

...省略

GET_HEDGEHOG_BY_ID_QUERY = """
    SELECT id, name, description, age, color_type, owner, created_at, updated_at
    FROM hedgehogs
    WHERE id = :id;
"""

LIST_ALL_USER_HEDGEHOGS_QUERY = """
    SELECT id, name, description, age, color_type, owner, created_at, updated_at
    FROM hedgehogs
    WHERE owner = :owner;
"""

この変更をサポートするためにルートも変更しましょう:

...省略

@router.get(
    "/{hedgehog_id}/",
    response_model=HedgehogPublic,
    name="hedgehogs:get-hedgehog-by-id",
)
async def get_hedgehog_by_id(
    hedgehog_id: int = Path(..., ge=1),
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> HedgehogPublic:
    hedgehog = await hedgehogs_repo.get_hedgehog_by_id(
        id=hedgehog_id, requesting_user=current_user
    )
    if not hedgehog:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No hedgehog found with that id.",
        )
    return hedgehog


@router.get(
    "/", response_model=List[HedgehogPublic], name="hedgehogs:list-all-user-hedgehogs"
)
async def list_all_user_hedgehogs(
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> List[HedgehogPublic]:
    return await hedgehogs_repo.list_all_user_hedgehogs(requesting_user=current_user)

ルートを保護するためにget_current_active_userをdependencyで使用しユーザーをHedgehogsRepositoryに渡しています。

これでGETメソッドの改修が完了しました、テストを実行し全てパスすることを確認してください。

PUT hedgehogの修正

ハリネズミを更新するためのテストはほんの少しの修正で済みます:

...省略

class TestUpdateHedgehog:
    @pytest.mark.parametrize(
        "attrs_to_change, values",
        (
            (["name"], ["new fake hedgehog name"]),
            (["description"], ["new fake hedgehog description"]),
            (["age"], [3.14]),
            (["color_type"], ["CHOCOLATE"]),
            (
                ["name", "description"],
                ["extra new fake hedgehog name", "extra new fake hedgehog description"],
            ),
            (["age", "color_type"], [2.00, "CHOCOLATE"]),
        ),
    )
    async def test_update_hedgehog_with_valid_input(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_hedgehog: HedgehogInDB,
        attrs_to_change: List[str],
        values: List[str],
    ) -> None:
        hedgehog_update = {
            "hedgehog_update": {
                attrs_to_change[i]: values[i] for i in range(len(attrs_to_change))
            }
        }
        res = await authorized_client.put(
            app.url_path_for(
                "hedgehogs:update-hedgehog-by-id", hedgehog_id=test_hedgehog.id
            ),
            json=hedgehog_update,
        )
        assert res.status_code == status.HTTP_200_OK
        updated_hedgehog = HedgehogInDB(**res.json())
        assert updated_hedgehog.id == test_hedgehog.id
        for i in range(len(attrs_to_change)):
            assert getattr(updated_hedgehog, attrs_to_change[i]) != getattr(
                test_hedgehog, attrs_to_change[i]
            )
            assert getattr(updated_hedgehog, attrs_to_change[i]) == values[i]

        for attr, value in updated_hedgehog.dict().items():
            if attr not in attrs_to_change and attr != "updated_at":
                assert getattr(test_hedgehog, attr) == value

    async def test_user_recieves_error_if_updating_other_users_hedgehog(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_hedgehogs_list: List[HedgehogInDB],
    ) -> None:
        res = await authorized_client.put(
            app.url_path_for(
                "hedgehogs:update-hedgehog-by-id", hedgehog_id=test_hedgehogs_list[0].id
            ),
            json={"hedgehog_update": {"age": 3.2}},
        )
        assert res.status_code == status.HTTP_403_FORBIDDEN

    async def test_user_cant_change_ownership_of_hedgehog(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_hedgehog: HedgehogInDB,
        test_user: UserInDB,
        test_user2: UserInDB,
    ) -> None:
        res = await authorized_client.put(
            app.url_path_for(
                "hedgehogs:update-hedgehog-by-id", hedgehog_id=test_hedgehog.id
            ),
            json={"hedgehog_update": {"owner": test_user2.id}},
        )
        assert res.status_code == status.HTTP_200_OK
        hedgehog = HedgehogPublic(**res.json())
        assert hedgehog.owner == test_user.id

    @pytest.mark.parametrize(
        "id, payload, status_code",
        (
            (-1, {"name": "test"}, 422),
            (0, {"name": "test2"}, 422),
            (500, {"name": "test3"}, 404),
            (1, None, 422),
            (1, {"color_type": "invalid color type"}, 422),
            (1, {"color_type": None}, 400),
        ),
    )
    async def test_update_hedgehog_with_invalid_input_throws_error(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        id: int,
        payload: Dict[str, Optional[str]],
        status_code: int,
    ) -> None:
        hedgehog_update = {"hedgehog_update": payload}
        res = await authorized_client.put(
            app.url_path_for("hedgehogs:update-hedgehog-by-id", hedgehog_id=id),
            json=hedgehog_update,
        )
        assert res.status_code == status_code

変更したのは認証されたユーザが他のユーザが所有するハリネズミリソースを更新する権限を持っていないことを確認することだけです。
またユーザが自分のハリネズミリソースの所有者を変更できないようにしています。
その他はすべて同じです。

リポジトリ側を更新しましょう:

...省略

class HedgehogsRepository(BaseRepository):
    ...省略

    async def update_hedgehog(
        self, *, id: int, hedgehog_update: HedgehogUpdate, requesting_user: UserInDB
    ) -> HedgehogInDB:
        hedgehog = await self.get_hedgehog_by_id(id=id, requesting_user=requesting_user)
        if not hedgehog:
            return None
        if hedgehog.owner != requesting_user.id:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Users are only able to update hedgehogs that they created.",
            )
        hedgehog_update_params = hedgehog.copy(
            update=hedgehog_update.dict(exclude_unset=True)
        )
        if hedgehog_update_params.color_type is None:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Invalid color type. Cannot be None.",
            )
        updated_hedgehog = await self.db.fetch_one(
            query=query.UPDATE_HEDGEHOG_BY_ID_QUERY,
            values={
                **hedgehog_update_params.dict(exclude={"created_at", "updated_at"}),
                "owner": requesting_user.id,
            },
        )
        return HedgehogInDB(**updated_hedgehog)

update_hedgehogメソッドのサイズが大きくなっていますが殆どはエッジケースを扱っています。
もし頻繁に肥大化が起こるようであればBaseRepositoryをもう少し構築し多くの定型的な処理を継承するように構築するが好ましいです。
今回は完成間近なのでこのまま行こうと思います。

SQLクエリも更新します:

...省略

UPDATE_HEDGEHOG_BY_ID_QUERY = """
    UPDATE hedgehogs
    SET name          = :name,
        description   = :description,
        age           = :age,
        color_type    = :color_type
    WHERE id = :id AND owner = :owner
    RETURNING id, name, description, age, color_type, owner, created_at, updated_at;
"""

最後にルートを構成します:

...省略

@router.put(
    "/{hedgehog_id}/",
    response_model=HedgehogPublic,
    name="hedgehogs:update-hedgehog-by-id",
)
async def update_hedgehog_by_id(
    hedgehog_id: int = Path(..., ge=1),
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehog_update: HedgehogUpdate = Body(..., embed=True),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> HedgehogPublic:
    updated_hedgehog = await hedgehogs_repo.update_hedgehog(
        id=hedgehog_id, hedgehog_update=hedgehog_update, requesting_user=current_user
    )
    if not updated_hedgehog:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No hedgehog found with that id.",
        )
    return updated_hedgehog

ルートでは殆ど変更点はありません。
他のメソッド同様にget_current_active_userでルートを保護しそれをRepositoryに渡していることです。

ここでもテストを実行し適切に処理されることを確認してください。

DELETE hedgehogの修正

それではラストにDELETEメソッドをリファクタしていきましょう。
テストケースを更新します:

class TestDeleteHedgehog:
    async def test_can_delete_hedgehog_successfully(
        self, app: FastAPI, authorized_client: AsyncClient, test_hedgehog: HedgehogInDB
    ) -> None:
        res = await authorized_client.delete(
            app.url_path_for(
                "hedgehogs:delete-hedgehog-by-id", hedgehog_id=test_hedgehog.id
            )
        )
        assert res.status_code == status.HTTP_200_OK

    async def test_user_cant_delete_other_users_hedgehog(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_hedgehogs_list: List[HedgehogInDB],
    ) -> None:
        res = await authorized_client.delete(
            app.url_path_for(
                "hedgehogs:delete-hedgehog-by-id", hedgehog_id=test_hedgehogs_list[0].id
            )
        )
        assert res.status_code == status.HTTP_403_FORBIDDEN

    @pytest.mark.parametrize(
        "id, status_code",
        ((5000000, 404), (0, 422), (-1, 422), (None, 422)),
    )
    async def test_wrong_id_throws_error(
        self,
        app: FastAPI,
        authorized_client: AsyncClient,
        test_hedgehog: HedgehogInDB,
        id: int,
        status_code: int,
    ) -> None:
        res = await authorized_client.delete(
            app.url_path_for("hedgehogs:delete-hedgehog-by-id", hedgehog_id=id)
        )
        assert res.status_code == status_code

店舗ユーザーが自分のハリネズミを削除でき、他の店舗ユーザーのハリネズミを削除できないことを確認しています。

Repositoryを更新します:

...省略

class HedgehogsRepository(BaseRepository):
    ...省略

    async def delete_hedgehog_by_id(self, *, id: int, requesting_user: UserInDB) -> int:
        hedgehog = await self.get_hedgehog_by_id(id=id, requesting_user=requesting_user)
        if not hedgehog:
            return None
        if hedgehog.owner != requesting_user.id:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Users are only able to delete hedgehogs that they created.",
            )
        deleted_id = await self.db.execute(
            query=query.DELETE_HEDGEHOG_BY_ID_QUERY,
            values={"id": id, "owner": requesting_user.id},
        )
        return deleted_id

delete_hedgehog_by_idメソッドでも他と同じことをしています。
requesting_user を渡して自分が所有するハリネズミの削除のみが許可されていることを確認します。

SQLクエリも他と同様にWHERE句にownerを指定します:

...省略

DELETE_HEDGEHOG_BY_ID_QUERY = """
    DELETE FROM hedgehogs
    WHERE id = :id AND owner = :owner
    RETURNING id;
"""

最後にルートを書き換えます:

...省略

@router.delete(
    "/{hedgehog_id}/", response_model=int, name="hedgehogs:delete-hedgehog-by-id"
)
async def delete_hedgehog_by_id(
    hedgehog_id: int = Path(..., ge=1),
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> int:
    deleted_id = await hedgehogs_repo.delete_hedgehog_by_id(
        id=hedgehog_id, requesting_user=current_user
    )
    if not deleted_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No hedgehog found with that id.",
        )
    return deleted_id

お疲れ様でした!
テストを実行し全てがパスするのを確認してください。

しかし現在は例外返却用のコードがあちこちで重複しています。
ハリネズミリソースが既に存在するかチェックし続け存在しない場合は404例外を発生させています。
また、ユーザーがリソースの変更や削除を許可されていない場合は403例外を発生させています。

ここもリファクタリングして綺麗に纏めましょう、これはAuthでも利用したFastAPIの依存性注入機能を使って共通処理として抽象化することができます。
改修用の新しいdependencyを作りましょう:

$ touch /backend/app/api/dependencies/hedgehogs.py
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.database import get_repository
from app.db.repositories.hedgehogs import HedgehogsRepository
from app.models.hedgehog import HedgehogInDB
from app.models.user import UserInDB
from fastapi import Depends, HTTPException, Path, status


async def get_hedgehog_by_id_from_path(
    hedgehog_id: int = Path(..., ge=1),
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> HedgehogInDB:
    hedgehog = await hedgehogs_repo.get_hedgehog_by_id(
        id=hedgehog_id, requesting_user=current_user
    )
    if not hedgehog:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No hedgehog found with that id.",
        )
    return hedgehog


def check_hedgehog_modification_permissions(
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehog: HedgehogInDB = Depends(get_hedgehog_by_id_from_path),
) -> None:
    if hedgehog.owner != current_user.id:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Action forbidden. Users are only able to modify hedgehogs they own",
        )

一般的な例外を抽象化しハリネズミリソースへのアクセスを管理できるようにしました。
これで、ルートとリポジトリを大幅に簡素化することができます。

HedgehogsRepositoryに組み込んで重複したコードを掃除しましょう:

from typing import List

import app.db.repositories.queries.hedgehogs as query
from app.db.repositories.base import BaseRepository
from app.models.hedgehog import HedgehogCreate, HedgehogInDB, HedgehogUpdate
from app.models.user import UserInDB
from fastapi import HTTPException, status


class HedgehogsRepository(BaseRepository):
    async def create_hedgehog(
        self, *, new_hedgehog: HedgehogCreate, requesting_user: UserInDB
    ) -> HedgehogInDB:
        hedgehog = await self.db.fetch_one(
            query=query.CREATE_HEDGEHOG_QUERY,
            values={**new_hedgehog.dict(), "owner": requesting_user.id},
        )
        return HedgehogInDB(**hedgehog)

    async def get_hedgehog_by_id(
        self, *, id: int, requesting_user: UserInDB
    ) -> HedgehogInDB:
        hedgehog = await self.db.fetch_one(
            query=query.GET_HEDGEHOG_BY_ID_QUERY, values={"id": id}
        )
        if not hedgehog:
            return None
        return HedgehogInDB(**hedgehog)

    async def list_all_user_hedgehogs(
        self, requesting_user: UserInDB
    ) -> List[HedgehogInDB]:
        hedgehog_records = await self.db.fetch_all(
            query=query.LIST_ALL_USER_HEDGEHOGS_QUERY,
            values={"owner": requesting_user.id},
        )
        return [HedgehogInDB(**item) for item in hedgehog_records]

    async def update_hedgehog(
        self, *, hedgehog: HedgehogInDB, hedgehog_update: HedgehogUpdate
    ) -> HedgehogInDB:
        hedgehog_update_params = hedgehog.copy(
            update=hedgehog_update.dict(exclude_unset=True)
        )
        if hedgehog_update_params.color_type is None:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Invalid hedgehog type. Cannot be None.",
            )
        updated_hedgehog = await self.db.fetch_one(
            query=query.UPDATE_HEDGEHOG_BY_ID_QUERY,
            values=hedgehog_update_params.dict(
                exclude={"owner", "created_at", "updated_at"}
            ),
        )
        return HedgehogInDB(**updated_hedgehog)

    async def delete_hedgehog_by_id(self, *, hedgehog: HedgehogInDB) -> int:
        return await self.db.execute(
            query=query.DELETE_HEDGEHOG_BY_ID_QUERY, values={"id": hedgehog.id}
        )

update_hedgehogを中心にコードが大幅に少なくなりupdateとdelete両方のアクションでidの代わりにHedgehogInDB モデルを受け入れるようになりました。
依存関係は get_hedgehog_by_id メソッドを使用してすべての404エラーを処理しています。
また、check_hedgehog_modification_permissionsが処理してくれるので、updateメソッドのrequesting_user引数を削除しました。

これを扱うためにルートを変更します:

from typing import List

from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.database import get_repository
from app.api.dependencies.hedgehogs import (
    check_hedgehog_modification_permissions,
    get_hedgehog_by_id_from_path,
)
from app.db.repositories.hedgehogs import HedgehogsRepository
from app.models.hedgehog import (
    HedgehogCreate,
    HedgehogInDB,
    HedgehogPublic,
    HedgehogUpdate,
)
from app.models.user import UserInDB
from fastapi import APIRouter, Body, Depends, status

router = APIRouter()


@router.post(
    "/",
    response_model=HedgehogPublic,
    name="hedgehogs:create-hedgehog",
    status_code=status.HTTP_201_CREATED,
)
async def create_new_hedgehog(
    new_hedgehog: HedgehogCreate = Body(..., embed=True),
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> HedgehogPublic:
    return await hedgehogs_repo.create_hedgehog(
        new_hedgehog=new_hedgehog, requesting_user=current_user
    )


@router.get(
    "/", response_model=List[HedgehogPublic], name="hedgehogs:list-all-user-hedgehogs"
)
async def list_all_user_hedgehogs(
    current_user: UserInDB = Depends(get_current_active_user),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> List[HedgehogPublic]:
    return await hedgehogs_repo.list_all_user_hedgehogs(requesting_user=current_user)


@router.get(
    "/{hedgehog_id}/",
    response_model=HedgehogPublic,
    name="hedgehogs:get-hedgehog-by-id",
)
async def get_hedgehog_by_id(
    hedgehog: HedgehogInDB = Depends(get_hedgehog_by_id_from_path),
) -> HedgehogPublic:
    return hedgehog


@router.put(
    "/{hedgehog_id}/",
    response_model=HedgehogPublic,
    name="hedgehogs:update-hedgehog-by-id",
    dependencies=[Depends(check_hedgehog_modification_permissions)],
)
async def update_hedgehog_by_id(
    hedgehog: HedgehogInDB = Depends(get_hedgehog_by_id_from_path),
    hedgehog_update: HedgehogUpdate = Body(..., embed=True),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> HedgehogPublic:
    return await hedgehogs_repo.update_hedgehog(
        hedgehog=hedgehog, hedgehog_update=hedgehog_update
    )


@router.delete(
    "/{hedgehog_id}/",
    response_model=int,
    name="hedgehogs:delete-hedgehog-by-id",
    dependencies=[Depends(check_hedgehog_modification_permissions)],
)
async def delete_hedgehog_by_id(
    hedgehog: HedgehogInDB = Depends(get_hedgehog_by_id_from_path),
    hedgehogs_repo: HedgehogsRepository = Depends(get_repository(HedgehogsRepository)),
) -> int:
    return await hedgehogs_repo.delete_hedgehog_by_id(hedgehog=hedgehog)

それぞれのルートは宣言的でシンプルなワンライナーになりました、ほとんどの作業はdependencyによって行われています。
このパターンについては、FastAPI のドキュメントで詳しく説明されています。

SQLクエリからも更新と削除の部分でowner指定を外すことができます:

...省略

UPDATE_HEDGEHOG_BY_ID_QUERY = """
    UPDATE hedgehogs
    SET name          = :name,
        description   = :description,
        age           = :age,
        color_type    = :color_type
    WHERE id = :id  # 更新
    RETURNING id, name, description, age, color_type, owner, created_at, updated_at;
"""

DELETE_HEDGEHOG_BY_ID_QUERY = """
    DELETE FROM hedgehogs
    WHERE id = :id  # 更新
    RETURNING id;
"""


テストを実行しすべて合格することを確認しましょう。

リファクタリングはTDDの中でも一番楽しくない部分ではありますが必要不可欠です。
このシリーズ投稿が続く間にあと何回か同様のリファクタをするかもしれません。

まとめ

これで最初のリファクタリングが完了しました。

次の投稿ではユーザーがハリネズミを予約しオーナがその予約を受け入れるかどうかを判断できるようにします。

作成したコードはGitHubにアップしています。
part10ブランチが対応しています。

Python,TIPSDocker,FastAPI

Posted by Kenny