diff --git a/README.md b/README.md
index 26a6c32ae..a42cedae6 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ The key features are:
* **Robust**: Get production-ready code. With automatic interactive documentation.
* **Standards-based**: Based on (and fully compatible with) the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema.
-* estimation based on tests on an internal development team, building production applications.
+* estimation based on tests conducted by an internal development team, building production applications.
## Sponsors
diff --git a/docs/en/docs/advanced/behind-a-proxy.md b/docs/en/docs/advanced/behind-a-proxy.md
index f692a28e8..f4dbd4560 100644
--- a/docs/en/docs/advanced/behind-a-proxy.md
+++ b/docs/en/docs/advanced/behind-a-proxy.md
@@ -443,6 +443,14 @@ The docs UI will interact with the server that you select.
///
+/// note | Technical Details
+
+The `servers` property in the OpenAPI specification is optional.
+
+If you don't specify the `servers` parameter and `root_path` is equal to `/`, the `servers` property in the generated OpenAPI schema will be omitted entirely by default, which is the equivalent of a single server with a `url` value of `/`.
+
+///
+
### Disable automatic server from `root_path` { #disable-automatic-server-from-root-path }
If you don't want **FastAPI** to include an automatic server using the `root_path`, you can use the parameter `root_path_in_servers=False`:
diff --git a/docs/en/docs/index.md b/docs/en/docs/index.md
index 8a79b26a6..df03b7675 100644
--- a/docs/en/docs/index.md
+++ b/docs/en/docs/index.md
@@ -46,7 +46,7 @@ The key features are:
* **Robust**: Get production-ready code. With automatic interactive documentation.
* **Standards-based**: Based on (and fully compatible with) the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema.
-* estimation based on tests on an internal development team, building production applications.
+* estimation based on tests conducted by an internal development team, building production applications.
## Sponsors { #sponsors }
diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md
index ea509a760..74c7eb9ea 100644
--- a/docs/en/docs/release-notes.md
+++ b/docs/en/docs/release-notes.md
@@ -7,8 +7,48 @@ hide:
## Latest Changes
+## 0.123.4
+
+### Fixes
+
+* 🐛 Fix OpenAPI schema support for computed fields when using `separate_input_output_schemas=False`. PR [#13207](https://github.com/fastapi/fastapi/pull/13207) by [@vgrafe](https://github.com/vgrafe).
+
+### Docs
+
+* 📝 Fix docstring of `servers` parameter. PR [#14405](https://github.com/fastapi/fastapi/pull/14405) by [@YuriiMotov](https://github.com/YuriiMotov).
+
+## 0.123.3
+
+### Fixes
+
+* 🐛 Fix Query\Header\Cookie parameter model alias. PR [#14360](https://github.com/fastapi/fastapi/pull/14360) by [@YuriiMotov](https://github.com/YuriiMotov).
+* 🐛 Fix optional sequence handling in `serialize sequence value` with Pydantic V2. PR [#14297](https://github.com/fastapi/fastapi/pull/14297) by [@YuriiMotov](https://github.com/YuriiMotov).
+
+## 0.123.2
+
+### Fixes
+
+* 🐛 Fix unformatted `{type_}` in FastAPIError. PR [#14416](https://github.com/fastapi/fastapi/pull/14416) by [@Just-Helpful](https://github.com/Just-Helpful).
+* 🐛 Fix parsing extra non-body parameter list. PR [#14356](https://github.com/fastapi/fastapi/pull/14356) by [@YuriiMotov](https://github.com/YuriiMotov).
+* 🐛 Fix parsing extra `Form` parameter list. PR [#14303](https://github.com/fastapi/fastapi/pull/14303) by [@YuriiMotov](https://github.com/YuriiMotov).
+* 🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms. PR [#13537](https://github.com/fastapi/fastapi/pull/13537) by [@MarinPostma](https://github.com/MarinPostma).
+
+### Docs
+
+* 📝 Add tip on how to install `pip` in case of `No module named pip` error in `virtual-environments.md`. PR [#14211](https://github.com/fastapi/fastapi/pull/14211) by [@zadevhub](https://github.com/zadevhub).
+* 📝 Update Primary Key notes for the SQL databases tutorial to avoid confusion. PR [#14120](https://github.com/fastapi/fastapi/pull/14120) by [@FlaviusRaducu](https://github.com/FlaviusRaducu).
+* 📝 Clarify estimation note in documentation. PR [#14070](https://github.com/fastapi/fastapi/pull/14070) by [@SaisakthiM](https://github.com/SaisakthiM).
+
+## 0.123.1
+
+### Fixes
+
+* 🐛 Avoid accessing non-existing "$ref" key for Pydantic v2 compat remapping. PR [#14361](https://github.com/fastapi/fastapi/pull/14361) by [@svlandeg](https://github.com/svlandeg).
+* 🐛 Fix `TypeError` when encoding a decimal with a `NaN` or `Infinity` value. PR [#12935](https://github.com/fastapi/fastapi/pull/12935) by [@kentwelcome](https://github.com/kentwelcome).
+
### Internal
+* 🐛 Fix Windows UnicodeEncodeError in CLI test. PR [#14295](https://github.com/fastapi/fastapi/pull/14295) by [@hemanth-thirthahalli](https://github.com/hemanth-thirthahalli).
* 🔧 Update sponsors: add Greptile. PR [#14429](https://github.com/fastapi/fastapi/pull/14429) by [@tiangolo](https://github.com/tiangolo).
* 👥 Update FastAPI GitHub topic repositories. PR [#14426](https://github.com/fastapi/fastapi/pull/14426) by [@tiangolo](https://github.com/tiangolo).
* ⬆ Bump markdown-include-variants from 0.0.6 to 0.0.7. PR [#14423](https://github.com/fastapi/fastapi/pull/14423) by [@YuriiMotov](https://github.com/YuriiMotov).
diff --git a/docs/en/docs/tutorial/sql-databases.md b/docs/en/docs/tutorial/sql-databases.md
index cfa1c9073..b42e9ba2f 100644
--- a/docs/en/docs/tutorial/sql-databases.md
+++ b/docs/en/docs/tutorial/sql-databases.md
@@ -65,7 +65,7 @@ There are a few differences:
* `Field(primary_key=True)` tells SQLModel that the `id` is the **primary key** in the SQL database (you can learn more about SQL primary keys in the SQLModel docs).
- By having the type as `int | None`, SQLModel will know that this column should be an `INTEGER` in the SQL database and that it should be `NULLABLE`.
+ **Note:** We use `int | None` for the primary key field so that in Python code we can *create an object without an `id`* (`id=None`), assuming the database will *generate it when saving*. SQLModel understands that the database will provide the `id` and *defines the column as a non-null `INTEGER`* in the database schema. See SQLModel docs on primary keys for details.
* `Field(index=True)` tells SQLModel that it should create a **SQL index** for this column, that would allow faster lookups in the database when reading data filtered by this column.
diff --git a/docs/en/docs/virtual-environments.md b/docs/en/docs/virtual-environments.md
index e9b0a9fc4..c02e43ab9 100644
--- a/docs/en/docs/virtual-environments.md
+++ b/docs/en/docs/virtual-environments.md
@@ -242,6 +242,26 @@ $ python -m pip install --upgrade pip
+/// tip
+
+Sometimes, you might get a **`No module named pip`** error when trying to upgrade pip.
+
+If this happens, install and upgrade pip using the command below:
+
+
+
+```console
+$ python -m ensurepip --upgrade
+
+---> 100%
+```
+
+
+
+This command will install pip if it is not already installed and also ensures that the installed version of pip is at least as recent as the one available in `ensurepip`.
+
+///
+
## Add `.gitignore` { #add-gitignore }
If you are using **Git** (you should), add a `.gitignore` file to exclude everything in your `.venv` from Git.
diff --git a/fastapi/__init__.py b/fastapi/__init__.py
index 25ed2bbeb..b1d2dcecc 100644
--- a/fastapi/__init__.py
+++ b/fastapi/__init__.py
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
-__version__ = "0.123.0"
+__version__ = "0.123.4"
from starlette import status as status
diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py
index 5cd49343b..543a42dda 100644
--- a/fastapi/_compat/v2.py
+++ b/fastapi/_compat/v2.py
@@ -180,8 +180,13 @@ def get_schema_from_model_field(
],
separate_input_output_schemas: bool = True,
) -> Dict[str, Any]:
+ computed_fields = field._type_adapter.core_schema.get("schema", {}).get(
+ "computed_fields", []
+ )
override_mode: Union[Literal["validation"], None] = (
- None if separate_input_output_schemas else "validation"
+ None
+ if (separate_input_output_schemas or len(computed_fields) > 0)
+ else "validation"
)
# This expects that GenerateJsonSchema was already used to generate the definitions
json_schema = field_mapping[(field, override_mode or field.mode)]
@@ -203,9 +208,14 @@ def get_definitions(
Dict[Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue],
Dict[str, Dict[str, Any]],
]:
+ has_computed_fields: bool = any(
+ field._type_adapter.core_schema.get("schema", {}).get("computed_fields", [])
+ for field in fields
+ )
+
schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE)
override_mode: Union[Literal["validation"], None] = (
- None if separate_input_output_schemas else "validation"
+ None if (separate_input_output_schemas or has_computed_fields) else "validation"
)
validation_fields = [field for field in fields if field.mode == "validation"]
serialization_fields = [field for field in fields if field.mode == "serialization"]
@@ -304,7 +314,7 @@ def _remap_definitions_and_field_mappings(
old_name_to_new_name_map = {}
for field_key, schema in field_mapping.items():
model = field_key[0].type_
- if model not in model_name_map:
+ if model not in model_name_map or "$ref" not in schema:
continue
new_name = model_name_map[model]
old_name = schema["$ref"].split("/")[-1]
@@ -371,6 +381,13 @@ def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo:
def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]:
origin_type = get_origin(field.field_info.annotation) or field.field_info.annotation
+ if origin_type is Union: # Handle optional sequences
+ union_args = get_args(field.field_info.annotation)
+ for union_arg in union_args:
+ if union_arg is type(None):
+ continue
+ origin_type = get_origin(union_arg) or union_arg
+ break
assert issubclass(origin_type, shared.sequence_types) # type: ignore[arg-type]
return shared.sequence_annotation_to_type[origin_type](value) # type: ignore[no-any-return]
diff --git a/fastapi/applications.py b/fastapi/applications.py
index 0a47699ae..02193312b 100644
--- a/fastapi/applications.py
+++ b/fastapi/applications.py
@@ -301,7 +301,12 @@ class FastAPI(Starlette):
browser tabs open). Or if you want to leave fixed the possible URLs.
If the servers `list` is not provided, or is an empty `list`, the
- default value would be a `dict` with a `url` value of `/`.
+ `servers` property in the generated OpenAPI will be:
+
+ * a `dict` with a `url` value of the application's mounting point
+ (`root_path`) if it's different from `/`.
+ * otherwise, the `servers` property will be omitted from the OpenAPI
+ schema.
Each item in the `list` is a `dict` containing:
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index d43fa8a51..ef3f56417 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -787,13 +787,19 @@ def request_params_to_args(
)
value = _get_multidict_value(field, received_params, alias=alias)
if value is not None:
- params_to_process[field.name] = value
+ params_to_process[field.alias] = value
processed_keys.add(alias or field.alias)
- processed_keys.add(field.name)
- for key, value in received_params.items():
+ for key in received_params.keys():
if key not in processed_keys:
- params_to_process[key] = value
+ if hasattr(received_params, "getlist"):
+ value = received_params.getlist(key)
+ if isinstance(value, list) and (len(value) == 1):
+ params_to_process[key] = value[0]
+ else:
+ params_to_process[key] = value
+ else:
+ params_to_process[key] = received_params.get(key)
if single_not_embedded_field:
field_info = first_field.field_info
@@ -902,9 +908,14 @@ async def _extract_form_body(
value = serialize_sequence_value(field=field, value=results)
if value is not None:
values[field.alias] = value
- for key, value in received_body.items():
- if key not in values:
- values[key] = value
+ field_aliases = {field.alias for field in body_fields}
+ for key in received_body.keys():
+ if key not in field_aliases:
+ param_values = received_body.getlist(key)
+ if len(param_values) == 1:
+ values[key] = param_values[0]
+ else:
+ values[key] = param_values
return values
diff --git a/fastapi/encoders.py b/fastapi/encoders.py
index 6fc6228e1..793951089 100644
--- a/fastapi/encoders.py
+++ b/fastapi/encoders.py
@@ -34,14 +34,14 @@ def isoformat(o: Union[datetime.date, datetime.time]) -> str:
return o.isoformat()
-# Taken from Pydantic v1 as is
+# Adapted from Pydantic v1
# TODO: pv2 should this return strings instead?
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
"""
- Encodes a Decimal as int of there's no exponent, otherwise float
+ Encodes a Decimal as int if there's no exponent, otherwise float
This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
- where a integer (but not int typed) is used. Encoding this as a float
+ where an integer (but not int typed) is used. Encoding this as a float
results in failed round-tripping between encode and parse.
Our Id type is a prime example of this.
@@ -50,8 +50,12 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
>>> decimal_encoder(Decimal("1"))
1
+
+ >>> decimal_encoder(Decimal("NaN"))
+ nan
"""
- if dec_value.as_tuple().exponent >= 0: # type: ignore[operator]
+ exponent = dec_value.as_tuple().exponent
+ if isinstance(exponent, int) and exponent >= 0:
return int(dec_value)
else:
return float(dec_value)
diff --git a/fastapi/utils.py b/fastapi/utils.py
index 2e79ee6b1..b3b89ed2b 100644
--- a/fastapi/utils.py
+++ b/fastapi/utils.py
@@ -110,7 +110,9 @@ def create_model_field(
try:
return v1.ModelField(**v1_kwargs) # type: ignore[no-any-return]
except RuntimeError:
- raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None
+ raise fastapi.exceptions.FastAPIError(
+ _invalid_args_message.format(type_=type_)
+ ) from None
elif PYDANTIC_V2:
from ._compat import v2
@@ -121,7 +123,9 @@ def create_model_field(
try:
return v2.ModelField(**kwargs) # type: ignore[return-value,arg-type]
except PydanticSchemaGenerationError:
- raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None
+ raise fastapi.exceptions.FastAPIError(
+ _invalid_args_message.format(type_=type_)
+ ) from None
# Pydantic v2 is not installed, but it's not a Pydantic v1 ModelField, it could be
# a Pydantic v1 type, like a constrained int
from fastapi._compat import v1
@@ -129,7 +133,9 @@ def create_model_field(
try:
return v1.ModelField(**v1_kwargs) # type: ignore[no-any-return]
except RuntimeError:
- raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None
+ raise fastapi.exceptions.FastAPIError(
+ _invalid_args_message.format(type_=type_)
+ ) from None
def create_cloned_field(
diff --git a/tests/test_compat.py b/tests/test_compat.py
index 0184c9a2e..c3a97209a 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -136,6 +136,30 @@ def test_is_uploadfile_sequence_annotation():
assert is_uploadfile_sequence_annotation(Union[List[str], List[UploadFile]])
+@needs_pydanticv2
+def test_serialize_sequence_value_with_optional_list():
+ """Test that serialize_sequence_value handles optional lists correctly."""
+ from fastapi._compat import v2
+
+ field_info = FieldInfo(annotation=Union[List[str], None])
+ field = v2.ModelField(name="items", field_info=field_info)
+ result = v2.serialize_sequence_value(field=field, value=["a", "b", "c"])
+ assert result == ["a", "b", "c"]
+ assert isinstance(result, list)
+
+
+@needs_pydanticv2
+def test_serialize_sequence_value_with_none_first_in_union():
+ """Test that serialize_sequence_value handles Union[None, List[...]] correctly."""
+ from fastapi._compat import v2
+
+ field_info = FieldInfo(annotation=Union[None, List[str]])
+ field = v2.ModelField(name="items", field_info=field_info)
+ result = v2.serialize_sequence_value(field=field, value=["x", "y"])
+ assert result == ["x", "y"]
+ assert isinstance(result, list)
+
+
@needs_py_lt_314
def test_is_pv1_scalar_field():
from fastapi._compat import v1
diff --git a/tests/test_computed_fields.py b/tests/test_computed_fields.py
index a1b412168..f2e42999b 100644
--- a/tests/test_computed_fields.py
+++ b/tests/test_computed_fields.py
@@ -6,8 +6,9 @@ from .utils import needs_pydanticv2
@pytest.fixture(name="client")
-def get_client():
- app = FastAPI()
+def get_client(request):
+ separate_input_output_schemas = request.param
+ app = FastAPI(separate_input_output_schemas=separate_input_output_schemas)
from pydantic import BaseModel, computed_field
@@ -32,6 +33,7 @@ def get_client():
return client
+@pytest.mark.parametrize("client", [True, False], indirect=True)
@pytest.mark.parametrize("path", ["/", "/responses"])
@needs_pydanticv2
def test_get(client: TestClient, path: str):
@@ -40,6 +42,7 @@ def test_get(client: TestClient, path: str):
assert response.json() == {"width": 3, "length": 4, "area": 12}
+@pytest.mark.parametrize("client", [True, False], indirect=True)
@needs_pydanticv2
def test_openapi_schema(client: TestClient):
response = client.get("/openapi.json")
diff --git a/tests/test_fastapi_cli.py b/tests/test_fastapi_cli.py
index a5c10778a..78a49a1fb 100644
--- a/tests/test_fastapi_cli.py
+++ b/tests/test_fastapi_cli.py
@@ -1,3 +1,4 @@
+import os
import subprocess
import sys
from unittest.mock import patch
@@ -20,6 +21,7 @@ def test_fastapi_cli():
],
capture_output=True,
encoding="utf-8",
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
)
assert result.returncode == 1, result.stdout
assert "Path does not exist non_existent_file.py" in result.stdout
diff --git a/tests/test_form_default.py b/tests/test_form_default.py
new file mode 100644
index 000000000..2a12049d1
--- /dev/null
+++ b/tests/test_form_default.py
@@ -0,0 +1,35 @@
+from typing import Optional
+
+from fastapi import FastAPI, File, Form
+from starlette.testclient import TestClient
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+@app.post("/urlencoded")
+async def post_url_encoded(age: Annotated[Optional[int], Form()] = None):
+ return age
+
+
+@app.post("/multipart")
+async def post_multi_part(
+ age: Annotated[Optional[int], Form()] = None,
+ file: Annotated[Optional[bytes], File()] = None,
+):
+ return {"file": file, "age": age}
+
+
+client = TestClient(app)
+
+
+def test_form_default_url_encoded():
+ response = client.post("/urlencoded", data={"age": ""})
+ assert response.status_code == 200
+ assert response.text == "null"
+
+
+def test_form_default_multi_part():
+ response = client.post("/multipart", data={"age": ""})
+ assert response.status_code == 200
+ assert response.json() == {"file": None, "age": None}
diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py
index 880ab3820..1db63f021 100644
--- a/tests/test_forms_single_model.py
+++ b/tests/test_forms_single_model.py
@@ -2,6 +2,7 @@ from typing import List, Optional
from dirty_equals import IsDict
from fastapi import FastAPI, Form
+from fastapi._compat import PYDANTIC_V2
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from typing_extensions import Annotated
@@ -17,11 +18,27 @@ class FormModel(BaseModel):
alias_with: str = Field(alias="with", default="nothing")
+class FormModelExtraAllow(BaseModel):
+ param: str
+
+ if PYDANTIC_V2:
+ model_config = {"extra": "allow"}
+ else:
+
+ class Config:
+ extra = "allow"
+
+
@app.post("/form/")
def post_form(user: Annotated[FormModel, Form()]):
return user
+@app.post("/form-extra-allow/")
+def post_form_extra_allow(params: Annotated[FormModelExtraAllow, Form()]):
+ return params
+
+
client = TestClient(app)
@@ -131,3 +148,33 @@ def test_no_data():
]
}
)
+
+
+def test_extra_param_single():
+ response = client.post(
+ "/form-extra-allow/",
+ data={
+ "param": "123",
+ "extra_param": "456",
+ },
+ )
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "param": "123",
+ "extra_param": "456",
+ }
+
+
+def test_extra_param_list():
+ response = client.post(
+ "/form-extra-allow/",
+ data={
+ "param": "123",
+ "extra_params": ["456", "789"],
+ },
+ )
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "param": "123",
+ "extra_params": ["456", "789"],
+ }
diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py
index 447c5b4d6..3b6513e27 100644
--- a/tests/test_jsonable_encoder.py
+++ b/tests/test_jsonable_encoder.py
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
+from math import isinf, isnan
from pathlib import PurePath, PurePosixPath, PureWindowsPath
from typing import Optional
@@ -306,6 +307,20 @@ def test_decimal_encoder_int():
assert jsonable_encoder(data) == {"value": 2}
+@needs_pydanticv2
+def test_decimal_encoder_nan():
+ data = {"value": Decimal("NaN")}
+ assert isnan(jsonable_encoder(data)["value"])
+
+
+@needs_pydanticv2
+def test_decimal_encoder_infinity():
+ data = {"value": Decimal("Infinity")}
+ assert isinf(jsonable_encoder(data)["value"])
+ data = {"value": Decimal("-Infinity")}
+ assert isinf(jsonable_encoder(data)["value"])
+
+
def test_encode_deque_encodes_child_models():
class Model(BaseModel):
test: str
diff --git a/tests/test_optional_file_list.py b/tests/test_optional_file_list.py
new file mode 100644
index 000000000..0228900cf
--- /dev/null
+++ b/tests/test_optional_file_list.py
@@ -0,0 +1,30 @@
+from typing import List, Optional
+
+from fastapi import FastAPI, File
+from fastapi.testclient import TestClient
+
+app = FastAPI()
+
+
+@app.post("/files")
+async def upload_files(files: Optional[List[bytes]] = File(None)):
+ if files is None:
+ return {"files_count": 0}
+ return {"files_count": len(files), "sizes": [len(f) for f in files]}
+
+
+def test_optional_bytes_list():
+ client = TestClient(app)
+ response = client.post(
+ "/files",
+ files=[("files", b"content1"), ("files", b"content2")],
+ )
+ assert response.status_code == 200
+ assert response.json() == {"files_count": 2, "sizes": [8, 8]}
+
+
+def test_optional_bytes_list_no_files():
+ client = TestClient(app)
+ response = client.post("/files")
+ assert response.status_code == 200
+ assert response.json() == {"files_count": 0}
diff --git a/tests/test_query_cookie_header_model_extra_params.py b/tests/test_query_cookie_header_model_extra_params.py
new file mode 100644
index 000000000..f4ebefb3f
--- /dev/null
+++ b/tests/test_query_cookie_header_model_extra_params.py
@@ -0,0 +1,111 @@
+from fastapi import Cookie, FastAPI, Header, Query
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class Model(BaseModel):
+ param: str
+
+ if PYDANTIC_V2:
+ model_config = {"extra": "allow"}
+ else:
+
+ class Config:
+ extra = "allow"
+
+
+@app.get("/query")
+async def query_model_with_extra(data: Model = Query()):
+ return data
+
+
+@app.get("/header")
+async def header_model_with_extra(data: Model = Header()):
+ return data
+
+
+@app.get("/cookie")
+async def cookies_model_with_extra(data: Model = Cookie()):
+ return data
+
+
+def test_query_pass_extra_list():
+ client = TestClient(app)
+ resp = client.get(
+ "/query",
+ params={
+ "param": "123",
+ "param2": ["456", "789"], # Pass a list of values as extra parameter
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json() == {
+ "param": "123",
+ "param2": ["456", "789"],
+ }
+
+
+def test_query_pass_extra_single():
+ client = TestClient(app)
+ resp = client.get(
+ "/query",
+ params={
+ "param": "123",
+ "param2": "456",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json() == {
+ "param": "123",
+ "param2": "456",
+ }
+
+
+def test_header_pass_extra_list():
+ client = TestClient(app)
+
+ resp = client.get(
+ "/header",
+ headers=[
+ ("param", "123"),
+ ("param2", "456"), # Pass a list of values as extra parameter
+ ("param2", "789"),
+ ],
+ )
+ assert resp.status_code == 200
+ resp_json = resp.json()
+ assert "param2" in resp_json
+ assert resp_json["param2"] == ["456", "789"]
+
+
+def test_header_pass_extra_single():
+ client = TestClient(app)
+
+ resp = client.get(
+ "/header",
+ headers=[
+ ("param", "123"),
+ ("param2", "456"),
+ ],
+ )
+ assert resp.status_code == 200
+ resp_json = resp.json()
+ assert "param2" in resp_json
+ assert resp_json["param2"] == "456"
+
+
+def test_cookie_pass_extra_list():
+ client = TestClient(app)
+ client.cookies = [
+ ("param", "123"),
+ ("param2", "456"), # Pass a list of values as extra parameter
+ ("param2", "789"),
+ ]
+ resp = client.get("/cookie")
+ assert resp.status_code == 200
+ resp_json = resp.json()
+ assert "param2" in resp_json
+ assert resp_json["param2"] == "789" # Cookies only keep the last value
diff --git a/tests/test_request_param_model_by_alias.py b/tests/test_request_param_model_by_alias.py
new file mode 100644
index 000000000..a6f759f23
--- /dev/null
+++ b/tests/test_request_param_model_by_alias.py
@@ -0,0 +1,76 @@
+from dirty_equals import IsPartialDict
+from fastapi import Cookie, FastAPI, Header, Query
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+
+
+class Model(BaseModel):
+ param: str = Field(alias="param_alias")
+
+
+@app.get("/query")
+async def query_model(data: Model = Query()):
+ return {"param": data.param}
+
+
+@app.get("/header")
+async def header_model(data: Model = Header()):
+ return {"param": data.param}
+
+
+@app.get("/cookie")
+async def cookie_model(data: Model = Cookie()):
+ return {"param": data.param}
+
+
+def test_query_model_with_alias():
+ client = TestClient(app)
+ response = client.get("/query", params={"param_alias": "value"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"param": "value"}
+
+
+def test_header_model_with_alias():
+ client = TestClient(app)
+ response = client.get("/header", headers={"param_alias": "value"})
+ assert response.status_code == 200, response.text
+ assert response.json() == {"param": "value"}
+
+
+def test_cookie_model_with_alias():
+ client = TestClient(app)
+ client.cookies.set("param_alias", "value")
+ response = client.get("/cookie")
+ assert response.status_code == 200, response.text
+ assert response.json() == {"param": "value"}
+
+
+def test_query_model_with_alias_by_name():
+ client = TestClient(app)
+ response = client.get("/query", params={"param": "value"})
+ assert response.status_code == 422, response.text
+ details = response.json()
+ if PYDANTIC_V2:
+ assert details["detail"][0]["input"] == {"param": "value"}
+
+
+def test_header_model_with_alias_by_name():
+ client = TestClient(app)
+ response = client.get("/header", headers={"param": "value"})
+ assert response.status_code == 422, response.text
+ details = response.json()
+ if PYDANTIC_V2:
+ assert details["detail"][0]["input"] == IsPartialDict({"param": "value"})
+
+
+def test_cookie_model_with_alias_by_name():
+ client = TestClient(app)
+ client.cookies.set("param", "value")
+ response = client.get("/cookie")
+ assert response.status_code == 422, response.text
+ details = response.json()
+ if PYDANTIC_V2:
+ assert details["detail"][0]["input"] == {"param": "value"}
diff --git a/tests/test_schema_compat_pydantic_v2.py b/tests/test_schema_compat_pydantic_v2.py
new file mode 100644
index 000000000..39626c0ec
--- /dev/null
+++ b/tests/test_schema_compat_pydantic_v2.py
@@ -0,0 +1,92 @@
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+from pydantic import BaseModel
+
+from tests.utils import needs_py310, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from enum import Enum
+
+ app = FastAPI()
+
+ class PlatformRole(str, Enum):
+ admin = "admin"
+ user = "user"
+
+ class OtherRole(str, Enum): ...
+
+ class User(BaseModel):
+ username: str
+ role: PlatformRole | OtherRole
+
+ @app.get("/users")
+ async def get_user() -> User:
+ return {"username": "alice", "role": "admin"}
+
+ client = TestClient(app)
+ return client
+
+
+@needs_py310
+@needs_pydanticv2
+def test_get(client: TestClient):
+ response = client.get("/users")
+ assert response.json() == {"username": "alice", "role": "admin"}
+
+
+@needs_py310
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient):
+ response = client.get("openapi.json")
+ assert response.json() == snapshot(
+ {
+ "openapi": "3.1.0",
+ "info": {"title": "FastAPI", "version": "0.1.0"},
+ "paths": {
+ "/users": {
+ "get": {
+ "summary": "Get User",
+ "operationId": "get_user_users_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/User"}
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "PlatformRole": {
+ "type": "string",
+ "enum": ["admin", "user"],
+ "title": "PlatformRole",
+ },
+ "User": {
+ "properties": {
+ "username": {"type": "string", "title": "Username"},
+ "role": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/PlatformRole"},
+ {"enum": [], "title": "OtherRole"},
+ ],
+ "title": "Role",
+ },
+ },
+ "type": "object",
+ "required": ["username", "role"],
+ "title": "User",
+ },
+ }
+ },
+ }
+ )
diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial003.py b/tests/test_tutorial/test_header_param_models/test_tutorial003.py
index 60940e1da..554a48d2e 100644
--- a/tests/test_tutorial/test_header_param_models/test_tutorial003.py
+++ b/tests/test_tutorial/test_header_param_models/test_tutorial003.py
@@ -77,7 +77,7 @@ def test_header_param_model_no_underscore(client: TestClient):
"user-agent": "testclient",
"save-data": "true",
"if-modified-since": "yesterday",
- "x-tag": "two",
+ "x-tag": ["one", "two"],
},
}
)