pythonrequestfastapipydantic

Parsing Pydantic dict params


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.

FastAPI query params

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?


Solution

  • It is indeed a complex subjects for which I see mainly 2 paths forward.

    Parse your dictionary from the query

    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"
        }
      }
    ]
    

    swagger rendering: swagger rendering

    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: swagger rendering of openapi

    Explicit all of the filters

    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