Search code examples
pythonfastapipydanticpydantic-v2

Allow only certain fields of Pydantic model to be passed to FastAPI endpoint


Let's say I have a Pydantic model with validation:

Name = Annotated[str, AfterValidator(validate_name)]

class Foo(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    name: Name

And a FastAPI endpoint:

@app.post('/foos')
def create_foo(foo: Foo) -> Foo:
    save_to_database(foo)
    return foo

I only want the caller to be able to pass a value for name, but not for id. Is there any way to do something like this?

def create_foo(foo: Annotated[Foo, Body(include=['id'])]) -> Foo:

I know I can do:

@app.post('/foos')
def create_foo(name: Annotated[str, Body(embed=True)]) -> Foo:
    foo = Foo(name=name)
    save_to_database(foo)
    return foo

But then the implicit validation error handling doesn't work anymore, and I need to add more code to do that.

Any elegant way of handling that?


Solution

  • Option 1

    You could hide/exclude a field on object instantiation/creation, by using Private model attributes. Pydantic will exclude model attributes that have a leading underscore. As described in the linked documentation:

    Attributes whose name has a leading underscore are not treated as fields by Pydantic, and are not included in the model schema. Instead, these are converted into a "private attribute" which is not validated or even set during calls to __init__, model_validate, etc.

    Note, though, that:

    As of Pydantic v2.1.0, you will receive a NameError if trying to use the Field function with a private attribute. Because private attributes are not treated as fields (as mentioned earlier), the Field() function cannot be applied.

    Thus, in Pydantic V2, you could use the PrivateAttr instead of Field function, along with the default_factory parameter, in order to define a callable that will be called to generate a dynamic default value (i.e., different for each model instance)—in this case, a UUID.

    Woriking Example

    from fastapi import FastAPI
    from pydantic import BaseModel, PrivateAttr
    from uuid import UUID, uuid4
    
    
    class Foo(BaseModel):
        _id: UUID = PrivateAttr(default_factory=uuid4)
        name: str
    
    
    app = FastAPI()
    
    
    @app.post("/foo")
    def create_foo(foo: Foo):
        print(foo._id)
        return foo
    

    Option 2

    Simply a variation of the above (see the documentation for more details), without using the PrivateAttr and default_factory. Instead, the __init__ method is directly used to automatically generate a new UUID.

    Woriking Example

    from fastapi import FastAPI
    from pydantic import BaseModel
    from uuid import UUID, uuid4
    
    
    class Foo(BaseModel):
        _id: UUID
        name: str
        
        def __init__(self, **data):
            super().__init__(**data)
            self._id = uuid4()
    
    
    app = FastAPI()
    
    
    @app.post("/foo")
    def create_foo(foo: Foo):
        print(foo._id)
        return foo
    

    Option 3

    Another way would be to use two different Pydantic models, one meant to be used by the user, while the second one, which should inherit from the first (base) one, by the backend. Similar examples are given in FastAPI's Extra Models documentation, as well as in Full Stack FastAPI Template.

    Woriking Example

    from fastapi import FastAPI
    from pydantic import BaseModel, Field
    from uuid import UUID, uuid4
    
    
    class BaseFoo(BaseModel):
        name: str
    
    
    class Foo(BaseFoo):
        id: UUID = Field(default_factory=uuid4)
        
    
    app = FastAPI()
    
    
    @app.post("/foo")
    def create_foo(base: BaseFoo):
        foo = Foo(**base.model_dump())  # Foo(name=base.name) should work as well
        print(foo.id)
        return base
    

    Option 4

    This is a variation of Options 1 and 2, modified in a way that would allow one to define their private attribute without using an underscore (if that's a requirement in your project), but still get the same result as if any of the previous options were used.

    In this case, the Field function is used. Since the attribute is meant to be hidden from the client, you should need to set the exclude attribute to True, so that if you return the Pydantic model instance back to the client, that attribute won't be included. Also, in Pydantic V2, you could use the SkipJsonSchema annotation, in order to skip that field from the generated JSON schema, as in Swagger UI autodocs, for instance (for Pydantic V1 solutions, please have a look at this github post and its related discussion).

    Now, there is nothing from stopping the client to pass the hidden attribute in their JSON request body, regardless of hiding it from the schema and defining it as optional/non-required (i.e., Field(default=None)). Since this solution uses a regular Field and not PrivateAttr, using the default_factory attribute, as in Option 1 and as shown below:

    class Foo(BaseModel):
        id: SkipJsonSchema[UUID] = Field(default_factory=uuid4, exclude=True)
        name: str
    

    would not be the most suitalbe approach, as if the client passed a value for id, that value would be assigned to that field.

    However, using a similar approach to Option 2, i.e., replacing default_factory with __init__ (which is used to generate the UUID for id field), even if the client passed a value for id, it would be "ignored", and the one generated by the model would be assigned to the field.

    Woriking Example

    from fastapi import FastAPI
    from pydantic import BaseModel, Field
    from uuid import UUID, uuid4
    from pydantic.json_schema import SkipJsonSchema
    
    
    class Foo(BaseModel):
        id: SkipJsonSchema[UUID] = Field(default=None, exclude=True)
        name: str
        
        def __init__(self, **data):
            super().__init__(**data)
            self.id = uuid4()
        
    
    app = FastAPI()
    
    
    @app.post("/foo")
    def create_foo(foo: Foo):
        print(foo.id)
        return foo
    

    This answer and this answer might also prove helpful to future readers.

    How to populate a Pydantic model without default_factory or __init__ overwriting the provided field value

    While this is not an issue when using Option 3 provided above (and one could opt going for that option, if they wish), it might be when using one of the remaining options, depending on the method used to populate the model.

    For instance, if you populate the model using Foo(**data), where data is an already existing model instance from your database, using one of the options described earlier (besides Option 3 that does not suffer from this issue), the _id or id value (included in the data dictionary) that is passed to the model would be replaced/overwritten by a newly generated one.

    To overcome this issue, the following solutions are suggested.

    Solution 1

    After calling Foo(**data), you could simply replace the newly generated id with the existing one, by setting the value for that specific field to data["_id"] or data["id"] (depending on the Option used).

    Example:

    data = {"name": "foo", "_id": "7c4308a2-0f32-1243-b2ad-bf214a24a5aa"}
    f = Foo(**data)
    f._id = data["_id"]
    

    Solution 2

    Instead of using Foo(**data), which uses the model's __init__ method, and hence, a new id value is generated when called—for the sake of completeness, it should also be noted that there is an additional method, i.e., model_validate(), which is very similar to the __init__ method of the model, except it takes a dict or an object rather than keyword arguments—one could use the model_construct() method (in Pydantic V1, this used to be construct()).

    As per the documentation:

    Creating models without validation

    Pydantic also provides the model_construct() method, which allows models to be created without validation. This can be useful in at least a few cases:

    • when working with complex data that is already known to be valid (for performance reasons)
    • when one or more of the validator functions are non-idempotent, or
    • when one or more of the validator functions have side effects that you don't want to be triggered.
    Warning

    model_construct() does not do any validation, meaning it can create models which are invalid. You should only ever use the model_construct() method with data which has already been validated, or that you definitely trust.

    [...]

    When constructing an instance using model_construct(), no __init__ method from the model or any of its parent classes will be called, even when a custom __init__ method is defined.

    Example:

    data = {"name": "foo", "_id": "7c4308a2-0f32-1243-b2ad-bf214a24a5aa"}
    f = Foo.model_construct(**data)