0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PydanticのEmailStrでやらかした話

Posted at

はじめに

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

起こった事象

  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と仲良くなれたことが収穫としてあるので、この経験を糧にして、精進していきたいと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?