PydanticのEmailStrでやらかした話

# はじめに
先日、私が実装した部分で不具合が起こり、悲しい気持ちになったので記事にします。

# 起こった事象

1. 入力されたメールアドレスをDBに登録しメール送信
    - 仮にメールアドレス登録APIとします
2. メールアドレス登録APIで登録されたメールアドレスからレコードを特定し色々実施
    - 仮にレコード特定APIとします

といった2つのAPIを過去実装していたのですが、ドメインを大文字で入力された場合に、

- メールアドレス登録APIでメール送信は正常に実施されるが、レコード特定APIでレコード特定できない

といった事象が発生しました

# 原因

メールアドレス登録API/レコード特定APIのリクエスト属性の型が不一致だった。

- メールアドレス登録API => EmailStr型でメールアドレスを受付
- レコード特定API => Str型でメールアドレスを受付

具体的には以下のようなことが起こっていました。

1. メールアドレス登録APIにtest@EXAMPLE.COMでAPIコール
2. EmailStr型なのでドメイン部分は全て小文字(test@example.com)で登録される(DNSの仕様上、ドメイン部分は全て小文字で解釈されるためメール送信は正常に行われる)
3. レコード特定APIにメールアドレス登録APIで入力されたtest@EXAMPLE.COMでリクエストされる
4. DB登録はtest@example.comのためヒットせず非存在エラー 


うーーーーん。。。悲しい。。。。。。:cry:

# 何でPydanticのEmailStrを利用すると小文字に変換されるのか？

PydanticのEmailStrはemail_validatorと呼ばれるライブラリを利用して、メールアドレスの検証をしており、モデルインスタンスの作成時にメールアドレス検証が走ります。

```:python

class EmailStr(str):
    @classmethod
    def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None:
        field_schema.update(type='string', format='email')

    @classmethod
    def __get_validators__(cls) -> 'CallableGenerator':
        # included here and below so the error happens straight away
        import_email_validator() #この部分でemail_validatorのimportを実施

        yield str_validator
        yield cls.validate

    @classmethod
    def validate(cls, value: Union[str]) -> str:
        return validate_email(value)[1]
        
```

メールアドレス検証の中身のドメイン部分のバリデーションのuts46のリマップ処理内(uts46_remap)で置換が実施されていそうでした。

```:python
def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False) -> str:
    """Re-map the characters in the string according to UTS46 processing."""
    from .uts46data import uts46data
    output = ''

    for pos, char in enumerate(domain):
        code_point = ord(char)
        try:
            uts46row = uts46data[code_point if code_point < 256 else
                bisect.bisect_left(uts46data, (code_point, 'Z')) - 1]
            status = uts46row[1]
            replacement = None  # type: Optional[str]
            if len(uts46row) == 3:
                replacement = uts46row[2]  # type: ignore
            if (status == 'V' or
                    (status == 'D' and not transitional) or
                    (status == '3' and not std3_rules and replacement is None)):
                output += char
            elif replacement is not None and (status == 'M' or
                    (status == '3' and not std3_rules) or
                    (status == 'D' and transitional)):
                output += replacement
            elif status != 'I':
                raise IndexError()
        except IndexError:
            raise InvalidCodepoint(
                'Codepoint {} not allowed at position {} in {}'.format(
                _unot(code_point), pos + 1, repr(domain)))

    return unicodedata.normalize('NFC', output)```
```

uts46dataには、

- Unicodeのコードポイント
- ステータス
- 置換文字

のような形式のtupleが格納されており、
置換文字が存在する場合は、置換文字を変数に格納し、ステータスが"M"の場合に変換を実施するような挙動になっていました。

```:python
(0x41, 'M', 'a'),
(0x42, 'M', 'b'),
(0x43, 'M', 'c'),
(0x44, 'M', 'd'),
```


(誤りがあるかもしれないです:bow:)

# Pydanticの検証タイミングについて

上述した通り、model作成タイミングで属性(field)の検証が走ります。
今回の場合、メールアドレス登録APIの受付時点でモデルインスタンスが作成されるため、DB登録は小文字で値が入っていました。

また、Pydanticの内部処理を追ってみたところ、

1. ModelMetaclassの__new__処理
2. ModelFieldの__init__処理
3. prepare処理でvalidation処理の準備
4. BaseModelの__init__処理でvalidation実行

というような流れになっていました。
(間違っているかもしれないです。)


# 終わりに

急いで実装してしまったこともあり、EmailStr/Strの混在をAPI定義段階で見つけられなかったことが悔やまれます。

しかし、Pydanticと仲良くなれたことが収穫としてあるので、この経験を糧にして、精進していきたいと思いました。