I have an endpoint that takes a Pydantic model, Foo
, as a query parameter.
from typing import Annotated
import uvicorn
from fastapi import FastAPI, Query
from pydantic import BaseModel
app = FastAPI()
class Foo(BaseModel):
bar: str
baz: dict[str, str]
@app.get("/")
def root(foo: Annotated[Foo, Query()]):
return foo
if __name__ == "__main__":
uvicorn.run("test:app")
I'm defining my query params using Swagger, so the encoding should be correct. I know the baz
param syntax looks redundant because I've nested a dictionary, but parsing fails even without nesting.
But when I call the endpoint...
curl -X 'GET' \
'http://127.0.0.1:8000/?bar=sda&baz=%7B%22abc%22%3A%22def%22%7D' \
-H 'accept: application/json'
FastAPI does not seem to read in Foo.baz
correctly, returning
{
"detail": [
{
"type": "dict_type",
"loc": [
"query",
"baz"
],
"msg": "Input should be a valid dictionary",
"input": "{\"abc\":\"def\"}"
}
]
}
I've read similar questions and I know I can ingest the dictionary by accessing dict(request.query_params)
, but this bypasses FastAPI's validation and I'd prefer to keep the endpoint simple and consistent with the rest of my codebase by keeping the param as a Pydantic model.
How can I get FastAPI to parse Foo
as a query param?
It is indeed a complex subjects for which I see mainly 2 paths forward.
It is actually possible to get the dictionary back from the query parameters:
from pydantic import BaseModel, Json
class Foo(BaseModel):
bar: str
baz: Json
When baz
will receive the Json string, it will be parsed into a dictionary. This method changes the openapi schema, so swagger will render baz
as a file input.
openapi schema:
"parameters": [
{"name": "bar",...},
{
"name": "baz",
"in": "query",
"required": true,
"schema": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {},
"title": "Baz"
}
}
]
If swagger is important to you, you can use your custom Json loader type that will render as a textarea:
from typing import Annotated
import json
from pydantic import BaseModel, Json, BeforeValidator
CustomJsonValidator = Annotated[dict[str, str], BeforeValidator(json.loads)]
class Foo(BaseModel):
bar: str
baz: CustomJsonValidator
The associated openapi schema will be:
"parameters": [
{"name": "bar", ...},
{
"name": "baz",
"in": "query",
"required": true,
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "Baz"
}
}
]
and swagger will render like the following:
If possible, you can explicit all of your filters in a pydantic schema (a bit like flattening out baz
dictionary). It can avoid potential issue since it's way harder to validate a dictionary than fields. Of course, you will have to use None
by defaults a bit everywhere, but you can always exclude them from your model dumping.
from typing import Literal
from pydantic import BaseModel
class Foo(BaseModel):
bar: str
abc: Literal["def"] | None = None
ghi: str | None = None
jkl: int | None = None
If you have filters that can't be used together, you can use pydantic unions for that. Let's say "abc" and "ghi" filters must always coexist, but can't be defined along with "jkl". You would have:
from typing import Literal
from pydantic import BaseModel
class GoodLuckNamingThat(BaseModel):
bar: str
abc: Literal["def"]
ghi: str
class AnotherNamingNightmare(BaseModel):
bar: str
jkl: int
@app.get("/")
def root(foo: Annotated[GoodLuckNamingThat | AnotherNamingNightmare, Query()]):
return foo