Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does FastAPI execute the Pydantic constructor twice when returning from the route function?

I have a use case where I want to create some values inside my class on the construction of the model. However, when I return the class to FastAPI for converting to JSON when I call the API, the constructor gets run again and I can get differing values from my original instance.

Here is a contrived example to illustrate:

class SomeModel(BaseModel):
    public_value: str
    secret_value: Optional[str]

    def __init__(self, **data):
        super().__init__(**data)
        # this could also be done with default_factory
        self.secret_value = randint(1, 5)


def some_function() -> SomeModel:
    something = SomeModel(public_value="hello")
    print(something)
    return something


@app.get("/test", response_model=SomeModel)
async def exec_test():
    something = some_function()
    print(something)
    return something

The console output is:

public_value='hello' secret_value=1
public_value='hello' secret_value=1

But the JSON in the web API is:

{
  "public_value": "hello",
  "secret_value": 2
}

When I step through the code I can see the __init__ being called twice.

First on the construction something = SomeModel(public_value="hello").

Second, which is unexpected to me, is in the API handler exec_test on the return something call.

If this is the wrong method to set up some internal data within the class please let me know the correct method to use. Otherwise, this seems like this is unintended behavior of one of the modules.

like image 387
Ryan Goss Avatar asked Sep 07 '21 14:09

Ryan Goss


2 Answers

This is supposedly expected behavior when you use response_model. It is not quite explained clearly in the docs, but in the Response Model section, it says that:

FastAPI will use this response_model to:

  • Convert the output data to its type declaration.
  • Validate the data.
    ...

When you return something at the end of your route function exec_test, FastAPI will convert that to another SomeModel instance, do validation, and then return that validated instance. So, you get a different instance than the one you originally returned.

I've had this same issue before: Why does the response_model seem to __init__ the same object twice?, which led to this old issue: [BUG] Double validation pydantic model when used response_model. Most of the responses are along the lines of "it's expected":

This is something expected and the point of the ResponseModel, it ensures your data is in the correct order.

It's not exactly duplicated. Internally it validates once (if you add a response_model), but on your endpoint function you're manually validating once again.

I believe the answer would be: it's the expected result. 😗

The solution I've since learned is to never mutate the models on __init__ like that. The answer provided by alex_noname using Field or validators are, I also think, the best approaches to avoid this issue.

Here are the other workarounds if you really need that mutation on __init__:

  1. Just skip validation on the route function

    In some_function, where you are instantiating SomeModel, that will already raise validation errors if the arguments are wrong (ex. public_value={'a': 1}). Repeating the validation with response_model could be redundant. There was even a PR on FastAPI to skip validation on response_model but that never got merged.

    You can just remove response_model, and replace it with responses to maintain the documentation with OpenAPI.

    # @app.get("/test", response_model=SomeModel)
    @app.get("/test", responses={200: {"model": SomeModel}})
    async def exec_test():
        something = some_function()
        print(something)
        return something
    
  2. Let some_function return the raw values of your model, rather than an instance of the model itself. Basically, moving/delaying the instantation and validation of your model onto the route function.

    def some_function() -> dict:
        # something = SomeModel(public_value="hello")
        something = {"public_value": "hello"}
        print(something)
        return something
    
    @app.get("/test", response_model=SomeModel)
    async def exec_test():
        something = some_function()
        print(something)
        return something
    

    As the docs says, FastAPI will convert the return value to the response_model type, thereby instantiating the model. Doing it here means validation will happen very late. Also, you'll have to deal with losing the convenience of using Pydantic models everywhere.

  3. Related to #2, have 2 separate models, 1 for internal use and 1 for putting into the response_model. This is similar to having separate input and output models example from the FastAPI docs:

    class InternalModel(BaseModel):
        public_value: str
    
    class OutputModel(BaseModel):
        public_value: str
        secret_value: Optional[str]
    
        def __init__(self, **data):
            super().__init__(**data)
            self.secret_value = randint(1, 5)
    
    def some_function() -> InternalModel:
        something = InternalModel(public_value="hello")
        print(something)
        return something
    
    @app.get("/test", response_model=OutputModel)
    async def exec_test():
        something = some_function()
        print(something)
        return something
    
like image 129
Gino Mempin Avatar answered Oct 12 '22 12:10

Gino Mempin


Since you are using response_model for path operation, your return value is validated against it. But since you are returning an already validated instance of the model, this happens twice. It wouldn't be noticeable if you didn't use a mutating __init__ method that can generate different values for every instantiation of the model regardless of the input values.

I think the most preferable solution is to use the default_factory function, since in this case the dynamic values ​​will be generated only at the time of instantiation of objects in your code, and the ready-made value will be used in the process of model validation while returning.

class SomeModel(BaseModel):
    public_value: str
    secret_value: int = Field(default_factory=lambda: randint(1, 5))

If for some reason you do not want to use the approach above, then you can use an always validator, that will allow you to check that the value is passed or it needs to be created:

class SomeModel(BaseModel):
    public_value: str
    secret_value: int = None

    @validator('secret_value', pre=True, always=True)
    def secret_validator(cls, v):
        return randint(1, 5) if v is None else v
like image 35
alex_noname Avatar answered Oct 12 '22 12:10

alex_noname