Compare commits

...

9 Commits

Author SHA1 Message Date
mvanderlee b08add0945
Merge 5a254e566f into c48539f4c6 2026-02-07 08:38:50 +00:00
github-actions[bot] c48539f4c6 📝 Update release notes
[skip ci]
2026-02-07 08:34:59 +00:00
Sebastián Ramírez 2e7d3754cd
♻️ Refactor and simplify Pydantic v2 (and v1) compatibility internal utils (#14862) 2026-02-07 08:34:32 +00:00
Sebastián Ramírez 8eac94bd91 🔖 Release version 0.128.4 2026-02-07 09:12:54 +01:00
github-actions[bot] 58cdfc7f4b 📝 Update release notes
[skip ci]
2026-02-07 08:08:31 +00:00
Sebastián Ramírez d59fbc3494
♻️ Refactor internals, simplify Pydantic v2/v1 utils, `create_model_field`, better types for `lenient_issubclass` (#14860) 2026-02-07 08:08:07 +00:00
pre-commit-ci-lite[bot] 5a254e566f
🎨 Auto format 2026-01-06 22:07:02 +00:00
pre-commit-ci-lite[bot] a5d28b0bc4 🎨 Auto format 2026-01-06 23:06:07 +01:00
Michiel bec5e359e8 Add support for Sentinel values 2026-01-06 23:06:07 +01:00
11 changed files with 379 additions and 140 deletions

View File

@ -9,6 +9,13 @@ hide:
### Refactors
* ♻️ Refactor and simplify Pydantic v2 (and v1) compatibility internal utils. PR [#14862](https://github.com/fastapi/fastapi/pull/14862) by [@tiangolo](https://github.com/tiangolo).
## 0.128.4
### Refactors
* ♻️ Refactor internals, simplify Pydantic v2/v1 utils, `create_model_field`, better types for `lenient_issubclass`. PR [#14860](https://github.com/fastapi/fastapi/pull/14860) by [@tiangolo](https://github.com/tiangolo).
* ♻️ Simplify internals, remove Pydantic v1 only logic, no longer needed. PR [#14857](https://github.com/fastapi/fastapi/pull/14857) by [@tiangolo](https://github.com/tiangolo).
* ♻️ Refactor internals, cleanup unneeded Pydantic v1 specific logic. PR [#14856](https://github.com/fastapi/fastapi/pull/14856) by [@tiangolo](https://github.com/tiangolo).

View File

@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.128.3"
__version__ = "0.128.4"
from starlette import status as status

View File

@ -1,7 +1,14 @@
from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE
from .shared import annotation_is_pydantic_v1 as annotation_is_pydantic_v1
from .shared import field_annotation_is_scalar as field_annotation_is_scalar
from .shared import is_pydantic_v1_model_class as is_pydantic_v1_model_class
from .shared import (
field_annotation_is_scalar_sequence as field_annotation_is_scalar_sequence,
)
from .shared import field_annotation_is_sequence as field_annotation_is_sequence
from .shared import (
is_bytes_or_nonable_bytes_annotation as is_bytes_or_nonable_bytes_annotation,
)
from .shared import is_bytes_sequence_annotation as is_bytes_sequence_annotation
from .shared import is_pydantic_v1_model_instance as is_pydantic_v1_model_instance
from .shared import (
is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation,
@ -12,27 +19,21 @@ from .shared import (
from .shared import lenient_issubclass as lenient_issubclass
from .shared import sequence_types as sequence_types
from .shared import value_is_sequence as value_is_sequence
from .v2 import BaseConfig as BaseConfig
from .v2 import ModelField as ModelField
from .v2 import PydanticSchemaGenerationError as PydanticSchemaGenerationError
from .v2 import RequiredParam as RequiredParam
from .v2 import Undefined as Undefined
from .v2 import UndefinedType as UndefinedType
from .v2 import Url as Url
from .v2 import Validator as Validator
from .v2 import _regenerate_error_with_loc as _regenerate_error_with_loc
from .v2 import copy_field_info as copy_field_info
from .v2 import create_body_model as create_body_model
from .v2 import evaluate_forwardref as evaluate_forwardref
from .v2 import get_cached_model_fields as get_cached_model_fields
from .v2 import get_definitions as get_definitions
from .v2 import get_flat_models_from_fields as get_flat_models_from_fields
from .v2 import get_missing_field_error as get_missing_field_error
from .v2 import get_model_name_map as get_model_name_map
from .v2 import get_schema_from_model_field as get_schema_from_model_field
from .v2 import is_bytes_field as is_bytes_field
from .v2 import is_bytes_sequence_field as is_bytes_sequence_field
from .v2 import is_scalar_field as is_scalar_field
from .v2 import is_scalar_sequence_field as is_scalar_sequence_field
from .v2 import is_sequence_field as is_sequence_field
from .v2 import serialize_sequence_value as serialize_sequence_value
from .v2 import (
with_info_plain_validator_function as with_info_plain_validator_function,

View File

@ -8,6 +8,7 @@ from dataclasses import is_dataclass
from typing import (
Annotated,
Any,
TypeVar,
Union,
)
@ -15,7 +16,9 @@ from fastapi.types import UnionType
from pydantic import BaseModel
from pydantic.version import VERSION as PYDANTIC_VERSION
from starlette.datastructures import UploadFile
from typing_extensions import get_args, get_origin
from typing_extensions import TypeGuard, get_args, get_origin
_T = TypeVar("_T")
# Copy from Pydantic: pydantic/_internal/_typing_extra.py
if sys.version_info < (3, 10):
@ -39,15 +42,13 @@ sequence_annotation_to_type = {
deque: deque,
}
sequence_types = tuple(sequence_annotation_to_type.keys())
Url: type[Any]
sequence_types: tuple[type[Any], ...] = tuple(sequence_annotation_to_type.keys())
# Copy of Pydantic: pydantic/_internal/_utils.py
# Copy of Pydantic: pydantic/_internal/_utils.py with added TypeGuard
def lenient_issubclass(
cls: Any, class_or_tuple: Union[type[Any], tuple[type[Any], ...], None]
) -> bool:
cls: Any, class_or_tuple: Union[type[_T], tuple[type[_T], ...], None]
) -> TypeGuard[type[_T]]:
try:
return isinstance(cls, type) and issubclass(cls, class_or_tuple) # type: ignore[arg-type]
except TypeError: # pragma: no cover
@ -177,16 +178,26 @@ def is_uploadfile_sequence_annotation(annotation: Any) -> bool:
def is_pydantic_v1_model_instance(obj: Any) -> bool:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
from pydantic import v1
# TODO: remove this function once the required version of Pydantic fully
# removes pydantic.v1
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
from pydantic import v1
except ImportError: # pragma: no cover
return False
return isinstance(obj, v1.BaseModel)
def is_pydantic_v1_model_class(cls: Any) -> bool:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
from pydantic import v1
# TODO: remove this function once the required version of Pydantic fully
# removes pydantic.v1
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
from pydantic import v1
except ImportError: # pragma: no cover
return False
return lenient_issubclass(cls, v1.BaseModel)

View File

@ -12,7 +12,7 @@ from typing import (
cast,
)
from fastapi._compat import shared
from fastapi._compat import lenient_issubclass, shared
from fastapi.openapi.constants import REF_TEMPLATE
from fastapi.types import IncEx, ModelNameMap, UnionType
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
@ -23,29 +23,20 @@ from pydantic._internal._schema_generation_shared import ( # type: ignore[attr-
GetJsonSchemaHandler as GetJsonSchemaHandler,
)
from pydantic._internal._typing_extra import eval_type_lenient
from pydantic._internal._utils import lenient_issubclass as lenient_issubclass
from pydantic.fields import FieldInfo as FieldInfo
from pydantic.json_schema import GenerateJsonSchema as GenerateJsonSchema
from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue
from pydantic_core import CoreSchema as CoreSchema
from pydantic_core import PydanticUndefined, PydanticUndefinedType
from pydantic_core import PydanticUndefined
from pydantic_core import Url as Url
from pydantic_core.core_schema import (
with_info_plain_validator_function as with_info_plain_validator_function,
)
from typing_extensions import Literal, get_args, get_origin
try:
from pydantic_core.core_schema import (
with_info_plain_validator_function as with_info_plain_validator_function,
)
except ImportError: # pragma: no cover
from pydantic_core.core_schema import (
general_plain_validator_function as with_info_plain_validator_function, # noqa: F401
)
RequiredParam = PydanticUndefined
Undefined = PydanticUndefined
UndefinedType = PydanticUndefinedType
evaluate_forwardref = eval_type_lenient
Validator = Any
# TODO: remove when dropping support for Pydantic < v2.12.3
_Attrs = {
@ -87,14 +78,6 @@ def asdict(field_info: FieldInfo) -> dict[str, Any]:
}
class BaseConfig:
pass
class ErrorWrapper(Exception):
pass
@dataclass
class ModelField:
field_info: FieldInfo
@ -119,18 +102,10 @@ class ModelField:
sa = self.field_info.serialization_alias
return sa or None
@property
def required(self) -> bool:
return self.field_info.is_required()
@property
def default(self) -> Any:
return self.get_default()
@property
def type_(self) -> Any:
return self.field_info.annotation
def __post_init__(self) -> None:
with warnings.catch_warnings():
# Pydantic >= 2.12.0 warns about field specific metadata that is unused
@ -143,8 +118,8 @@ class ModelField:
warnings.simplefilter(
"ignore", category=UnsupportedFieldAttributeWarning
)
# TODO: remove after dropping support for Python 3.8 and
# setting the min Pydantic to v2.12.3 that adds asdict()
# TODO: remove after setting the min Pydantic to v2.12.3
# that adds asdict(), and use self.field_info.asdict() instead
field_dict = asdict(self.field_info)
annotated_args = (
field_dict["annotation"],
@ -284,9 +259,9 @@ def get_definitions(
for model in flat_serialization_models
]
flat_model_fields = flat_validation_model_fields + flat_serialization_model_fields
input_types = {f.type_ for f in fields}
input_types = {f.field_info.annotation for f in fields}
unique_flat_model_fields = {
f for f in flat_model_fields if f.type_ not in input_types
f for f in flat_model_fields if f.field_info.annotation not in input_types
}
inputs = [
(
@ -321,22 +296,6 @@ def is_scalar_field(field: ModelField) -> bool:
) and not isinstance(field.field_info, params.Body)
def is_sequence_field(field: ModelField) -> bool:
return shared.field_annotation_is_sequence(field.field_info.annotation)
def is_scalar_sequence_field(field: ModelField) -> bool:
return shared.field_annotation_is_scalar_sequence(field.field_info.annotation)
def is_bytes_field(field: ModelField) -> bool:
return shared.is_bytes_or_nonable_bytes_annotation(field.type_)
def is_bytes_sequence_field(field: ModelField) -> bool:
return shared.is_bytes_sequence_annotation(field.type_)
def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo:
cls = type(field_info)
merged_field_info = cls.from_annotation(annotation)
@ -432,10 +391,11 @@ def get_flat_models_from_annotation(
origin = get_origin(annotation)
if origin is not None:
for arg in get_args(annotation):
if lenient_issubclass(arg, (BaseModel, Enum)) and arg not in known_models:
known_models.add(arg)
if lenient_issubclass(arg, BaseModel):
get_flat_models_from_model(arg, known_models=known_models)
if lenient_issubclass(arg, (BaseModel, Enum)):
if arg not in known_models:
known_models.add(arg) # type: ignore[arg-type]
if lenient_issubclass(arg, BaseModel):
get_flat_models_from_model(arg, known_models=known_models)
else:
get_flat_models_from_annotation(arg, known_models=known_models)
return known_models
@ -444,7 +404,7 @@ def get_flat_models_from_annotation(
def get_flat_models_from_field(
field: ModelField, known_models: TypeModelSet
) -> TypeModelSet:
field_type = field.type_
field_type = field.field_info.annotation
if lenient_issubclass(field_type, BaseModel):
if field_type in known_models:
return known_models

View File

@ -25,13 +25,13 @@ from fastapi._compat import (
create_body_model,
evaluate_forwardref,
field_annotation_is_scalar,
field_annotation_is_scalar_sequence,
field_annotation_is_sequence,
get_cached_model_fields,
get_missing_field_error,
is_bytes_field,
is_bytes_sequence_field,
is_bytes_or_nonable_bytes_annotation,
is_bytes_sequence_annotation,
is_scalar_field,
is_scalar_sequence_field,
is_sequence_field,
is_uploadfile_or_nonable_uploadfile_annotation,
is_uploadfile_sequence_annotation,
lenient_issubclass,
@ -67,6 +67,15 @@ from starlette.websockets import WebSocket
from typing_extensions import Literal, get_args, get_origin
from typing_inspection.typing_objects import is_typealiastype
try:
# This was added in 4.14.0 (June 2, 2025) We want to be able to support older versions
from typing_extensions import Sentinel
except ImportError:
class Sentinel:
pass
multipart_not_installed_error = (
'Form data requires "python-multipart" to be installed. \n'
'You can install "python-multipart" with: \n\n'
@ -182,8 +191,10 @@ def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
if not fields:
return fields
first_field = fields[0]
if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel):
fields_to_extract = get_cached_model_fields(first_field.type_)
if len(fields) == 1 and lenient_issubclass(
first_field.field_info.annotation, BaseModel
):
fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
return fields_to_extract
return fields
@ -512,7 +523,6 @@ def analyze_param(
type_=use_annotation_from_field_info,
default=field_info.default,
alias=alias,
required=field_info.default in (RequiredParam, Undefined),
field_info=field_info,
)
if is_path_param:
@ -522,12 +532,8 @@ def analyze_param(
elif isinstance(field_info, params.Query):
assert (
is_scalar_field(field)
or is_scalar_sequence_field(field)
or (
lenient_issubclass(field.type_, BaseModel)
# For Pydantic v1
and getattr(field, "shape", 1) == 1
)
or field_annotation_is_scalar_sequence(field.field_info.annotation)
or lenient_issubclass(field.field_info.annotation, BaseModel)
), f"Query parameter {param_name!r} must be one of the supported types"
return ParamDetails(type_annotation=type_annotation, depends=depends, field=field)
@ -713,7 +719,7 @@ def _validate_value_with_model_field(
*, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...]
) -> tuple[Any, list[Any]]:
if value is None:
if field.required:
if field.field_info.is_required():
return None, [get_missing_field_error(loc=loc)]
else:
return deepcopy(field.default), []
@ -730,7 +736,7 @@ def _get_multidict_value(
alias = alias or get_validation_alias(field)
if (
(not _is_json_field(field))
and is_sequence_field(field)
and field_annotation_is_sequence(field.field_info.annotation)
and isinstance(values, (ImmutableMultiDict, Headers))
):
value = values.getlist(alias)
@ -743,12 +749,19 @@ def _get_multidict_value(
and isinstance(value, str) # For type checks
and value == ""
)
or (is_sequence_field(field) and len(value) == 0)
or (
field_annotation_is_sequence(field.field_info.annotation)
and len(value) == 0
)
):
if field.required:
if field.field_info.is_required():
return
else:
return deepcopy(field.default)
return (
field.default
if isinstance(field.default, Sentinel)
else deepcopy(field.default)
)
return value
@ -766,8 +779,10 @@ def request_params_to_args(
fields_to_extract = fields
single_not_embedded_field = False
default_convert_underscores = True
if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel):
fields_to_extract = get_cached_model_fields(first_field.type_)
if len(fields) == 1 and lenient_issubclass(
first_field.field_info.annotation, BaseModel
):
fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
single_not_embedded_field = True
# If headers are in a Pydantic model, the way to disable convert_underscores
# would be with Header(convert_underscores=False) at the Pydantic model level
@ -871,8 +886,8 @@ def _should_embed_body_fields(fields: list[ModelField]) -> bool:
# otherwise it has to be embedded, so that the key value pair can be extracted
if (
isinstance(first_field.field_info, params.Form)
and not lenient_issubclass(first_field.type_, BaseModel)
and not is_union_of_base_models(first_field.type_)
and not lenient_issubclass(first_field.field_info.annotation, BaseModel)
and not is_union_of_base_models(first_field.field_info.annotation)
):
return True
return False
@ -889,12 +904,12 @@ async def _extract_form_body(
field_info = field.field_info
if (
isinstance(field_info, params.File)
and is_bytes_field(field)
and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation)
and isinstance(value, UploadFile)
):
value = await value.read()
elif (
is_bytes_sequence_field(field)
is_bytes_sequence_annotation(field.field_info.annotation)
and isinstance(field_info, params.File)
and value_is_sequence(value)
):
@ -941,10 +956,10 @@ async def request_body_to_args(
if (
single_not_embedded_field
and lenient_issubclass(first_field.type_, BaseModel)
and lenient_issubclass(first_field.field_info.annotation, BaseModel)
and isinstance(received_body, FormData)
):
fields_to_extract = get_cached_model_fields(first_field.type_)
fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
if isinstance(received_body, FormData):
body_to_process = await _extract_form_body(fields_to_extract, received_body)
@ -997,7 +1012,9 @@ def get_body_field(
BodyModel = create_body_model(
fields=flat_dependant.body_params, model_name=model_name
)
required = any(True for f in flat_dependant.body_params if f.required)
required = any(
True for f in flat_dependant.body_params if f.field_info.is_required()
)
BodyFieldInfo_kwargs: dict[str, Any] = {
"annotation": BodyModel,
"alias": "body",
@ -1021,7 +1038,6 @@ def get_body_field(
final_field = create_model_field(
name="body",
type_=BodyModel,
required=required,
alias="body",
field_info=BodyFieldInfo(**BodyFieldInfo_kwargs),
)

View File

@ -143,10 +143,7 @@ class Schema(BaseModelWithConfig):
else_: Optional["SchemaOrBool"] = Field(default=None, alias="else")
dependentSchemas: Optional[dict[str, "SchemaOrBool"]] = None
prefixItems: Optional[list["SchemaOrBool"]] = None
# TODO: uncomment and remove below when deprecating Pydantic v1
# It generates a list of schemas for tuples, before prefixItems was available
# items: Optional["SchemaOrBool"] = None
items: Optional[Union["SchemaOrBool", list["SchemaOrBool"]]] = None
items: Optional["SchemaOrBool"] = None
contains: Optional["SchemaOrBool"] = None
properties: Optional[dict[str, "SchemaOrBool"]] = None
patternProperties: Optional[dict[str, "SchemaOrBool"]] = None

View File

@ -10,12 +10,10 @@ from fastapi._compat import (
ModelField,
Undefined,
get_definitions,
get_schema_from_model_field,
lenient_issubclass,
)
from fastapi._compat.v2 import (
get_flat_models_from_fields,
get_model_name_map,
get_schema_from_model_field,
lenient_issubclass,
)
from fastapi.datastructures import DefaultPlaceholder
from fastapi.dependencies.models import Dependant
@ -131,7 +129,7 @@ def _get_openapi_operation_parameters(
default_convert_underscores = True
if len(flat_dependant.header_params) == 1:
first_field = flat_dependant.header_params[0]
if lenient_issubclass(first_field.type_, BaseModel):
if lenient_issubclass(first_field.field_info.annotation, BaseModel):
default_convert_underscores = getattr(
first_field.field_info, "convert_underscores", True
)
@ -163,7 +161,7 @@ def _get_openapi_operation_parameters(
parameter = {
"name": name,
"in": param_type.value,
"required": param.required,
"required": param.field_info.is_required(),
"schema": param_schema,
}
if field_info.description:
@ -200,7 +198,7 @@ def get_openapi_operation_request_body(
)
field_info = cast(Body, body_field.field_info)
request_media_type = field_info.media_type
required = body_field.required
required = body_field.field_info.is_required()
request_body_oai: dict[str, Any] = {}
if required:
request_body_oai["required"] = required

View File

@ -34,7 +34,6 @@ from fastapi import params
from fastapi._compat import (
ModelField,
Undefined,
annotation_is_pydantic_v1,
lenient_issubclass,
)
from fastapi.datastructures import Default, DefaultPlaceholder
@ -52,7 +51,6 @@ from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import (
EndpointContext,
FastAPIError,
PydanticV1NotSupportedError,
RequestValidationError,
ResponseValidationError,
WebSocketRequestValidationError,
@ -638,11 +636,6 @@ class APIRoute(routing.Route):
f"Status code {status_code} must not have a response body"
)
response_name = "Response_" + self.unique_id
if annotation_is_pydantic_v1(self.response_model):
raise PydanticV1NotSupportedError(
"pydantic.v1 models are no longer supported by FastAPI."
f" Please update the response model {self.response_model!r}."
)
self.response_field = create_model_field(
name=response_name,
type_=self.response_model,
@ -664,11 +657,6 @@ class APIRoute(routing.Route):
f"Status code {additional_status_code} must not have a response body"
)
response_name = f"Response_{additional_status_code}_{self.unique_id}"
if annotation_is_pydantic_v1(model):
raise PydanticV1NotSupportedError(
"pydantic.v1 models are no longer supported by FastAPI."
f" In responses={{}}, please update {model}."
)
response_field = create_model_field(
name=response_name, type_=model, mode="serialization"
)

View File

@ -9,12 +9,9 @@ from typing import (
import fastapi
from fastapi._compat import (
BaseConfig,
ModelField,
PydanticSchemaGenerationError,
Undefined,
UndefinedType,
Validator,
annotation_is_pydantic_v1,
)
from fastapi.datastructures import DefaultPlaceholder, DefaultType
@ -63,26 +60,19 @@ _invalid_args_message = (
def create_model_field(
name: str,
type_: Any,
class_validators: Optional[dict[str, Validator]] = None,
default: Optional[Any] = Undefined,
required: Union[bool, UndefinedType] = Undefined,
model_config: Union[type[BaseConfig], None] = None,
field_info: Optional[FieldInfo] = None,
alias: Optional[str] = None,
mode: Literal["validation", "serialization"] = "validation",
version: Literal["1", "auto"] = "auto",
) -> ModelField:
if annotation_is_pydantic_v1(type_):
raise PydanticV1NotSupportedError(
"pydantic.v1 models are no longer supported by FastAPI."
f" Please update the response model {type_!r}."
)
class_validators = class_validators or {}
field_info = field_info or FieldInfo(annotation=type_, default=default, alias=alias)
kwargs = {"mode": mode, "name": name, "field_info": field_info}
try:
return v2.ModelField(**kwargs) # type: ignore[arg-type]
return v2.ModelField(mode=mode, name=name, field_info=field_info)
except PydanticSchemaGenerationError:
raise fastapi.exceptions.FastAPIError(
_invalid_args_message.format(type_=type_)

271
tests/test_sentinel.py Normal file
View File

@ -0,0 +1,271 @@
from typing import Union
import pydantic_core
import pytest
from fastapi import Body, Cookie, FastAPI, Header, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel
try:
# We support older pydantic versions, so do a safe import
from pydantic_core import MISSING
except ImportError:
class MISSING:
pass
def create_app():
app = FastAPI()
class Item(BaseModel):
data: Union[str, MISSING, None] = MISSING # pyright: ignore[reportInvalidTypeForm] - requires pyright option: enableExperimentalFeatures = true
# see https://docs.pydantic.dev/latest/concepts/experimental/#missing-sentinel
@app.post("/sentinel/")
def sentinel(
item: Item = Body(),
):
return item
@app.get("/query_sentinel/")
def query_sentinel(
data: Union[str, MISSING, None] = Query(default=MISSING), # pyright: ignore[reportInvalidTypeForm]
):
return data
@app.get("/header_sentinel/")
def header_sentinel(
data: Union[str, MISSING, None] = Header(default=MISSING), # pyright: ignore[reportInvalidTypeForm]
):
return data
@app.get("/cookie_sentinel/")
def cookie_sentinel(
data: Union[str, MISSING, None] = Cookie(default=MISSING), # pyright: ignore[reportInvalidTypeForm]
):
return data
return app
@pytest.mark.skipif(
pydantic_core.__version__ < "2.37.0",
reason="This pydantic_core version doesn't support MISSING",
)
def test_call_api():
app = create_app()
client = TestClient(app)
response = client.post("/sentinel/", json={})
assert response.status_code == 200, response.text
response = client.post("/sentinel/", json={"data": "Foo"})
assert response.status_code == 200, response.text
response = client.get("/query_sentinel/")
assert response.status_code == 200, response.text
response = client.get("/query_sentinel/", params={"data": "Foo"})
assert response.status_code == 200, response.text
response = client.get("/header_sentinel/")
assert response.status_code == 200, response.text
response = client.get("/header_sentinel/", headers={"data": "Foo"})
assert response.status_code == 200, response.text
response = client.get("/cookie_sentinel/")
assert response.status_code == 200, response.text
client.cookies = {"data": "Foo"}
response = client.get("/cookie_sentinel/")
assert response.status_code == 200, response.text
@pytest.mark.skipif(
pydantic_core.__version__ < "2.37.0",
reason="This pydantic_core version doesn't support MISSING",
)
def test_openapi_schema():
"""
Test that example overrides work:
* pydantic model schema_extra is included
* Body(example={}) overrides schema_extra in pydantic model
* Body(examples{}) overrides Body(example={}) and schema_extra in pydantic model
"""
app = create_app()
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
assert response.json() == {
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/sentinel/": {
"post": {
"summary": "Sentinel",
"operationId": "sentinel_sentinel__post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Item",
}
}
},
"required": True,
},
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/query_sentinel/": {
"get": {
"summary": "Query Sentinel",
"operationId": "query_sentinel_query_sentinel__get",
"parameters": [
{
"required": False,
"schema": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"title": "Data",
},
"name": "data",
"in": "query",
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/header_sentinel/": {
"get": {
"summary": "Header Sentinel",
"operationId": "header_sentinel_header_sentinel__get",
"parameters": [
{
"required": False,
"schema": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"title": "Data",
},
"name": "data",
"in": "header",
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/cookie_sentinel/": {
"get": {
"summary": "Cookie Sentinel",
"operationId": "cookie_sentinel_cookie_sentinel__get",
"parameters": [
{
"required": False,
"schema": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"title": "Data",
},
"name": "data",
"in": "cookie",
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
},
"components": {
"schemas": {
"HTTPValidationError": {
"title": "HTTPValidationError",
"type": "object",
"properties": {
"detail": {
"title": "Detail",
"type": "array",
"items": {"$ref": "#/components/schemas/ValidationError"},
}
},
},
"Item": {
"title": "Item",
"type": "object",
"properties": {
"data": {
"title": "Data",
"anyOf": [{"type": "string"}, {"type": "null"}],
}
},
},
"ValidationError": {
"title": "ValidationError",
"required": ["loc", "msg", "type"],
"type": "object",
"properties": {
"loc": {
"title": "Location",
"type": "array",
"items": {
"anyOf": [{"type": "string"}, {"type": "integer"}]
},
},
"msg": {"title": "Message", "type": "string"},
"type": {"title": "Error Type", "type": "string"},
},
},
}
},
}