Search code examples
pythonfastapiopenapiswagger-uipydantic

Problem with FastAPI, Pydantic, and kebab-case header fields


In my FastAPI project, if I create a common header definition with Pydantic, I find that kebab-case header fields aren't behaving as expected. The "magic" conversion from kebab-case header fields in the request to their snake_case counterparts is not working, in addition to inconsistencies in the generated Swagger docs.

What is the right way to specify this Pydantic header class so that the Swagger docs and behavior match?

Here's a minimal reproduction of the problem:

### main.py

from typing import Annotated
from fastapi import FastAPI, Header
from pydantic import BaseModel, Field

app = FastAPI()


class CommonHeaders(BaseModel):
    simpleheader: str
    a_kebab_header: str | None = Field(
        default=None,
        title="a-kebab-header",
        alias="a-kebab-header",
        description="This is a header that should be specified as `a-kebab-header`",
    )


@app.get("/")
def root_endpoint(
    headers: Annotated[CommonHeaders, Header()],
):
    result = {"headers received": headers}
    return result

If I run this and look at the Swagger docs at http://localhost:8000/docs I see this, which looks correct:

Swagger docs showing correctly specified kebab-case header.

And if I "try it out" it will generate what I would expect as the correct request:

curl -X 'GET' \
  'http://localhost:8000/' \
  -H 'accept: application/json' \
  -H 'simpleheader: foo' \
  -H 'a-kebab-header: bar'

But in the response, it becomes clear it did not correctly receive the kebab-case header:

{
  "headers received": {
    "simpleheader": "foo",
    "a-kebab-header": null
  }
}

Changing the header name to snake_case "a_kebab_header" in the request does not work, either.

Updating the header definition to look like this doesn't work as expected, either. The Swagger docs and actual behavior are inconsistent.

class CommonHeaders(BaseModel):
    simpleheader: str
    a_kebab_header: str | None = Field(
        default=None,
        description="This is a header that should be specified as `a-kebab-header`",
    )

Notice this now results in the Swagger docs specifying it in snake_case:

Swagger docs showing incorrectly specified header in snake_case

And using "try it out" results in the snake_case variant:

curl -X 'GET' \
  'http://localhost:8000/' \
  -H 'accept: application/json' \
  -H 'simpleheader: foo' \
  -H 'a_kebab_header: bar'

But SURPRISINGLY this doesn't work! The response:

{
  "headers received": {
    "simpleheader": "foo",
    "a_kebab_header": null
  }
}

But in a SURPRISE ENDING, if I manually re-write the request in kebab-case:

curl -X 'GET' \
  'http://localhost:8000/' \
  -H 'accept: application/json' \
  -H 'simpleheader: foo' \
  -H 'a-kebab-header: bar'

it finally picks up that header value via the magic translation and I get the desired results back:

{"headers received":{"simpleheader":"foo","a_kebab_header":"bar"}}

What is the right way to specify this Pydantic header class so that the Swagger docs and behavior match? If the docs are inconsistent with behavior I'm going to get hassled.


As a final thought: the following way works correctly in both the OpenAPI documentation and in the application (displaying and working as kebab-case), BUT it doesn't use Pydantic and so I lose the ability to define and use a common header structure easily across my project, and instead need to declare them individually for each endpoint:

"""Alternative version without Pydantic."""
from typing import Annotated
from fastapi import FastAPI, Header

app = FastAPI()


@app.get("/")
def root_endpoint(
    simpleheader: Annotated[str, Header()],
    a_kebab_header: Annotated[
        str | None,
        Header(
            title="a-kebab-header",
            description="This is a header that should be specified as `a-kebab-header`",
        ),
    ] = None,
):
    result = {
        "headers received": {
            "simpleheader": simpleheader,
            "a_kebab_header": a_kebab_header,
        }
    }
    return result

Solution

  • The only way I found is to define parameters without using Pydantic model.

    To use this common parameters in different endpoints you can define them using dependency function:

    from typing import Annotated
    from fastapi import Depends, FastAPI, Header
    from pydantic import BaseModel
    
    app = FastAPI()
    
    class CommonHeaders(BaseModel):
        simpleheader: str
        a_kebab_header: str | None
    
    def get_common_headers(
        simpleheader: Annotated[str, Header()],
        a_kebab_header: str | None = Header(
            default=None,
            title="a-kebab-header",
            alias="a-kebab-header",
            description="This is a header that should be specified as `a-kebab-header`",
        ),
    ):
        return CommonHeaders(simpleheader=simpleheader, a_kebab_header=a_kebab_header)
    
    
    @app.get("/")
    def root_endpoint(
        headers: Annotated[CommonHeaders, Depends(get_common_headers)],
    ):
        result = {"headers received": headers}
        return result
    
    
    @app.get("/another")
    def another_endpoint(
        headers: Annotated[CommonHeaders, Depends(get_common_headers)],
    ):
        result = {"headers received": headers}
        return result