はじめに
先日、私が実装した部分で不具合が起こり、悲しい気持ちになったので記事にします。
起こった事象
- 入力されたメールアドレスをDBに登録しメール送信
- 仮にメールアドレス登録APIとします
- メールアドレス登録APIで登録されたメールアドレスからレコードを特定し色々実施
- 仮にレコード特定APIとします
といった2つのAPIを過去実装していたのですが、ドメインを大文字で入力された場合に、
- メールアドレス登録APIでメール送信は正常に実施されるが、レコード特定APIでレコード特定できない
といった事象が発生しました
原因
メールアドレス登録API/レコード特定APIのリクエスト属性の型が不一致だった。
- メールアドレス登録API => EmailStr型でメールアドレスを受付
- レコード特定API => Str型でメールアドレスを受付
具体的には以下のようなことが起こっていました。
- メールアドレス登録APIにtest@EXAMPLE.COMでAPIコール
- EmailStr型なのでドメイン部分は全て小文字(test@example.com)で登録される(DNSの仕様上、ドメイン部分は全て小文字で解釈されるためメール送信は正常に行われる)
- レコード特定APIにメールアドレス登録APIで入力されたtest@EXAMPLE.COMでリクエストされる
- DB登録はtest@example.comのためヒットせず非存在エラー
うーーーーん。。。悲しい。。。。。。
何でPydanticのEmailStrを利用すると小文字に変換されるのか?
PydanticのEmailStrはemail_validatorと呼ばれるライブラリを利用して、メールアドレスの検証をしており、モデルインスタンスの作成時にメールアドレス検証が走ります。
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)で置換が実施されていそうでした。
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"の場合に変換を実施するような挙動になっていました。
(0x41, 'M', 'a'),
(0x42, 'M', 'b'),
(0x43, 'M', 'c'),
(0x44, 'M', 'd'),
(誤りがあるかもしれないです)
Pydanticの検証タイミングについて
上述した通り、model作成タイミングで属性(field)の検証が走ります。
今回の場合、メールアドレス登録APIの受付時点でモデルインスタンスが作成されるため、DB登録は小文字で値が入っていました。
また、Pydanticの内部処理を追ってみたところ、
- ModelMetaclassの__new__処理
- ModelFieldの__init__処理
- prepare処理でvalidation処理の準備
- BaseModelの__init__処理でvalidation実行
というような流れになっていました。
(間違っているかもしれないです。)
終わりに
急いで実装してしまったこともあり、EmailStr/Strの混在をAPI定義段階で見つけられなかったことが悔やまれます。
しかし、Pydanticと仲良くなれたことが収穫としてあるので、この経験を糧にして、精進していきたいと思いました。