Search code examples
pythonpython-3.xfastapipydantic

Make every field as optional with Pydantic


I'm making an API with FastAPI and Pydantic.

I would like to have some PATCH endpoints, where 1 or N fields of a record could be edited at once. Moreover, I would like the client to only pass the necessary fields in the payload.

Example:

class Item(BaseModel):
    name: str
    description: str
    price: float
    tax: float


@app.post("/items", response_model=Item)
async def post_item(item: Item):
    ...

@app.patch("/items/{item_id}", response_model=Item)
async def update_item(item_id: str, item: Item):
    ...

In this example, for the POST request, I want every field to be required. However, in the PATCH endpoint, I don't mind if the payload only contains, for example, the description field. That's why I wish to have all fields as optional.

Naive approach:

class UpdateItem(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    price: Optional[float] = None
    tax: Optional[float]

But that would be terrible in terms of code repetition.

Any better option?


Solution

  • This method prevents data validation

    Read this by @Anime Bk: https://stackoverflow.com/a/75011200

    Solution with metaclasses

    I've just come up with the following:

    
    class AllOptional(pydantic.main.ModelMetaclass):
        def __new__(cls, name, bases, namespaces, **kwargs):
            annotations = namespaces.get('__annotations__', {})
            for base in bases:
                annotations.update(base.__annotations__)
            for field in annotations:
                if not field.startswith('__'):
                    annotations[field] = Optional[annotations[field]]
            namespaces['__annotations__'] = annotations
            return super().__new__(cls, name, bases, namespaces, **kwargs)
    

    Use it as:

    class UpdatedItem(Item, metaclass=AllOptional):
        pass
    

    So basically it replace all non optional fields with Optional

    Any edits are welcome!

    With your example:

    from typing import Optional
    
    from fastapi import FastAPI
    from pydantic import BaseModel
    import pydantic
    
    app = FastAPI()
    
    class Item(BaseModel):
        name: str
        description: str
        price: float
        tax: float
    
    
    class AllOptional(pydantic.main.ModelMetaclass):
        def __new__(self, name, bases, namespaces, **kwargs):
            annotations = namespaces.get('__annotations__', {})
            for base in bases:
                annotations.update(base.__annotations__)
            for field in annotations:
                if not field.startswith('__'):
                    annotations[field] = Optional[annotations[field]]
            namespaces['__annotations__'] = annotations
            return super().__new__(self, name, bases, namespaces, **kwargs)
    
    class UpdatedItem(Item, metaclass=AllOptional):
        pass
    
    # This continues to work correctly
    @app.get("/items/{item_id}", response_model=Item)
    async def get_item(item_id: int):
        return {
            'name': 'Uzbek Palov',
            'description': 'Palov is my traditional meal',
            'price': 15.0,
            'tax': 0.5,
        }
    
    @app.patch("/items/{item_id}") # does using response_model=UpdatedItem makes mypy sad? idk, i did not check
    async def update_item(item_id: str, item: UpdatedItem):
        return item