I have an API that looks something like this:
from fastapi import File, Formfrom pydantic import BaseModel
class CreateMyObject(BaseModel): name: str file: File
@app.post("/my_object")async def create_my_object(data: Annotated[CreateMyObject, Form()]) -> MyObject: ...
FastAPI has this cool concept of Form Models
that allows you to treat a FormData
input like a pydantic BaseModel
. So, the API caller would send a multipart/form-data
object.
formData = new FormData();formData.append("name", "my_name");formData.append("file", myFile, "my_filename.png");
On the backend, I can treat it as though it’s a JSON payload that FastAPI deserialized into a pydantic model for me. Nifty feature!
I recently had to extend this API to accept an arbitrary JSON payload:
from fastapi import File, Formfrom pydantic import BaseModel
class CreateMyObject(BaseModel): name: str file: File my_complicated_field: ComplicatedModel
@app.post("/my_object")async def create_my_object(data: Annotated[CreateMyObject, Form()]) -> MyObject: ...
Here, ComplicatedModel
is a heavily nested Pydantic object. This introduces an interesting problem: FormData
only accepts primitive types—such as strings, blobs, or files—and, as you might imagine, ComplicatedModel
is not primitive.
My naive approach was, client-side, to JSON.stringify()
the object on my frontend, converting it to a string, to then pass to backend:
formData = new FormData();formData.append("name", "my_name");formData.append("file", myFile, "my_filename.png");formData.append("my_complicated_field", JSON.stringify(myComplicatedObject));
Unfortunately, this causes a 422 Validation Error, as FastAPI expects my_complicated_field
to be a nested object, and not a string. While we could annotate my_complicated_field
as a str
and manually perform Pydantic validation inside the model, I generally prefer to rely on API contracts. I really want to be able to statically tell my caller (as enforced via OpenAPI) that “I’m expecting this sort of payload, don’t waste your time with something else, thanks”!
Instead, I discovered you can use a field validator to achieve the desired solution.
from fastapi import File, Formfrom pydantic import BaseModel, BeforeValidatorfrom pydantic_core import from_json
class CreateMyObject(BaseModel): name: str file: File my_complicated_field: Annotated[ ComplicatedModel, BeforeValidator(lambda val: from_json(val)) ]
@app.post("/my_object")async def create_my_object(data: Annotated[CreateMyObject, Form()]) -> MyObject: ...
This solution is declarative with the BeforeValidator
construct, requiring zero changes to any other code. I also learned that pydantic_core
exports a from_json
, which acts as a faster drop-in replacement for json.loads
. I’ll take it!