-4

I am trying to set up custom validation for pydantic models. According to the the pydantic docs, custom validators can be applied to a field using the Annotated Pattern or field_validator() decorator applied to a class method:

from typing import Annotated

from pydantic import AfterValidator, BaseModel, ValidationError


def is_even(value: int) -> int:
    if value % 2 == 1:
        raise ValueError(f'{value} is not an even number')
    return value  


class Model(BaseModel):
    number: Annotated[int, AfterValidator(is_even)]


try:
    Model(number=1)
except ValidationError as err:
    print(err)
    """
    1 validation error for Model
    number
      Value error, 1 is not an even number [type=value_error, input_value=1, input_type=int]
    """

field_validator() decorator:

from pydantic import BaseModel, ValidationError, field_validator


class Model(BaseModel):
    number: int

    @field_validator('number', mode='after')  
    @classmethod
    def is_even(cls, value: int) -> int:
        if value % 2 == 1:
            raise ValueError(f'{value} is not an even number')
        return value  


try:
    Model(number=1)
except ValidationError as err:
    print(err)
    """
    1 validation error for Model
    number
      Value error, 1 is not an even number [type=value_error, input_value=1, input_type=int]
    """

I have tried both the Annotated and field_validator decorator methods, but each one causes an error. The error occurs when FastAPI perform the initial validation on the pydantic model received as a request from a client:

TypeError: Object of type ValueError is not JSON serializable

Upon further inspect from the RequestValidationError, the error handler displayed the issue that the raised ValueError is not converted to the correct dict for the ctx key, causing the JSON Serializable error since the ValueError is not serializable:

fastapi.exceptions.RequestValidationError: [{'type': 'value_error', 'loc': ('body', 'email'), 'msg': 'value is not a valid email address: An email address must have an @-sign.', 'input': 'user+dmiexample.com', 'ctx': {'reason': 'An email address must have an @-sign.'}}, {'type': 'value_error', 'loc': ('body', 'password'), 'msg': 'Value error, password must have one uppercase character', 'input': 'password', 'ctx': {'error': ValueError('password must have one uppercase character')}}]

I haven't really figured out a way to convert the ValueError to the expected {'reason': 'validation_msg'} because according to the official validation pydantic docs, they use ValueError for their validation functions. Meaning, the solution would deviate from the official pydantic documentation and the fastAPI documentation only mentions using Field for basic validation.

Both the EmailStr and constr datatype to a pydantic field as documented here, have produced the correct output in terms of validating it correctly:

from pydantic import BaseModel, EmailStr, constr, ValidationError

# Defome User model
class User(BaseModel):
    email: EmailStr
    password: constr(
        min_length=8,
        max_length=20
    )

I just can't get the ValueError to be converted correctly.

requirements.txt:

fastapi==0.116.1
fastapi-cli==0.0.8
fastapi-cloud-cli==0.1.5
pydantic==2.11.7
pydantic_core==2.33.2
sqlmodel==0.0.24

Model:

class UserCreate(SQLModel):
    email: EmailStr
    password: Annotated[str, AfterValidator(ApiValidators.is_password_valid)]

Validator:

class ApiValidators:
    @staticmethod
    def is_password_valid(value: str) -> str:
        """Checks if a password is valid and strong"""
        if len(value) < 8:
            raise ValueError(f"{value} must have at least 8 characters")
        if not re.search(ApiPatterns.LOWER_CASE_CHARS, value):
            raise ValueError(f"{value} must have one lowercase character")
        if not re.search(ApiPatterns.UPPER_CASE_CHARS, value):
            raise ValueError(f"{value} must have one uppercase character")
        if not re.search(ApiPatterns.DIGITS, value):
            raise ValueError(f"{value} must have atleast one digit")
        if not re.search(ApiPatterns.SPECIAL_CHARS, value):
            raise ValueError(f"{value} must have atleast one special character")

        return value

1 Answer 1

0

Investigation

After struggling for about 2 hours, I found the solution.

I inspected the EmailStr datatype, discovering that the validation raises a PydanticCustomError:

def validate_email(value: str) -> tuple[str, str]:
    """Email address validation using [email-validator](https://pypi.org/project/email-validator/).

    Returns:
        A tuple containing the local part of the email (or the name for "pretty" email addresses)
            and the normalized email.

    Raises:
        PydanticCustomError: If the email is invalid.

    Note:
        Note that:

        * Raw IP address (literal) domain parts are not allowed.
        * `"John Doe <[email protected]>"` style "pretty" email addresses are processed.
        * Spaces are striped from the beginning and end of addresses, but no error is raised.
    """
    if email_validator is None:
        import_email_validator()

    if len(value) > MAX_EMAIL_LENGTH:
        raise PydanticCustomError(
            'value_error',
            'value is not a valid email address: {reason}',
            {'reason': f'Length must not exceed {MAX_EMAIL_LENGTH} characters'},
        )

    m = pretty_email_regex.fullmatch(value)
    name: str | None = None
    if m:
        unquoted_name, quoted_name, value = m.groups()
        name = unquoted_name or quoted_name

    email = value.strip()

    try:
        parts = email_validator.validate_email(email, check_deliverability=False)
    except email_validator.EmailNotValidError as e:
        raise PydanticCustomError(
            'value_error', 'value is not a valid email address: {reason}', {'reason': str(e.args[0])}
        ) from e

    email = parts.normalized
    assert email is not None
    name = name or parts.local_part
    return name, email

Sure enough, using PydanticCustomError gets serialized under the hood by FastAPI and SQLModel, generating the correct exception. For any custom validators, use PydanticCustomError

Note: The field_validator() decorator opts to the after validation type. I suggest using the AfterValidator Annotated to ensure that your custom validation takes effect or to use the field_validator()'s default of after.

Solution Implementation

Rather than having to rewrite "value_error" as the message and reason, create a base exception and raise errors with that exception.

Custom Exception:

from pydantic_core import PydanticCustomError


class ValidationValueError(ValueError):
    """Wrapper around PydanticCustomError for custom pydantic validation"""

    def __init__(self, field: str, reason: str) -> None:
        message: str = f"value is not a valid {field}: {reason}"
        super().__init__(message)
        raise PydanticCustomError(
            "value_error",
            message,
            {"reason": reason},
        )

Updated Validator:

import re
from api.src.models.v1.errors import ValidationValueError
from api.src.utils.constants.patterns import ApiPatterns


class ApiValidators:
    @staticmethod
    def is_password_valid(value: str) -> str:
        """Checks if a password is valid and strong"""
        if len(value) < 8:
            raise ValidationValueError(
                "password",
                "password must have at least 8 characters",
            )
        if not re.search(ApiPatterns.LOWER_CASE_CHARS, value):
            raise ValidationValueError(
                "password",
                "password must have one lowercase character",
            )
        if not re.search(ApiPatterns.UPPER_CASE_CHARS, value):
            raise ValidationValueError(
                "password",
                "password must have one uppercase character",
            )
        if not re.search(ApiPatterns.DIGITS, value):
            raise ValidationValueError(
                "password",
                "password must have atleast one digit",
            )
        if not re.search(ApiPatterns.SPECIAL_CHARS, value):
            raise ValidationValueError(
                "password",
                "password must have atleast one special character",
            )

        return value

Updated Model:

from sqlmodel import SQLModel
from pydantic import AfterValidator, EmailStr
from typing import Annotated
from api.src.utils.validation import ApiValidators


class UserCreate(SQLModel):
    email: EmailStr
    password: Annotated[str, AfterValidator(ApiValidators.is_password_valid)]

I hope this helps any wandered soul on this error.

Happy programming and good luck --> voorspoed

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.