pythonfastapipydantic

How to use a Pydantic model with Form data in FastAPI?


I am trying to submit data from HTML forms and validate it with a Pydantic model.

Using this code

from fastapi import FastAPI, Form
from pydantic import BaseModel
from starlette.responses import HTMLResponse


app = FastAPI()

@app.get("/form", response_class=HTMLResponse)
def form_get():
    return '''<form method="post"> 
    <input type="text" name="no" value="1"/> 
    <input type="text" name="nm" value="abcd"/> 
    <input type="submit"/> 
    </form>'''


class SimpleModel(BaseModel):
    no: int
    nm: str = ""

@app.post("/form", response_model=SimpleModel)
def form_post(form_data: SimpleModel = Form(...)):
    return form_data

However, I get the HTTP error: "422 Unprocessable Entity"

{
    "detail": [
        {
            "loc": [
                "body",
                "form_data"
            ],
            "msg": "field required",
            "type": "value_error.missing"
        }
    ]
}

The equivalent curl command (generated by Firefox) is

curl 'http://localhost:8001/form' -H 'Content-Type: application/x-www-form-urlencoded' --data 'no=1&nm=abcd'

Here the request body contains no=1&nm=abcd.

What am I doing wrong?


Solution

  • I found a solution that can help us to use Pydantic with FastAPI forms :)

    My code:

    class AnyForm(BaseModel):
        any_param: str
        any_other_param: int = 1
    
        @classmethod
        def as_form(
            cls,
            any_param: str = Form(...),
            any_other_param: int = Form(1)
        ) -> AnyForm:
            return cls(any_param=any_param, any_other_param=any_other_param)
    
    @router.post('')
    async def any_view(form_data: AnyForm = Depends(AnyForm.as_form)):
            ...
    

    It's shown in the Swagger as a usual form.

    It can be more generic as a decorator:

    import inspect
    from typing import Type
    
    from fastapi import Form
    from pydantic import BaseModel
    from pydantic.fields import ModelField
    
    def as_form(cls: Type[BaseModel]):
        new_parameters = []
    
        for field_name, model_field in cls.__fields__.items():
            model_field: ModelField  # type: ignore
    
            new_parameters.append(
                 inspect.Parameter(
                     model_field.alias,
                     inspect.Parameter.POSITIONAL_ONLY,
                     default=Form(...) if model_field.required else Form(model_field.default),
                     annotation=model_field.outer_type_,
                 )
             )
    
        async def as_form_func(**data):
            return cls(**data)
    
        sig = inspect.signature(as_form_func)
        sig = sig.replace(parameters=new_parameters)
        as_form_func.__signature__ = sig  # type: ignore
        setattr(cls, 'as_form', as_form_func)
        return cls
    

    And the usage looks like

    @as_form
    class Test(BaseModel):
        param: str
        a: int = 1
        b: str = '2342'
        c: bool = False
        d: Optional[float] = None
    
    
    @router.post('/me', response_model=Test)
    async def me(request: Request, form: Test = Depends(Test.as_form)):
        return form