Search code examples
pythonfastapipydantic

Pydantic / FastAPI, how to set up a case-insensitive model with suggested values


Using FastAPI I have set up a POST endpoint that takes a command, I want this command to be case insensitive, while still having suggested values (i.e. within the SwaggerUI docs)

For this, I have set up an endpoint with a Command class as a schema for the POST body parameters:

@router.post("/command", status_code=HTTPStatus.ACCEPTED)  # @router is a fully set up APIRouter()
async def control_battery(command: Command):
    result = do_work(command.action)
    return result

For Command I currently have 2 possible versions, which both do not have the full functionality I desire.

from fastapi import HTTPException
from pydantic import BaseModel, field_validator
from typing import Literal

## VERSION 1
class Command(BaseModel):
    action: Literal["jump", "walk", "sleep"]


## VERSION 2
class Command(BaseModel):
    action: str

    @field_validator('action')
    @classmethod
    def validate_command(cls, v: str) -> str:
        """
        Checks if command is valid and converts it to lower.
        """
        if v.lower() not in {'jump', 'walk', 'sleep'}:
            raise HTTPException(status_code=422, detail="Action must be either 'jump', 'walk', or 'sleep'")
        return v.lower()

Version 1 is obviously not case sensitive, but has the correct 'suggested value' behaviour, as below.

Whereas Version 2 has the correct case sensitivity and allows for greater control over the validation, but no longer shares suggested values with users of the schema. e.g., in the image above "jump" would be replaced with "string".

How do I combine the functionality of both of these approaches?


Solution

  • Specify the type as either a literal or some string and keep the validation:

    class Command(BaseModel):
        action: Literal["jump", "walk", "sleep"] | str
    
        @field_validator('action')
        @classmethod
        def validate_command(cls, v: str) -> str:
            """
            Checks if command is valid and converts it to lower.
            """
            if v.lower() not in {'jump', 'walk', 'sleep'}:
                raise HTTPException(status_code=422, detail="Action must be either 'jump', 'walk', or 'sleep'")
            return v.lower()
    

    A purist might say that Literal["jump", "walk", "sleep"] | str is the same as str, but in this case both a human and a computer can infer that "jump" and the other two values are special inputs. It works for providing example values.

    You may not want to repeat the literal values - a mypy-passing solution would be to make them an enum:

    from enum import Enum
    
    class Action(Enum):
        jump = "jump"
        walk = "walk"
        sleep = "sleep"
    
    
    class Command(BaseModel):
        action: Action | str
    
        @field_validator('action')
        @classmethod
        def validate_command(cls, v: str) -> str:
            """
            Checks if command is valid and converts it to lower.
            """
            if v.lower() not in Action.__members__:
                raise HTTPException(status_code=422, detail="Action must be either 'jump', 'walk', or 'sleep'")
            return v.lower()
    

    Another solution would be to keep using the Literal instead of the enum and use get_args to extract the values of the literal type, as explained in this answer