Reactで、複数のパラメーターをチェックしてコンポーネントをレンダリングするかどうかを決定する

Reactで、複数のパラメーターをチェックして、コンポーネントレンダリングするかどうかを決定するときの書き方を整理します。

環境

  • React 18.2.0

書いてみた

方法① return文の中で直接条件を判定する

type AppProps = {
  isUserLoggedIn: boolean;
  isAdmin: boolean;
};

export const App: FC<AppProps> = ({ isUserLoggedIn, isAdmin }) => {
  return <div>{isUserLoggedIn && isAdmin ? <p>Welcome, Admin!</p> : null}</div>;
};

方法② 条件を関数にする

type AppProps = {
  isUserLoggedIn: boolean;
  isAdmin: boolean;
};

export const App: FC<AppProps> = ({ isUserLoggedIn, isAdmin }) => {
  const isAdminUserLoggedIn = (): boolean => isUserLoggedIn && isAdmin;

  return <div>{isAdminUserLoggedIn() ? <p>Welcome, Admin!</p> : null}</div>;
};

方法③ 条件判定用のコンポーネントを用意する

type RenderIfProps = {
  condition: boolean;
  children: ReactElement | string;
};

const RenderIf: FC<RenderIfProps> = ({ condition, children }) => {
  return condition ? <>{children}</> : null;
};

type AppProps = {
  isUserLoggedIn: boolean;
  isAdmin: boolean;
};

export const App: FC<AppProps> = ({ isUserLoggedIn, isAdmin }) => {
  return (
    <div>
      <RenderIf condition={isUserLoggedIn && isAdmin}>
        <p>Welcome, Admin!</p>
      </RenderIf>
    </div>
  );
};

条件が簡単で再利用しない場合、①で十分そうですが、条件が複雑な場合は②、再利用を考える場合③がよさそうです。

参考

Amazon | React 18 Design Patterns and Best Practices - Fourth Edition: Design, build, and deploy production-ready web applications with React by leveraging industry-best practices | Roldán, Carlos Santana | Web Browsers

Pytestで、テストをパラメータ化してみた

pytestでは、テストにパラメータを追加することで、何種類かの引数を使って同じテストを繰り返し実行できます。

今回は、ここで書いたコードを少し修正して、パラメタライズドテストを書いてみました。

テスト対象コードを用意する

ここではstatus["capacity"] == "Available":のときにCapacity availableを返していましたが、 status["capacity"]の種類を増やして、Few left, Limited availability, Availableの3種類の時にCapacity availableを返すようにします。

    def check_capacity(self):
        status = self.get_status()
        if status["capacity"] == "Full":
            return "No capacity"
        elif status["capacity"] in ["Few left", "Limited availability", "Available"]:
            return "Capacity available"
        else:
            return "Status check failed"

pytestを書いてみる

How to parametrize fixtures and test functions — pytest documentation を参考に、テストをパラメータ化してみます。

class TestAPIClient:

    @pytest.fixture(autouse=True)
    def setup(self, mocker):
        self.client = APIClient("http://test")
        self.mock_get_status = mocker.patch.object(
            self.client, "get_status", autospec=True
        )

    @pytest.mark.parametrize("capacity", ["Few left", "Limited availability", "Available"])
    def test_check_capacity_available(self, capacity):
        self.mock_get_status.return_value = {"capacity": capacity}
        assert self.client.check_capacity() == "Capacity available"

テストの定義にパラメータを追加し、@pytest.mark.parametrize()マーカーの第一引数にパラメータの名前を文字列で、第二引数にテストケースのリストを指定します。

実行結果

> pytest .\test_main.py -k test_check_capacity_available
====================================================== test session starts ======================================================= 
platform win32 -- Python 3.11.0, pytest-8.0.0, pluggy-1.4.0 -- C:\Users\kei-c\fastApiProject\oop_test\venv\Scripts\python.exe      
cachedir: .pytest_cache
rootdir: C:\Users\kei-c\fastApiProject\oop_test
plugins: anyio-4.2.0, mock-3.12.0
collected 5 items / 2 deselected / 3 selected                                                                                      

test_main.py::TestAPIClient::test_check_capacity_available[Few left] PASSED                                                 [ 33%] 
test_main.py::TestAPIClient::test_check_capacity_available[Limited availability] PASSED                                     [ 66%] 
test_main.py::TestAPIClient::test_check_capacity_available[Available] PASSED                                                [100%] 

================================================ 3 passed, 2 deselected in 1.09s ================================================= 

3つのテストが別のテストとして報告されます。 今回の関数のパラメータ化以外にも、フィクスチャのパラメータ化やpytest_generate_testsというフック関数を使う方法もあるので、別の機会に記事にしようと思います。

参考

テスト駆動Python 第2版(Brian Okken 株式会社クイープ 株式会社クイープ 安井 力)|翔泳社の本

pytestのテスト関数を、クラスを使って書いてみた

pytestでクラスを使う

pytestではテストをクラスにまとめることができます。 テストをクラスにまとめてグループ化することで、テストの構造が明確になります。
また、共通のsetupをクラスレベルで管理できるようになり、 @pytest.fixtureを使ってセットアップを一度だけ記述することでコードの重複を避けることができます。

環境

テスト対象コードを用意する

テストを書くための、外部APIをたたくコードを用意しました。

class APIClient:
    def __init__(self, url: str):
        self.url = url

    def get_status(self):
        try:
            response = requests.get(self.url)

            return response.json()
        except RequestException as e:
            raise HTTPException(status_code=500, detail=str(e))

    def check_capacity(self):
        status = self.get_status()
        if status["capacity"] == "Full":
            return "No capacity"
        elif status["capacity"] == "Available":
            return "Capacity available"
        else:
            return "Status check failed"

pytestを書いてみる

import pytest
from main import APIClient


class TestAPIClient:

    @pytest.fixture(autouse=True)
    def setup(self, mocker):
        self.client = APIClient("http://test")
        self.mock_get_status = mocker.patch.object(
            self.client, "get_status", autospec=True
        )

    def test_check_capacity_full(self):
        self.mock_get_status.return_value = {"capacity": "Full"}
        assert self.client.check_capacity() == "No capacity"

    def test_check_capacity_available(self):
        self.mock_get_status.return_value = {"capacity": "Available"}
        assert self.client.check_capacity() == "Capacity available"

    def test_check_capacity_failed(self):
        self.mock_get_status.return_value = {"capacity": "Unknown"}
        assert self.client.check_capacity() == "Status check failed"

注意点

継承を使って凝ったことをしようとすると、メンテナンスが複雑になるので、グループ化を目的とすることにとどめておくことが推奨されています。

参考

テスト駆動Python 第2版(Brian Okken 株式会社クイープ 株式会社クイープ 安井 力)|翔泳社の本

FastAPIで、DELETEのときのステータスコードを`204No Content`に変更する

RFC基準と削除操作のステータスコード

RFC 9110では、特に削除操作(DELETEリクエスト)の場合、レスポンスボディが含まれる場合は200 OKを使用し、レスポンスボディがない場合は204 No Contentを使用すべきとされています。

a DELETE method is successfully applied, the origin server SHOULD send a 202 (Accepted) status code if the action will likely succeed but has not yet been enacted, a 204 (No Content) status code if the action has been enacted and no further information is to be supplied, or a 200 (OK) status code if the action has been enacted and the response message includes a representation describing the status.

しかし、FastAPIはリクエストが成功した時は、デフォルトで200 OKステータスコードを返すようになっているので、今回は削除が成功した時に204 No Contentを返せるようにしてみます。
Response class - FastAPI

DELETEエンドポイントの設定

reports = {
    "1": {"title": "Report 1", "description": "This is a report"},
    "2": {"title": "Report 2", "description": "This is another report"},
    "3": {"title": "Report 3", "description": "This is a third report"}
}


@app.get("/reports")
async def get_reports():
    return reports


@app.delete("/reports/{report_id}", status_code=204)
async def delete_report(report_id: str):
    if report_id in reports:
        del reports[report_id]
    else:
        raise HTTPException(status_code=404, detail="Item not found")

エンドポイントの設定時に、status_code=204を指定し、DELETEが出来ていることを確認するため、GETも用意しています。

Swagger UIで確認


status code 204が返ってきました。

実装を確認する

Starlette

# starlette/responses.py
class Response:
    media_type = None
    charset = "utf-8"

    def __init__(
        self,
        content: typing.Any = None,
        status_code: int = 200,
        headers: typing.Optional[typing.Mapping[str, str]] = None,
        media_type: typing.Optional[str] = None,
        background: typing.Optional[BackgroundTask] = None,
    ) -> None:
        self.status_code = status_code
(略)

FastAPI

from starlette.responses import JSONResponse, Response

    async def app(request: Request) -> Response:
        try:
            body: Any = None
            if body_field:
                if is_body_form:
                    body = await request.form()
                    stack = request.scope.get("fastapi_astack")
                    assert isinstance(stack, AsyncExitStack)
                    stack.push_async_callback(body.close)
                else:
                    body_bytes = await request.body()
                    if body_bytes:
                        json_body: Any = Undefined
                        content_type_value = request.headers.get("content-type")
                        if not content_type_value:
                            json_body = await request.json()
                        else:
                            message = email.message.Message()
                            message["content-type"] = content_type_value
                            if message.get_content_maintype() == "application":
                                subtype = message.get_content_subtype()
                                if subtype == "json" or subtype.endswith("+json"):
                                    json_body = await request.json()
                        if json_body != Undefined:
                            body = json_body
                        else:
                            body = body_bytes

    def api_route(
        self,
        path: str,
        *,
        response_model: Any = Default(None),
        status_code: Optional[int] = None,
       (略)
        ),
    ) -> Callable[[DecoratedCallable], DecoratedCallable]:
        def decorator(func: DecoratedCallable) -> DecoratedCallable:
            self.add_api_route(
                path,
                func,
                response_model=response_model,
                status_code=status_code,

def delete(
        self,
        path: str,
        *,
        response_model: Any = Default(None),
        status_code: Optional[int] = None,
(略)

FastAPIは、StarletteからResponseクラスをインポートして、Starletteが提供するレスポンスの生成と管理機能にアクセスし、 async def app(request: Request) -> Responsedef delete(...)で、HTTPリクエストを受け取り、StarletteのResponse型を戻り値として設定して、DELETEレスポンスを返しているようです。

ソースコード

https://github.com/kei-kmj/redirect_slashes_test/pull/1/commits/947c1438d734f48569bd08b2f610c4a68d8a4466

参考

FastAPIで、Trailing Slashリクエストの自動リダイレクトを無効にする

Trailing Slashのリダイレクト動作の制御

FastAPIを含む多くのフレームワークで、末尾にスラッシュのある(Trailing Slash)リクエストを受け取ったときにマッチするurlが見つからないと、 自動で末尾にスラッシュの無いurlにリダイレクトします。

しかし、APIの開発においては、エンドポイントは正確に指定すべきだ、という考え方もあり、 今回はその考え方に則り、 FastAPIのバージョン0.98.0で有効になったredirect_slashes=Falseオプションを使って、自動リダイレクトを無効に設定してみます。

自動リダイレクトを無効にすることで、正確にエンドポイントを指定しないと、404エラーを出すようにすることが狙いです。

FastAPIのバージョン0.98.0のインストール

まず、FastAPIのバージョン0.98.0をインストールします

$ pip install fastapi==0.98.0

インストール後、pip listでバージョンを確認します

$ pip list
Package           Version
----------------- ------------
(略)
fastapi           0.98.0
(略)

インストールできました。 次は、エンドポイントを設定します。

末尾にスラッシュのないエンドポイントの設定

末尾にスラッシュのないエンドポイントを用意します

@app.get("/hello")
async def say_hello():
    return {"message": "Hello!"}

アプリケーションを起動します

$ uvicorn main:app --reload

Trailing Slashのリダイレクト動作のテスト

"http://localhost:8000/hello"を叩いてみます

INFO:     127.0.0.1:65240 - "GET /hello HTTP/1.1" 200 OK

次に末尾にスラッシュがある "http://localhost:8000/hello/"を叩いてみると、

INFO:     127.0.0.1:65261 - "GET /hello/ HTTP/1.1" 307 Temporary Redirect
INFO:     127.0.0.1:65261 - "GET /hello HTTP/1.1" 200 OK

自動でリダイレクトがかかっていることが分かります。 FastAPIはredirect_slashes=Trueがデフォルト設定になっているからです。

redirect_slashesオプションの設定変更

Trailing Slashの自動リダイレクトを無効にするには、 FastAPIのインスタンスを作成する際に、redirect_slashes=Falseにします。

app = FastAPI(redirect_slashes=False)

@app.get("/hello")
async def say_hello():
    return {"message": "Hello!"}

これで "http://localhost:8000/hello/"を叩いてみると、

INFO:     127.0.0.1:65394 - "GET /hello/ HTTP/1.1" 404 Not Found

になります。 これで、開発段階でも正確にエンドポイントを叩くことを強制することができます。

最後に、FastAPIがどのようにredirect_slashesオプションを実装しているのか確認してみます。

redirect_slashesオプションの実装を確認する

FastAPIのソースコードを見ると、applications.pyファイル内でredirect_slashesオプションが定義されています。
FastAPIのアプリケーションインスタンスが生成される際、redirect_slashesオプションはコンストラクタ引数として与えられ、FastAPIの内部でStarletteのルーターオブジェクトを作成するときに、このオプションがルーターのコンストラクタに渡され実際のリダイレクト動作を制御しているようです。

Starletteには、2019年にはredirect_slashesオプションが導入されており、FastAPIのバージョン0.98.0でFastAPIから制御できるようになったようです。 Version 0.13 by tomchristie · Pull Request #704 · encode/starlette · GitHub

FastAPI

# fastapi/applications.py
class FastAPI(Starlette):
    def __init__(
        self: AppType,
        redirect_slashes: bool = True,
        redirect_slashes=redirect_slashes
# (略)
    self.router: routing.APIRouter = routing.APIRouter(
        routes=routes,
        redirect_slashes=redirect_slashes,

Starlette

# starlette/routing.py

class Router:
    def __init__(
        self,
       ...
    ) -> None:
        self.redirect_slashes = redirect_slashes

参考

ソースコード

GitHub - kei-kmj/redirect_slashes_test

PyCharmのLookupError: unknown encoding: windows-31jを解決した時のメモ

環境

エラー

PyCharmのデバッガーを設定しようとしたところ、

Fatal Python error: init_stdio_encoding: failed to get the Python codec name of the stdio encoding
Python runtime state: core initialized
LookupError: unknown encoding: windows-31j

というエラーがでて、デバッガーが起動できません。

原因

日本語のWindowsシステムで使用されるwindows-31jというエンコードが、Pythonに認識されていないために、 エラーになるようです。

解決した方法

エンコードを、Pythonが認識できるutf8に変えれば良さそうです。 というわけで、 - PyCharmをすべて閉じる - /JetBrains/PyCharm 2023.2.1/bin/pycharm64.exe.vmoptionsをエディタで開く - -Dfile.encoding=UTF-8を追加する

これでデバッガーが動くようになりました🎉

Reactのレビューの練習①

Reactを業務で使うようになって○ヶ月、まだ自分がレビューする機会が無いので、ここでレビューの練習をしてみたいと思います。

前提

  • Typescriptで書いている
  • buildツールはesbuild

お題

実際にプロダクト内で見かけたコードを簡素にしたものです。

import React from 'react';
import { useLangContext } from 'react-i18next';

type nameProps = {
  name: string;
}

export default function MyComponent(props: nameProps) {
  const { i18nTextTable: T } = useLangContext();

  return (
    <div>
      {T('hello')}, {props.name}!
    </div>
  );
}

レビュー

気になる点①:型の命名規約

type namePropsとしていますが、Typescriptでは、型の命名にはPascalCaseを使うことが一般的です。 type NamePropsの方が適切だと思います。

気になる点②:default exportの利用

esbuildの公式ドキュメントを確認すると、 https://esbuild.github.io/content-types/#default-interop

デフォルトエクスポートは解釈方法が複数あり、間違った解釈から、予期しないエラーが引き起こされることがあるようです。
また、デフォルトエクスポートは呼び出し側で自由に名前を変更できるため、名前の一貫性が保証されません。特別な理由がない限り、名前付きエクスポートを使用した方がいいのではないでしょうか?

気になる点③:propsの分割代入

export default function MyComponent(props: NameProps) {

これだと、コンポーネントに予期しないpropsが渡されるリスクがあり、最悪コンポーネントが壊れてしまいます。

export default function MyComponent({name}: NameProps) {

このように、分割代入を利用すれば、この一行は長くなってしまいますが、プロパティを明示して利用でき、 使う時に{props.name}ではなく、{name}として使うことができます。

気になる点④:翻訳関数の命名

  const { i18nTextTable: T } = useLangContext();

ここでTは略称でしょうか。そうであるならば、別の略称を使った方が良いと思われます。TypescriptでTは型引数のことなので、混同する可能性があるからです。 translatetextなどを使う方がi18nを使用してることを明示できるかもしれません。