phonenumbers.PhoneNumber as a FastAPI response_model field

Viewed 25

FastAPI supports having some (predefined) classes as pydantic model fields and have them be converted to JSON. For example datetime:

class MyModel(pydantic.BaseModel):
  created_at: datetime.datetime

When used this model would convert datetime to/from str in the output/input JSON, when used as a response model or request body model, respectively.

I would like to have similar type safety for my own classes:

class MyModel(pydantic.BaseModel):
  phone_number: phonenumbers.PhoneNumber

This can be made to work for request body models by using a custom validator but I also need MyModel to be convertible to JSON. Is this possible to achieve today? Note that I don't control the PhoneNumber class so the solution can't involve modifying that class.

Edit: the best I've come up with but still doesn't work:

def phone_number_validator(value: str) -> phonenumbers.PhoneNumber:
    ...

class MyModel(pydantic.BaseModel):
    phone_number: phonenumbers.PhoneNumber

    _validate_phone_number = pydantic.validator(
        'phone_number', pre=True, allow_reuse=True)(phone_number_validator)

    class Config:
        arbitrary_types_allowed = True
        json_encoders = {
            phonenumbers.PhoneNumber: lambda p: phonenumbers.format_number(
                p, phonenumbers.PhoneNumberFormat.E164),
        }

This fails in FastAPI with:

fastapi.exceptions.FastAPIError: Invalid args for response field! Hint: check that <class 'phonenumbers.phonenumber.PhoneNumber'> is a valid pydantic field type
1 Answers

As you have already noticed, this is a bug in FastAPI. I just created a PR to fix it.

The arbitrary_types_allowed config directive is lost during the processing of the response model.

Until the PR is merged, you can use the workaround of monkey-patching the Pydantic BaseConfig like this:

from pydantic import BaseConfig

...

BaseConfig.arbitrary_types_allowed = True

# Your routes here:
...

But keep in mind that independent from this bug you might also need to adjust the JSON schema for the custom type, if you want the OpenAPI docs to work properly. Arbitrary types are not generally supported by the BaseModel.schema() method.

For that you can probably just inherit from phonenumbers.PhoneNumber and set a proper __modify_schema__ classmethod. See here for an example. Though I have not looked thoroughly into phonenumbers.

Check the example code in my PR text, if you want to see how you could implement validation and schema modification on your PhoneNumber subclass.

PS

Here is a full working example:

from __future__ import annotations
from typing import Union

from fastapi import FastAPI
from phonenumbers import PhoneNumber as _PhoneNumber
from phonenumbers import NumberParseException, PhoneNumberFormat
from phonenumbers import format_number, is_possible_number, parse
from pydantic import BaseModel, BaseConfig


class PhoneNumber(_PhoneNumber):
    @classmethod
    def __get_validators__(cls):
        yield cls.validate

    @classmethod
    def validate(cls, v: Union[str, PhoneNumber]) -> PhoneNumber:
        if isinstance(v, PhoneNumber):
            return v
        try:
            number = parse(v, None)
        except NumberParseException as ex:
            raise ValueError(f'Invalid phone number: {v}') from ex
        if not is_possible_number(number):
            raise ValueError(f'Invalid phone number: {v}')
        return number

    @classmethod
    def __modify_schema__(cls, field_schema: dict) -> None:
        field_schema.update(
            type="string",
            # pattern='^SOMEPATTERN?$',
            examples=["+49123456789"],
        )

    def json_encode(self) -> str:
        return format_number(self, PhoneNumberFormat.E164)


class MyModel(BaseModel):
    phone_number: PhoneNumber

    class Config:
        arbitrary_types_allowed = True
        json_encoders = {
            PhoneNumber: PhoneNumber.json_encode,
        }


test_number = PhoneNumber(
    country_code=49,
    national_number=123456789
)


# Test:
obj = MyModel(phone_number=test_number)
obj_json = obj.json()
parsed_obj = MyModel.parse_raw(obj_json)
assert obj == parsed_obj

BaseConfig.arbitrary_types_allowed = True

api = FastAPI()


@api.get("/model/", response_model=MyModel)
def example_route():
    return MyModel(phone_number=test_number)
Related