pydanticにてエラーメッセージをカスタムする方法

## 目次
1. [はじめに](#1-はじめに)
2. [動作環境](#2-動作環境)
3. [フォルダ構成](#3-フォルダ構成)
4. [FastAPIのエンドポイントの引数でバリデーションする場合](#4-fastapiのエンドポイントの引数でバリデーションする場合)
5. [try~except文でバリデーションする場合](#5tryexcept文でバリデーションする場合)
6. [参考](#6-参考)

## 1. はじめに
FastAPIで作成したエンドポイントにてデータのバリデーションをpydanticで行っています。
ここで以下の2つを実現できるように改修したいと思います。
- データのバリデーションをデータの型だけでなく、指定した値と合致しているのかまで確認する
  (ageという変数が整数なのかだけでなく、0<age<100となっているかというイメージ)
- 指定した値と合致しない場合のエラーメッセージをカスタムする


## 2. 動作環境
fastapi 0.111.0
pydantic 2.7.1

## 3. フォルダ構成
```
project/ 
└─backend/ 
    ├─app/ # FastAPIによるエンドポイントの作成
        ├─routers
            └─student.py
        └─main.py
    └─models # pydanticによるデータ認証
        └─student_model.py
```
実行している内容としては以下の通りです。
- main.py
FastAPIアプリケーションのインスタンス作成やルーターの統合を行います。

- routers/student.py
エンドポイントを作成します。
studentの情報を登録します。
studentの登録する情報は「名前」「年齢」「専攻科目」になります。

- models/student_model.py
routers/student.pyで登録するデータの認証を行います。
「名前」は文字列が入力されたか、「年齢」は値が整数かつ0以上100未満か、
「専攻科目」は値が文字列かつ選択肢に用意した値かを確認します。

## 4. FastAPIのエンドポイントの引数でバリデーションする場合
- pydanticのモデルの作成
field_validatorデコレータを使用することで、バリデーションをカスタムすることができます。
    ```models/student_model.py
    from pydantic import BaseModel, field_validator
    from enume import Enum
    
    # 専攻科目として受け付ける選択肢を定義する
    class Major(str, Enum):
        engineering = "工学"
        literature = "文学部"
        economics = "経済学"
        
    
    class RegisterStudent(BaseModel):
        name: str
        # ここでageのデータ型がint型か確認する
        age: int
        # ここで専攻科目のデータ型がstr型か確認する
        major: str
    
        # field_validatorを使用して、年齢が指定した範囲内かを確認する
        @field_validator("age")
        def validate_age(cls, age):
            if not (0 <= age < 100):
                # 指定範囲外の値が入力された場合のエラーメッセージ
                raise ValueError("年齢は0歳以上100歳未満で入力してください")
            return age
    
        # field_validatorを使用して、専攻科目は用意した選択肢に含まれているかを確認する
        @field_validator("major")
        def validate_major(cls, major):
            if major not in Major:
                # 選択肢以外値が入力された場合のエラーメッセージ
                raise ValueError("専攻科目は選択肢の値から選択してください")
            return major
    ```

- エンドポイントの改修
エンドポイントにて先ほど作成した「RegisterStudent」モデルを使用するようにします。
    ```app/routers/user.py
    from fastapi import APIRouter, HTTPException, Depends
    # 作成したpydanticのモデルをインポート
    app.models.student_model import RegisterStudent
    
    router = APIRouter()
    
    
    @router.post("/students", status_code=201)
    # 受け取る引数の型ヒントにpydanticのモデルを指定する
    def create_student(student_info:RegisterStudent, db: Session = Depends(get_db)):
        try:
            # 処理内容は省略
        except Exception:
            raise HTTPException(status_code=500,
                                detail="サーバーでエラーが発生しました")
    ```

- main.pyの改修
エンドポイントの引数にて行うバリデーションでエラーとなった場合に例外を受け取るようにします。
公式ドキュメントにて以下の記載があります。
    >リクエストに無効なデータが含まれている場合、
    FastAPI は内部的にRequestValidationErrorを発生させます。
    また、そのためのデフォルトの例外ハンドラも含まれています。
    これをオーバーライドするにはRequestValidationErrorをインポートして@app.exception_handler(RequestValidationError)と一緒に使用して例外ハンドラをデコレートします。
この例外ハンドラはRequestと例外を受け取ります。

    そのため、@app.exception_handler(RequestValidationError)を使用します。

    
    ```app/main.py
    from fastapi import FastAPI, APIRouter
    from app.routers.user import router as user_router
    # 以下の2つを例外をキャッチするために追加
    from fastapi.exceptions import RequestValidationError
    from fastapi.responses import JSONResponse
    
    app = FastAPI()
    router = APIRouter()
    
    app.include_router(user_router)
    
    # 以下で例外をキャッチ
    @app.exception_handler(RequestValidationError)
    def validation_exception_handler(request, exc):
        if exc.errors():
            match exc.errors()[0]["type"]:
                # int型でない場合のエラーをキャッチ
                case "int_parsing":
                    return JSONResponse(status_code=422,
                                        content={"detail": "数値を入力してください"})
                # str型でない場合のエラーをキャッチ
                case "string_type":
                    return JSONResponse(status_code=422,
                                        content={"detail": "文字列を入力してください"})
                # field_validatorでカスタムしたバリデーションで発生したエラーをキャッチ
                case "value_error":
                    return JSONResponse(status_code=422,
                                        content={"detail": str(exc.errors()[0]["ctx"]["error"])})
                case _:
                    return JSONResponse(status_code=422,
                                        content={"detail": "入力データが正しくありません。入力データを確認してください"})
        else:
            return JSONResponse(status_code=422, content={"detail": "入力データが正しくありません。入力データを確認してください"})
    ```


## 5.try~except文でバリデーションする場合
FastAPIのエンドポイントの引数ではなく、通常の処理で使用する場合は以下のように使用できます。
FastAPIにおいてデータのバリデーションは基本的にエンドポイントの引数で行うようですが、FastAPIを使わない場合や引数でのバリデーションができない場合では以下のように実現できます。
```app/routers/user.py
from fastapi import APIRouter, HTTPException, Depends
# pydanticのモデルをインポート(4.で作成したものと同じモデルを使用します)
app.models.user_model import RegisterUserInfo

router = APIRouter()


@router.post("/students", status_code=201)
# 引数の受け取り方を変更
def create_user(name:str, age:int, major:int db: Session = Depends(get_db)):
    try:
        # モデルにてデータをバリデーション
        RegisterStudent(name, age, major)
        # 以下の処理は省略
    # バリデーションエラーを受け取る
    except ValidationError as validate_e:
        raise HTTPException(status_code=422, detail=str(validate_e.errors()[0]["ctx"]["error"]))
    except Exception:
        raise HTTPException(status_code=500,
                            detail="サーバーでエラーが発生しました")
```

## 6. 参考
https://fastapi.tiangolo.com/ja/tutorial/handling-errors/#fastapihttpexceptionstarlettehttpexception
https://docs.pydantic.dev/latest/errors/errors/#custom-errors