Skip to content

🐛 Fix get_model_fields to support Annotated types#14805

Open
ilovemesomeramen wants to merge 4 commits intofastapi:masterfrom
ilovemesomeramen:master
Open

🐛 Fix get_model_fields to support Annotated types#14805
ilovemesomeramen wants to merge 4 commits intofastapi:masterfrom
ilovemesomeramen:master

Conversation

@ilovemesomeramen
Copy link

#14482 added the model_config to the ModelField to support arbitrary types, however the if statement did not check for all the correct types.

This PR brings the check inline with the internal pydantic TypeAdapter check which would throw an error in some cases, e.g. Union fields with single Values.

@codspeed-hq
Copy link

codspeed-hq bot commented Feb 2, 2026

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing ilovemesomeramen:master (cbc0c32) with master (8fd2914)1

Open in CodSpeed

Footnotes

  1. No successful run was found on master (e94028a) during the generation of this report, so 8fd2914 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@YuriiMotov
Copy link
Member

@ilovemesomeramen, thank you for your interest and efforts!
Can you come up with a more realistic code example? Ideally something that we can not do in a simpler way

@ilovemesomeramen
Copy link
Author

@YuriiMotov What do you mean exactly?

The provided example is exactly the issue which this PR solved, this example worked in 0.124.0 but does not in 0.124.1

A little bit of context why this is needed:
I often design my APIs in such a way that it easy to extend the models in the future.
So if i know a specific model will be discriminated in the future i want the OpenAPI spec to reflect that immediately.

the provided test case is based on this test: tests/test_arbitrary_types.py

The Problem is the Union with a single value in it this worked before and no longer works since the type is not properly extracted.

@github-actions github-actions bot added the conflicts Automatically generated when a PR has a merge conflict label Feb 7, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 7, 2026

This pull request has a merge conflict that needs to be resolved.

@github-actions github-actions bot removed the conflicts Automatically generated when a PR has a merge conflict label Feb 8, 2026
@YuriiMotov YuriiMotov added the bug Something isn't working label Feb 9, 2026
@YuriiMotov YuriiMotov changed the title Fix bug introduced in #14482 which breaks openapi generation in special cases 🐛 Fix get_model_fields to support Annotated types Feb 9, 2026
Copy link
Member

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

We can implement the same (*almost) logic without RootModel (with MyModel: TypeAlias = Annotated[Union[Base], Field(discriminator="type")]), but openapi schema looks different - it doesn't create dedicated schema in components, but just embeds schema into response object:

Details
from typing import Annotated, Literal, TypeAlias, Union

from fastapi import FastAPI
from pydantic import BaseModel, Field


class Base(BaseModel):
    type: Literal["BASE"] = "BASE"
    value: str


MyModel: TypeAlias = Annotated[Union[Base], Field(discriminator="type")]


app = FastAPI()


@app.get("/")
def test() -> MyModel:
    pass
{
  "openapi": "3.1.0",
  "info": {
    "title": "FastAPI",
    "version": "0.1.0"
  },
  "paths": {
    "/": {
      "get": {
        "summary": "Test",
        "operationId": "test__get",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/Base"
                    }
                  ],
                  "title": "Response Test  Get",
                  "discriminator": {
                    "propertyName": "type",
                    "mapping": {
                      "BASE": "#/components/schemas/Base"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Base": {
        "properties": {
          "type": {
            "type": "string",
            "const": "BASE",
            "title": "Type",
            "default": "BASE"
          },
          "value": {
            "type": "string",
            "title": "Value"
          }
        },
        "type": "object",
        "required": [
          "value"
        ],
        "title": "Base"
      }
    }
  }
}

Serialization logic works the same, only schema is different.

I attempted to come up with code example without RootModel that causes the same issue, but failed - seems that regular BaseModel unwraps type annotations for its fields.

So, even though I don't really like the test, I still think we should merge this fix - it looks logically to also check for Annotated[BaseModel, ..]

@ilovemesomeramen, thanks for clarifications and for working on this!

@ilovemesomeramen
Copy link
Author

@YuriiMotov Yes exactly, i know the test looks a little wonky but it makes sense in my opinion.

I think it is logical that the plain annotated type does not create a new component but the root model creates one, as defining a RootModel implicates a new "Model" (component) while the plain Type Annotation does not (in my opinion).

So this should be fine.

Regarding the additional test, would something like this work for you?

Details
from typing import Annotated, Literal, Union

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, Field, RootModel


class Base(BaseModel):
    type: Literal["BASE"] = "BASE"
    value: str


class MyModelRoot(RootModel[Annotated[Union[Base], Field(discriminator="type")]]):
    pass


class MyModelBase(RootModel[Annotated[Base, Field(discriminator="type")]]):
    pass


app = FastAPI()


@app.get("/root")
def get_root() -> MyModelRoot:
    return MyModelRoot.model_validate(Base(value="test"))


@app.get("/base")
def get_base() -> MyModelBase:
    return MyModelBase.model_validate(Base(value="test"))


client = TestClient(app)


@pytest.mark.parametrize("path", ["/root", "/base"])
def test_get(path: str):
    response = client.get(path)
    assert response.json() == {"value": "test", "type": "BASE"}


def test_openapi_schema():
    response = client.get("openapi.json")
    assert response.json() == snapshot(
        {
            "openapi": "3.1.0",
            "info": {"title": "FastAPI", "version": "0.1.0"},
            "paths": {
                "/root": {
                    "get": {
                        "summary": "Get Root",
                        "operationId": "get_root_root_get",
                        "responses": {
                            "200": {
                                "description": "Successful Response",
                                "content": {
                                    "application/json": {
                                        "schema": {
                                            "$ref": "#/components/schemas/MyModelRoot"
                                        }
                                    }
                                },
                            }
                        },
                    }
                },
                "/base": {
                    "get": {
                        "summary": "Get Base",
                        "operationId": "get_base_base_get",
                        "responses": {
                            "200": {
                                "description": "Successful Response",
                                "content": {
                                    "application/json": {
                                        "schema": {
                                            "$ref": "#/components/schemas/MyModelBase"
                                        }
                                    }
                                },
                            }
                        },
                    }
                },
            },
            "components": {
                "schemas": {
                    "Base": {
                        "properties": {
                            "type": {
                                "type": "string",
                                "const": "BASE",
                                "title": "Type",
                                "default": "BASE",
                            },
                            "value": {"type": "string", "title": "Value"},
                        },
                        "type": "object",
                        "required": ["value"],
                        "title": "Base",
                    },
                    "MyModelBase": {
                        "oneOf": [{"$ref": "#/components/schemas/Base"}],
                        "title": "MyModelBase",
                        "discriminator": {
                            "propertyName": "type",
                            "mapping": {"BASE": "#/components/schemas/Base"},
                        },
                    },
                    "MyModelRoot": {
                        "oneOf": [{"$ref": "#/components/schemas/Base"}],
                        "title": "MyModelRoot",
                        "discriminator": {
                            "propertyName": "type",
                            "mapping": {"BASE": "#/components/schemas/Base"},
                        },
                    },
                }
            },
        }
    

GetJsonSchemaHandler as GetJsonSchemaHandler,
)
from pydantic._internal._typing_extra import eval_type_lenient
from pydantic._internal._typing_extra import annotated_type, eval_type_lenient
Copy link
Contributor

@Viicos Viicos Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great if FastAPI did not rely on more Pydantic internals than it does currently. eval_type_lenient is a clear example, I had to "soft-deprecate" it (i.e. only wrap it around @typing_extensions.deprecated without raising any runtime warning) solely because FastAPI was using it. annotated_type might be deleted at any point (and in fact has a good chance of being removed at some point).

This can easily be implemented directly in FastAPI.

Copy link
Author

@ilovemesomeramen ilovemesomeramen Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Viicos Good point, but in general this is a weird set of circumstances in the first place.
Since this entire code

        type_ = annotated_type(field_info.annotation) or field_info.annotation
        if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_):
            model_config = None
        else:
            model_config = model.model_config

is just a duplication of this pydantic internal function anyway

def _type_has_config(type_: Any) -> bool:
    """Returns whether the type has config."""
    type_ = _typing_extra.annotated_type(type_) or type_
    try:
        return issubclass(type_, BaseModel) or is_dataclass(type_) or is_typeddict(type_)
    except TypeError:
        # type is not a class
        return False

since otherwise the pydantic TypeAdapter raises an error. So i guess we could just copy this function to fastapi but then when Pydantic decides to remove the function and in the process maybe change the check in the TypeAdapter the two checks are out of sync again just waiting for a new bug to be introduced.

i think depending directly on the internal pydantic function will make it clear that this code needs to be looked at again if the pydantic function is removed and ported to the new pydantic way of handling it.

@YuriiMotov
Copy link
Member

Regarding the additional test, would something like this work for you?

As far as I can see the difference is only Union. So, I don't think it changes something.. Let's keep test as it is and only address the review comment of @Viicos

@Viicos
Copy link
Contributor

Viicos commented Feb 10, 2026

I'll also note that FieldInfo.annotation is supposed to contain the type, unwrapped from Annotated if necessary. The issue encountered here is a Pydantic bug that is fixed in pydantic/pydantic#12463. Might be worth adding a version check (e.g. pydantic_version < 2.13, yet to be released).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants