Clayton
Clayton

Reputation: 1

Using Python and Pydantic, how can I add custom validation to a field and have it return errors inline with the other errors Pydantic finds?

I have a model for a search endpoint that looks like this:

class ArtistInboundSearchModel(BaseModel):
    ids: Optional[str] = Query(default=None)
    name: Optional[str] = Query(default=None)
    name_like: Optional[str] = Query(default=None) 
    page: Optional[int] = Query(default=None) 
    page_length: Optional[int] = Query(default=None) 

As expected, if I post to an endpoint taking this model with "page_length" equal to "dog" AND "page" equal to "cat", I get a 422 response as Pydantic intercepts the request and validates it. This 422 response has 2 errors in it, one for each failed validation, as expected:

Response to "/artists?page=cat&page_length=dog":

{
    "detail": [
        {
            "type": "int_parsing",
            "loc": [
                "query",
                "page"
            ],
            "msg": "Input should be a valid integer, unable to parse string as an integer",
            "input": "cat"
        },
        {
            "type": "int_parsing",
            "loc": [
                "query",
                "page_length"
            ],
            "msg": "Input should be a valid integer, unable to parse string as an integer",
            "input": "dog"
        }
    ]
}

GREAT. Now let's talk about the query arg "ids". The purpose of this query arg is to allow the requestor to supply a comma-delimited list of ids and restrict the search to those ids. We will need to validate the ids input and make sure each id is a valid guid, otherwise, when we tokenize the string and try to convert them to ids, we'll get an exception.

The only real documentation I found around this suggested doing the following to implement a property validator (validate_comma_delimited_ids and generate_invalid_comma_delimited_ids_message are mine; they are unit tested and work as expected):


    @field_validator('ids')
    def ids_valid(cls, value: str): 
        results = validate_comma_delimited_ids(value)

        if(results is not None):
            message = generate_invalid_comma_delimited_ids_message("ids", results)

            raise ValueError(message)
        
        return value

This performs the proper validation, and if it passes, returns an unchanged value for me to tokenize and pass along to BL. If the validation fails, though, instead of adding it to the list of failures, it raises the ValueError and i get a 500 response for unhandled errors.

I looked around for a long time trying to find some information, maybe another parameter that is the rolling list of errors, or something like that and I just cannot.

The desired behavior, and the behavior I would expect from any other modern validation framework or my own validation framework, would be that I could have my validation block add a validation error to a running list so that it worked something like:

Response to "/artists?page=cat&page_length=dog?ids=notanid,ohboy"

{
    "detail": [
        {
            "type": "int_parsing",
            "loc": [
                "query",
                "page"
            ],
            "msg": "Input should be a valid integer, unable to parse string as an integer",
            "input": "cat"
        },
        {
            "type": "int_parsing",
            "loc": [
                "query",
                "page_length"
            ],
            "msg": "Input should be a valid integer, unable to parse string as an integer",
            "input": "dog"
        },
        {
            "type": "custom_parsing",
            "loc": [
                "query",
                "ids"
            ],
            "msg": "Property should be a comma-delimited list of valid uuidv4 values.",
            "input": "notanid,ohboy"
        }
    ]
}

Or really whatever I want. How do I access the rolling list of validation errors in my validation block so I can push failures to it and integrate with Pydantics usual operations?

I want a solution that makes it easy to define custom validation right in the model alongside the other validation definitions, as this is what other frameworks do and also is the preferred developer experience for my devs. I believe that pydantic isn't bad, per se, so I think there must be some way to do this, otherwise it is really not a useful tool for building apis.

Upvotes: 0

Views: 549

Answers (2)

Clayton
Clayton

Reputation: 1

Good news to anyone having this same issue: it wasn't well documented, and Yurii above is correct that the "normal" way you would solve this with bodies is not supported for query args, but I solved the above issue myself without hacking! Here's how!

  1. Define your validation method as just any ole method, as so:
def validate_ids(value: str | None): 
    if value is not None:
        common_utilities: CommonUtilities = CommonUtilities()
        results: dict[int, str] | None = validate_comma_delimited_ids(value)
        
        if(results is not None): 
            error_message =  f"Property should be a comma-delimited list of valid uuidv4 values."
        
            raise PydanticCustomError(
                'invalid_id_list',
                '{message}',
                dict(
                    message =  error_message
                )
            )
        
        return value
  1. When defining your model, you use an annotated type as so:
class ArtistInboundSearchModel(BaseModel):
    ids: Annotated[Optional[str], BeforeValidator(validate_ids)] = None
    name: Optional[str] = None
    name_like: Optional[str] = None
    page: Optional[int] = None 
    page_length: Optional[int] = None
  1. This will result in the desired error message appearing alongside the others.

Upvotes: 0

Yurii Motov
Yurii Motov

Reputation: 2353

FastAPI doesn't support Pydantic models for Query parameters. It worked somehow with Pydantic v.1, but was never documented, and it doesn't work with Pydantic v.2.

You can only use Pydantic models for Body parameters and responses.

As for passing these parameters as Body parameters, it works the way you expect:

from typing import Optional
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator


class ArtistInboundSearchModel(BaseModel):
    ids: Optional[str] = Field(default=None)
    page: Optional[int] = Field(default=None) 

    @field_validator('ids')
    def ids_valid(cls, value: str): 
        raise ValueError("-----")

app = FastAPI()

@app.post("/")
def get_index(a: ArtistInboundSearchModel):
    pass

def test_index():
    with TestClient(app) as client:
        resp = client.post("/", json={"page": "cat", "ids": ""})
        assert resp.status_code == 422
        data = resp.json()
        assert len(data["detail"]) == 2, data["detail"]

Upvotes: 0

Related Questions