]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✅ Add set of tests for request parameters and alias (#14358)
authorMotov Yurii <109919500+YuriiMotov@users.noreply.github.com>
Thu, 11 Dec 2025 16:15:36 +0000 (17:15 +0100)
committerGitHub <noreply@github.com>
Thu, 11 Dec 2025 16:15:36 +0000 (17:15 +0100)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
39 files changed:
tests/test_request_params/__init__.py [new file with mode: 0644]
tests/test_request_params/test_body/__init__.py [new file with mode: 0644]
tests/test_request_params/test_body/test_list.py [new file with mode: 0644]
tests/test_request_params/test_body/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_body/test_optional_str.py [new file with mode: 0644]
tests/test_request_params/test_body/test_required_str.py [new file with mode: 0644]
tests/test_request_params/test_body/utils.py [new file with mode: 0644]
tests/test_request_params/test_cookie/__init__.py [new file with mode: 0644]
tests/test_request_params/test_cookie/test_list.py [new file with mode: 0644]
tests/test_request_params/test_cookie/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_cookie/test_optional_str.py [new file with mode: 0644]
tests/test_request_params/test_cookie/test_required_str.py [new file with mode: 0644]
tests/test_request_params/test_file/__init__.py [new file with mode: 0644]
tests/test_request_params/test_file/test_list.py [new file with mode: 0644]
tests/test_request_params/test_file/test_optional.py [new file with mode: 0644]
tests/test_request_params/test_file/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_file/test_required.py [new file with mode: 0644]
tests/test_request_params/test_file/utils.py [new file with mode: 0644]
tests/test_request_params/test_form/__init__.py [new file with mode: 0644]
tests/test_request_params/test_form/test_list.py [new file with mode: 0644]
tests/test_request_params/test_form/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_form/test_optional_str.py [new file with mode: 0644]
tests/test_request_params/test_form/test_required_str.py [new file with mode: 0644]
tests/test_request_params/test_form/utils.py [new file with mode: 0644]
tests/test_request_params/test_header/__init__.py [new file with mode: 0644]
tests/test_request_params/test_header/test_list.py [new file with mode: 0644]
tests/test_request_params/test_header/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_header/test_optional_str.py [new file with mode: 0644]
tests/test_request_params/test_header/test_required_str.py [new file with mode: 0644]
tests/test_request_params/test_path/__init__.py [new file with mode: 0644]
tests/test_request_params/test_path/test_list.py [new file with mode: 0644]
tests/test_request_params/test_path/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_path/test_optional_str.py [new file with mode: 0644]
tests/test_request_params/test_path/test_required_str.py [new file with mode: 0644]
tests/test_request_params/test_query/__init__.py [new file with mode: 0644]
tests/test_request_params/test_query/test_list.py [new file with mode: 0644]
tests/test_request_params/test_query/test_optional_list.py [new file with mode: 0644]
tests/test_request_params/test_query/test_optional_str.py [new file with mode: 0644]
tests/test_request_params/test_query/test_required_str.py [new file with mode: 0644]

diff --git a/tests/test_request_params/__init__.py b/tests/test_request_params/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_body/__init__.py b/tests/test_request_params/test_body/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_body/test_list.py b/tests/test_request_params/test_body/test_list.py
new file mode 100644 (file)
index 0000000..884e1d0
--- /dev/null
@@ -0,0 +1,523 @@
+from typing import List, Union
+
+import pytest
+from dirty_equals import IsDict, IsOneOf, IsPartialDict
+from fastapi import Body, FastAPI
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/required-list-str", operation_id="required_list_str")
+async def read_required_list_str(p: Annotated[List[str], Body(embed=True)]):
+    return {"p": p}
+
+
+class BodyModelRequiredListStr(BaseModel):
+    p: List[str]
+
+
+@app.post("/model-required-list-str", operation_id="model_required_list_str")
+def read_model_required_list_str(p: BodyModelRequiredListStr):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": {
+                "items": {"type": "string"},
+                "title": "P",
+                "type": "array",
+            },
+        },
+        "required": ["p"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_missing(path: str, json: Union[dict, None]):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": IsOneOf(["body", "p"], ["body"]),
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        {
+            "detail": [
+                {
+                    "loc": IsOneOf(["body", "p"], ["body"]),
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/required-list-alias", operation_id="required_list_alias")
+async def read_required_list_alias(
+    p: Annotated[List[str], Body(embed=True, alias="p_alias")],
+):
+    return {"p": p}
+
+
+class BodyModelRequiredListAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias")
+
+
+@app.post("/model-required-list-alias", operation_id="model_required_list_alias")
+async def read_model_required_list_alias(p: BodyModelRequiredListAlias):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+        "/model-required-list-alias",
+    ],
+)
+def test_required_list_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": {
+                "items": {"type": "string"},
+                "title": "P Alias",
+                "type": "array",
+            },
+        },
+        "required": ["p_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_missing(path: str, json: Union[dict, None]):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": IsOneOf(["body", "p_alias"], ["body"]),
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": IsOneOf(["body", "p_alias"], ["body"]),
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {"p": ["hello", "world"]}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": ["hello", "world"]})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post(
+    "/required-list-validation-alias", operation_id="required_list_validation_alias"
+)
+def read_required_list_validation_alias(
+    p: Annotated[List[str], Body(embed=True, validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class BodyModelRequiredListValidationAlias(BaseModel):
+    p: List[str] = Field(validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-list-validation-alias",
+    operation_id="model_required_list_validation_alias",
+)
+async def read_model_required_list_validation_alias(
+    p: BodyModelRequiredListValidationAlias,
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {
+                "items": {"type": "string"},
+                "title": "P Val Alias",
+                "type": "array",
+            },
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_missing(path: str, json: Union[dict, None]):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": IsOneOf(  # /required-validation-alias fails here
+                    ["body"], ["body", "p_val_alias"]
+                ),
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 422, (
+        response.text  # /required-list-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, (
+        response.text  # /required-list-validation-alias fails here
+    )
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/required-list-alias-and-validation-alias",
+    operation_id="required_list_alias_and_validation_alias",
+)
+def read_required_list_alias_and_validation_alias(
+    p: Annotated[
+        List[str], Body(embed=True, alias="p_alias", validation_alias="p_val_alias")
+    ],
+):
+    return {"p": p}
+
+
+class BodyModelRequiredListAliasAndValidationAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-list-alias-and-validation-alias",
+    operation_id="model_required_list_alias_and_validation_alias",
+)
+def read_model_required_list_alias_and_validation_alias(
+    p: BodyModelRequiredListAliasAndValidationAlias,
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {
+                "items": {"type": "string"},
+                "title": "P Val Alias",
+                "type": "array",
+            },
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_missing(path: str, json):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": IsOneOf(  # /required-list-alias-and-validation-alias fails here
+                    ["body"], ["body", "p_val_alias"]
+                ),
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [  # /required-list-alias-and-validation-alias fails here
+                    "body",
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": ["hello", "world"]}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": ["hello", "world"]})
+    assert response.status_code == 422, response.text
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p_alias": ["hello", "world"]}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, (
+        response.text  # /required-list-alias-and-validation-alias fails here
+    )
+    assert response.json() == {"p": ["hello", "world"]}
diff --git a/tests/test_request_params/test_body/test_optional_list.py b/tests/test_request_params/test_body/test_optional_list.py
new file mode 100644 (file)
index 0000000..c86398c
--- /dev/null
@@ -0,0 +1,600 @@
+from typing import List, Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import Body, FastAPI
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/optional-list-str", operation_id="optional_list_str")
+async def read_optional_list_str(
+    p: Annotated[Optional[List[str]], Body(embed=True)] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalListStr(BaseModel):
+    p: Optional[List[str]] = None
+
+
+@app.post("/model-optional-list-str", operation_id="model_optional_list_str")
+async def read_model_optional_list_str(p: BodyModelOptionalListStr):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p": {"items": {"type": "string"}, "type": "array", "title": "P"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+def test_optional_list_str_missing():
+    client = TestClient(app)
+    response = client.post("/optional-list-str")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+def test_model_optional_list_str_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-list-str")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/optional-list-alias", operation_id="optional_list_alias")
+async def read_optional_list_alias(
+    p: Annotated[Optional[List[str]], Body(embed=True, alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalListAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, alias="p_alias")
+
+
+@app.post("/model-optional-list-alias", operation_id="model_optional_list_alias")
+async def read_model_optional_list_alias(p: BodyModelOptionalListAlias):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                strict=False,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2",
+            ),
+        ),
+        "/model-optional-list-alias",
+    ],
+)
+def test_optional_list_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_alias": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_alias": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+def test_optional_list_alias_missing():
+    client = TestClient(app)
+    response = client.post("/optional-list-alias")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+def test_model_optional_list_alias_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-list-alias")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post(
+    "/optional-list-validation-alias", operation_id="optional_list_validation_alias"
+)
+def read_optional_list_validation_alias(
+    p: Annotated[
+        Optional[List[str]], Body(embed=True, validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalListValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-optional-list-validation-alias",
+    operation_id="model_optional_list_validation_alias",
+)
+def read_model_optional_list_validation_alias(
+    p: BodyModelOptionalListValidationAlias,
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+def test_optional_list_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/optional-list-validation-alias")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+def test_model_optional_list_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-list-validation-alias")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-validation-alias",
+    ],
+)
+def test_optional_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-list-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-validation-alias",
+    ],
+)
+def test_optional_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, response.text
+    assert response.json() == {  # /optional-list-validation-alias fails here
+        "p": ["hello", "world"]
+    }
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/optional-list-alias-and-validation-alias",
+    operation_id="optional_list_alias_and_validation_alias",
+)
+def read_optional_list_alias_and_validation_alias(
+    p: Annotated[
+        Optional[List[str]],
+        Body(embed=True, alias="p_alias", validation_alias="p_val_alias"),
+    ] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalListAliasAndValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(
+        None, alias="p_alias", validation_alias="p_val_alias"
+    )
+
+
+@app.post(
+    "/model-optional-list-alias-and-validation-alias",
+    operation_id="model_optional_list_alias_and_validation_alias",
+)
+def read_model_optional_list_alias_and_validation_alias(
+    p: BodyModelOptionalListAliasAndValidationAlias,
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+def test_optional_list_alias_and_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/optional-list-alias-and-validation-alias")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+def test_model_optional_list_alias_and_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-list-alias-and-validation-alias")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-list-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "p": [  # /optional-list-alias-and-validation-alias fails here
+            "hello",
+            "world",
+        ]
+    }
diff --git a/tests/test_request_params/test_body/test_optional_str.py b/tests/test_request_params/test_body/test_optional_str.py
new file mode 100644 (file)
index 0000000..43ed367
--- /dev/null
@@ -0,0 +1,569 @@
+from typing import Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import Body, FastAPI
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/optional-str", operation_id="optional_str")
+async def read_optional_str(p: Annotated[Optional[str], Body(embed=True)] = None):
+    return {"p": p}
+
+
+class BodyModelOptionalStr(BaseModel):
+    p: Optional[str] = None
+
+
+@app.post("/model-optional-str", operation_id="model_optional_str")
+async def read_model_optional_str(p: BodyModelOptionalStr):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p": {"type": "string", "title": "P"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+def test_optional_str_missing():
+    client = TestClient(app)
+    response = client.post("/optional-str")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+def test_model_optional_str_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-str")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/optional-alias", operation_id="optional_alias")
+async def read_optional_alias(
+    p: Annotated[Optional[str], Body(embed=True, alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias")
+
+
+@app.post("/model-optional-alias", operation_id="model_optional_alias")
+async def read_model_optional_alias(p: BodyModelOptionalAlias):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                strict=False,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2",
+            ),
+        ),
+        "/model-optional-alias",
+    ],
+)
+def test_optional_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_alias": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_alias": {"type": "string", "title": "P Alias"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+def test_optional_alias_missing():
+    client = TestClient(app)
+    response = client.post("/optional-alias")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+def test_model_optional_alias_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-alias")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_model_optional_alias_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post("/optional-validation-alias", operation_id="optional_validation_alias")
+def read_optional_validation_alias(
+    p: Annotated[
+        Optional[str], Body(embed=True, validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-optional-validation-alias", operation_id="model_optional_validation_alias"
+)
+def read_model_optional_validation_alias(
+    p: BodyModelOptionalValidationAlias,
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {"type": "string", "title": "P Val Alias"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@needs_pydanticv2
+def test_optional_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/optional-validation-alias")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+def test_model_optional_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-validation-alias")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_model_optional_validation_alias_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /optional-validation-alias fails here
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/optional-alias-and-validation-alias",
+    operation_id="optional_alias_and_validation_alias",
+)
+def read_optional_alias_and_validation_alias(
+    p: Annotated[
+        Optional[str], Body(embed=True, alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class BodyModelOptionalAliasAndValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-optional-alias-and-validation-alias",
+    operation_id="model_optional_alias_and_validation_alias",
+)
+def read_model_optional_alias_and_validation_alias(
+    p: BodyModelOptionalAliasAndValidationAlias,
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {"type": "string", "title": "P Val Alias"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@needs_pydanticv2
+def test_optional_alias_and_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/optional-alias-and-validation-alias")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+def test_model_optional_alias_and_validation_alias_missing():
+    client = TestClient(app)
+    response = client.post("/model-optional-alias-and-validation-alias")
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "input": None,
+                    "loc": ["body"],
+                    "msg": "Field required",
+                    "type": "missing",
+                },
+            ],
+        }
+    ) | IsDict(
+        {
+            # TODO: remove when deprecating Pydantic v1
+            "detail": [
+                {
+                    "loc": ["body"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ],
+        }
+    )
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_model_optional_alias_and_validation_alias_missing_empty_dict(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": "hello"  # /optional-alias-and-validation-alias fails here
+    }
diff --git a/tests/test_request_params/test_body/test_required_str.py b/tests/test_request_params/test_body/test_required_str.py
new file mode 100644 (file)
index 0000000..fba3fe1
--- /dev/null
@@ -0,0 +1,514 @@
+from typing import Any, Dict, Union
+
+import pytest
+from dirty_equals import IsDict, IsOneOf
+from fastapi import Body, FastAPI
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/required-str", operation_id="required_str")
+async def read_required_str(p: Annotated[str, Body(embed=True)]):
+    return {"p": p}
+
+
+class BodyModelRequiredStr(BaseModel):
+    p: str
+
+
+@app.post("/model-required-str", operation_id="model_required_str")
+async def read_model_required_str(p: BodyModelRequiredStr):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": {"title": "P", "type": "string"},
+        },
+        "required": ["p"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_missing(path: str, json: Union[Dict[str, Any], None]):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": IsOneOf(["body"], ["body", "p"]),
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": IsOneOf(["body"], ["body", "p"]),
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/required-alias", operation_id="required_alias")
+async def read_required_alias(
+    p: Annotated[str, Body(embed=True, alias="p_alias")],
+):
+    return {"p": p}
+
+
+class BodyModelRequiredAlias(BaseModel):
+    p: str = Field(alias="p_alias")
+
+
+@app.post("/model-required-alias", operation_id="model_required_alias")
+async def read_model_required_alias(p: BodyModelRequiredAlias):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2",
+                strict=False,
+            ),
+        ),
+        "/model-required-alias",
+    ],
+)
+def test_required_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": {"title": "P Alias", "type": "string"},
+        },
+        "required": ["p_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_missing(path: str, json: Union[Dict[str, Any], None]):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": IsOneOf(["body", "p_alias"], ["body"]),
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": IsOneOf(["body", "p_alias"], ["body"]),
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {"p": "hello"}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": IsOneOf(["body", "p_alias"], ["body"]),
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": "hello"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post("/required-validation-alias", operation_id="required_validation_alias")
+def read_required_validation_alias(
+    p: Annotated[str, Body(embed=True, validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class BodyModelRequiredValidationAlias(BaseModel):
+    p: str = Field(validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-validation-alias", operation_id="model_required_validation_alias"
+)
+def read_model_required_validation_alias(
+    p: BodyModelRequiredValidationAlias,
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/required-validation-alias", "/model-required-validation-alias"],
+)
+def test_required_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {"title": "P Val Alias", "type": "string"},
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_missing(
+    path: str, json: Union[Dict[str, Any], None]
+):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": IsOneOf(  # /required-validation-alias fails here
+                    ["body", "p_val_alias"], ["body"]
+                ),
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 422, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": "hello"})
+    assert response.status_code == 200, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/required-alias-and-validation-alias",
+    operation_id="required_alias_and_validation_alias",
+)
+def read_required_alias_and_validation_alias(
+    p: Annotated[
+        str, Body(embed=True, alias="p_alias", validation_alias="p_val_alias")
+    ],
+):
+    return {"p": p}
+
+
+class BodyModelRequiredAliasAndValidationAlias(BaseModel):
+    p: str = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-alias-and-validation-alias",
+    operation_id="model_required_alias_and_validation_alias",
+)
+def read_model_required_alias_and_validation_alias(
+    p: BodyModelRequiredAliasAndValidationAlias,
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {"title": "P Val Alias", "type": "string"},
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize("json", [None, {}])
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_missing(
+    path: str, json: Union[Dict[str, Any], None]
+):
+    client = TestClient(app)
+    response = client.post(path, json=json)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": IsOneOf(  # /required-alias-and-validation-alias fails here
+                    ["body"], ["body", "p_val_alias"]
+                ),
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p": "hello"})
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_alias": "hello"})
+    assert response.status_code == 422, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p_alias": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, json={"p_val_alias": "hello"})
+    assert response.status_code == 200, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {"p": "hello"}
diff --git a/tests/test_request_params/test_body/utils.py b/tests/test_request_params/test_body/utils.py
new file mode 100644 (file)
index 0000000..5151a82
--- /dev/null
@@ -0,0 +1,7 @@
+from typing import Any, Dict
+
+
+def get_body_model_name(openapi: Dict[str, Any], path: str) -> str:
+    body = openapi["paths"][path]["post"]["requestBody"]
+    body_schema = body["content"]["application/json"]["schema"]
+    return body_schema.get("$ref", "").split("/")[-1]
diff --git a/tests/test_request_params/test_cookie/__init__.py b/tests/test_request_params/test_cookie/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_cookie/test_list.py b/tests/test_request_params/test_cookie/test_list.py
new file mode 100644 (file)
index 0000000..4ae80e0
--- /dev/null
@@ -0,0 +1,3 @@
+# Currently, there is no way to pass multiple cookies with the same name.
+# The only way to pass multiple values for cookie params is to serialize them using
+# a comma as a delimiter, but this is not currently supported by Starlette.
diff --git a/tests/test_request_params/test_cookie/test_optional_list.py b/tests/test_request_params/test_cookie/test_optional_list.py
new file mode 100644 (file)
index 0000000..4ae80e0
--- /dev/null
@@ -0,0 +1,3 @@
+# Currently, there is no way to pass multiple cookies with the same name.
+# The only way to pass multiple values for cookie params is to serialize them using
+# a comma as a delimiter, but this is not currently supported by Starlette.
diff --git a/tests/test_request_params/test_cookie/test_optional_str.py b/tests/test_request_params/test_cookie/test_optional_str.py
new file mode 100644 (file)
index 0000000..7298baa
--- /dev/null
@@ -0,0 +1,383 @@
+from typing import Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import Cookie, FastAPI
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/optional-str")
+async def read_optional_str(p: Annotated[Optional[str], Cookie()] = None):
+    return {"p": p}
+
+
+class CookieModelOptionalStr(BaseModel):
+    p: Optional[str] = None
+
+
+@app.get("/model-optional-str")
+async def read_model_optional_str(p: Annotated[CookieModelOptionalStr, Cookie()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P",
+                },
+                "name": "p",
+                "in": "cookie",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"title": "P", "type": "string"},
+                "name": "p",
+                "in": "cookie",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/optional-alias")
+async def read_optional_alias(
+    p: Annotated[Optional[str], Cookie(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class CookieModelOptionalAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias")
+
+
+@app.get("/model-optional-alias")
+async def read_model_optional_alias(p: Annotated[CookieModelOptionalAlias, Cookie()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "cookie",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"title": "P Alias", "type": "string"},
+                "name": "p_alias",
+                "in": "cookie",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_name(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias",
+        pytest.param(
+            "/model-optional-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_alias_by_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /model-optional-alias fails here
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/optional-validation-alias")
+def read_optional_validation_alias(
+    p: Annotated[Optional[str], Cookie(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class CookieModelOptionalValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-validation-alias")
+def read_model_optional_validation_alias(
+    p: Annotated[CookieModelOptionalValidationAlias, Cookie()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "cookie",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_val_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /optional-validation-alias fails here
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/optional-alias-and-validation-alias")
+def read_optional_alias_and_validation_alias(
+    p: Annotated[
+        Optional[str], Cookie(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class CookieModelOptionalAliasAndValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-alias-and-validation-alias")
+def read_model_optional_alias_and_validation_alias(
+    p: Annotated[CookieModelOptionalAliasAndValidationAlias, Cookie()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "cookie",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_val_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": "hello"  # /optional-alias-and-validation-alias fails here
+    }
diff --git a/tests/test_request_params/test_cookie/test_required_str.py b/tests/test_request_params/test_cookie/test_required_str.py
new file mode 100644 (file)
index 0000000..9c1442c
--- /dev/null
@@ -0,0 +1,503 @@
+import pytest
+from dirty_equals import IsDict, IsOneOf
+from fastapi import Cookie, FastAPI
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/required-str")
+async def read_required_str(p: Annotated[str, Cookie()]):
+    return {"p": p}
+
+
+class CookieModelRequiredStr(BaseModel):
+    p: str
+
+
+@app.get("/model-required-str")
+async def read_model_required_str(p: Annotated[CookieModelRequiredStr, Cookie()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P", "type": "string"},
+            "name": "p",
+            "in": "cookie",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["cookie", "p"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["cookie", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/required-alias")
+async def read_required_alias(p: Annotated[str, Cookie(alias="p_alias")]):
+    return {"p": p}
+
+
+class CookieModelRequiredAlias(BaseModel):
+    p: str = Field(alias="p_alias")
+
+
+@app.get("/model-required-alias")
+async def read_model_required_alias(p: Annotated[CookieModelRequiredAlias, Cookie()]):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Alias", "type": "string"},
+            "name": "p_alias",
+            "in": "cookie",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["cookie", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["cookie", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias",
+        pytest.param(
+            "/model-required-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+    ],
+)
+def test_required_alias_by_name(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["cookie", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(
+                        None,
+                        {"p": "hello"},  # /model-required-alias PDv2 fails here
+                    ),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["cookie", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias",
+        pytest.param(
+            "/model-required-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_by_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200, (  # /model-required-alias fails here
+        response.text
+    )
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/required-validation-alias")
+def read_required_validation_alias(
+    p: Annotated[str, Cookie(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class CookieModelRequiredValidationAlias(BaseModel):
+    p: str = Field(validation_alias="p_val_alias")
+
+
+@app.get("/model-required-validation-alias")
+def read_model_required_validation_alias(
+    p: Annotated[CookieModelRequiredValidationAlias, Cookie()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-validation-alias", "/model-required-validation-alias"],
+)
+def test_required_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Val Alias", "type": "string"},
+            "name": "p_val_alias",
+            "in": "cookie",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "cookie",
+                    "p_val_alias",  # /required-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 422, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["cookie", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_val_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/required-alias-and-validation-alias")
+def read_required_alias_and_validation_alias(
+    p: Annotated[str, Cookie(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class CookieModelRequiredAliasAndValidationAlias(BaseModel):
+    p: str = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-required-alias-and-validation-alias")
+def read_model_required_alias_and_validation_alias(
+    p: Annotated[CookieModelRequiredAliasAndValidationAlias, Cookie()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Val Alias", "type": "string"},
+            "name": "p_val_alias",
+            "in": "cookie",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "cookie",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    client.cookies.set("p", "hello")
+    response = client.get(path)
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "cookie",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(  # /model-alias-and-validation-alias fails here
+                    None,
+                    {"p": "hello"},
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_alias", "hello")
+    response = client.get(path)
+    assert (
+        response.status_code == 422  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["cookie", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(  # /model-alias-and-validation-alias fails here
+                    None,
+                    {"p_alias": "hello"},
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    client.cookies.set("p_val_alias", "hello")
+    response = client.get(path)
+    assert response.status_code == 200, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {"p": "hello"}
diff --git a/tests/test_request_params/test_file/__init__.py b/tests/test_request_params/test_file/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_file/test_list.py b/tests/test_request_params/test_file/test_list.py
new file mode 100644 (file)
index 0000000..8722ce5
--- /dev/null
@@ -0,0 +1,597 @@
+from typing import List
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, File, UploadFile
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/list-bytes", operation_id="list_bytes")
+async def read_list_bytes(p: Annotated[List[bytes], File()]):
+    return {"file_size": [len(file) for file in p]}
+
+
+@app.post("/list-uploadfile", operation_id="list_uploadfile")
+async def read_list_uploadfile(p: Annotated[List[UploadFile], File()]):
+    return {"file_size": [file.size for file in p]}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes",
+        "/list-uploadfile",
+    ],
+)
+def test_list_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P",
+                    },
+                )
+                | IsDict(
+                    {
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                        "title": "P",
+                    },
+                )
+            )
+        },
+        "required": ["p"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes",
+        "/list-uploadfile",
+    ],
+)
+def test_list_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p"],
+                    "msg": "Field required",
+                    "input": None,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes",
+        "/list-uploadfile",
+    ],
+)
+def test_list(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello"), ("p", b"world")])
+    assert response.status_code == 200
+    assert response.json() == {"file_size": [5, 5]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/list-bytes-alias", operation_id="list_bytes_alias")
+async def read_list_bytes_alias(p: Annotated[List[bytes], File(alias="p_alias")]):
+    return {"file_size": [len(file) for file in p]}
+
+
+@app.post("/list-uploadfile-alias", operation_id="list_uploadfile_alias")
+async def read_list_uploadfile_alias(
+    p: Annotated[List[UploadFile], File(alias="p_alias")],
+):
+    return {"file_size": [file.size for file in p]}
+
+
+@pytest.mark.xfail(
+    raises=AssertionError,
+    condition=PYDANTIC_V2,
+    reason="Fails only with PDv2",
+    strict=False,
+)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias",
+        "/list-uploadfile-alias",
+    ],
+)
+def test_list_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P Alias",
+                    },
+                )
+                | IsDict(
+                    {
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                        "title": "P Alias",
+                    },
+                )
+            )
+        },
+        "required": ["p_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias",
+        "/list-uploadfile-alias",
+    ],
+)
+def test_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": None,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias",
+        "/list-uploadfile-alias",
+    ],
+)
+def test_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello"), ("p", b"world")])
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": None,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias",
+        "/list-uploadfile-alias",
+    ],
+)
+def test_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": [5, 5]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post("/list-bytes-validation-alias", operation_id="list_bytes_validation_alias")
+def read_list_bytes_validation_alias(
+    p: Annotated[List[bytes], File(validation_alias="p_val_alias")],
+):
+    return {"file_size": [len(file) for file in p]}
+
+
+@app.post(
+    "/list-uploadfile-validation-alias",
+    operation_id="list_uploadfile_validation_alias",
+)
+def read_list_uploadfile_validation_alias(
+    p: Annotated[List[UploadFile], File(validation_alias="p_val_alias")],
+):
+    return {"file_size": [file.size for file in p]}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-validation-alias",
+        "/list-uploadfile-validation-alias",
+    ],
+)
+def test_list_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P Val Alias",
+                    },
+                )
+                | IsDict(
+                    {
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                        "title": "P Val Alias",
+                    },
+                )
+            )
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/list-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/list-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [  # /list-*-validation-alias fail here
+                    "body",
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/list-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/list-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello"), ("p", b"world")])
+    assert response.status_code == 422, (  # /list-*-validation-alias fail here
+        response.text
+    )
+
+    assert response.json() == {  # pragma: no cover
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-validation-alias",
+        "/list-uploadfile-validation-alias",
+    ],
+)
+def test_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(
+        path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")]
+    )
+    assert response.status_code == 200, response.text  # all 2 fail here
+    assert response.json() == {"file_size": [5, 5]}  # pragma: no cover
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/list-bytes-alias-and-validation-alias",
+    operation_id="list_bytes_alias_and_validation_alias",
+)
+def read_list_bytes_alias_and_validation_alias(
+    p: Annotated[List[bytes], File(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"file_size": [len(file) for file in p]}
+
+
+@app.post(
+    "/list-uploadfile-alias-and-validation-alias",
+    operation_id="list_uploadfile_alias_and_validation_alias",
+)
+def read_list_uploadfile_alias_and_validation_alias(
+    p: Annotated[
+        List[UploadFile], File(alias="p_alias", validation_alias="p_val_alias")
+    ],
+):
+    return {"file_size": [file.size for file in p]}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias-and-validation-alias",
+        "/list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_list_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P Val Alias",
+                    },
+                )
+                | IsDict(
+                    {
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                        "title": "P Val Alias",
+                    },
+                )
+            )
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/list-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/list-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /list-*-alias-and-validation-alias fail here
+                ],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias-and-validation-alias",
+        "/list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /list-*-alias-and-validation-alias fail here
+                ],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/list-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/list-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")])
+    assert response.status_code == 422, (
+        response.text  # /list-*-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {  # pragma: no cover
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/list-bytes-alias-and-validation-alias",
+        "/list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(
+        path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")]
+    )
+    assert response.status_code == 200, (  # all 2 fail here
+        response.text
+    )
+    assert response.json() == {"file_size": [5, 5]}  # pragma: no cover
diff --git a/tests/test_request_params/test_file/test_optional.py b/tests/test_request_params/test_file/test_optional.py
new file mode 100644 (file)
index 0000000..14fc0a2
--- /dev/null
@@ -0,0 +1,443 @@
+from typing import Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, File, UploadFile
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/optional-bytes", operation_id="optional_bytes")
+async def read_optional_bytes(p: Annotated[Optional[bytes], File()] = None):
+    return {"file_size": len(p) if p else None}
+
+
+@app.post("/optional-uploadfile", operation_id="optional_uploadfile")
+async def read_optional_uploadfile(p: Annotated[Optional[UploadFile], File()] = None):
+    return {"file_size": p.size if p else None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes",
+        "/optional-uploadfile",
+    ],
+)
+def test_optional_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {"type": "string", "format": "binary"},
+                            {"type": "null"},
+                        ],
+                        "title": "P",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {"title": "P", "type": "string", "format": "binary"}
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes",
+        "/optional-uploadfile",
+    ],
+)
+def test_optional_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes",
+        "/optional-uploadfile",
+    ],
+)
+def test_optional(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello")])
+    assert response.status_code == 200
+    assert response.json() == {"file_size": 5}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/optional-bytes-alias", operation_id="optional_bytes_alias")
+async def read_optional_bytes_alias(
+    p: Annotated[Optional[bytes], File(alias="p_alias")] = None,
+):
+    return {"file_size": len(p) if p else None}
+
+
+@app.post("/optional-uploadfile-alias", operation_id="optional_uploadfile_alias")
+async def read_optional_uploadfile_alias(
+    p: Annotated[Optional[UploadFile], File(alias="p_alias")] = None,
+):
+    return {"file_size": p.size if p else None}
+
+
+@pytest.mark.xfail(
+    raises=AssertionError,
+    condition=PYDANTIC_V2,
+    reason="Fails only with PDv2",
+    strict=False,
+)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias",
+        "/optional-uploadfile-alias",
+    ],
+)
+def test_optional_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {"type": "string", "format": "binary"},
+                            {"type": "null"},
+                        ],
+                        "title": "P Alias",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {"title": "P Alias", "type": "string", "format": "binary"}
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias",
+        "/optional-uploadfile-alias",
+    ],
+)
+def test_optional_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias",
+        "/optional-uploadfile-alias",
+    ],
+)
+def test_optional_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello")])
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias",
+        "/optional-uploadfile-alias",
+    ],
+)
+def test_optional_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": 5}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post(
+    "/optional-bytes-validation-alias", operation_id="optional_bytes_validation_alias"
+)
+def read_optional_bytes_validation_alias(
+    p: Annotated[Optional[bytes], File(validation_alias="p_val_alias")] = None,
+):
+    return {"file_size": len(p) if p else None}
+
+
+@app.post(
+    "/optional-uploadfile-validation-alias",
+    operation_id="optional_uploadfile_validation_alias",
+)
+def read_optional_uploadfile_validation_alias(
+    p: Annotated[Optional[UploadFile], File(validation_alias="p_val_alias")] = None,
+):
+    return {"file_size": p.size if p else None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-validation-alias",
+        "/optional-uploadfile-validation-alias",
+    ],
+)
+def test_optional_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {"type": "string", "format": "binary"},
+                            {"type": "null"},
+                        ],
+                        "title": "P Val Alias",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {"title": "P Val Alias", "type": "string", "format": "binary"}
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-validation-alias",
+        "/optional-uploadfile-validation-alias",
+    ],
+)
+def test_optional_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/optional-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {  # /optional-*-validation-alias fail here
+        "file_size": None
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/optional-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_val_alias", b"hello")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": 5}  # /optional-*-validation-alias fail here
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/optional-bytes-alias-and-validation-alias",
+    operation_id="optional_bytes_alias_and_validation_alias",
+)
+def read_optional_bytes_alias_and_validation_alias(
+    p: Annotated[
+        Optional[bytes], File(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"file_size": len(p) if p else None}
+
+
+@app.post(
+    "/optional-uploadfile-alias-and-validation-alias",
+    operation_id="optional_uploadfile_alias_and_validation_alias",
+)
+def read_optional_uploadfile_alias_and_validation_alias(
+    p: Annotated[
+        Optional[UploadFile], File(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"file_size": p.size if p else None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias-and-validation-alias",
+        "/optional-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {"type": "string", "format": "binary"},
+                            {"type": "null"},
+                        ],
+                        "title": "P Val Alias",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {"title": "P Val Alias", "type": "string", "format": "binary"}
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias-and-validation-alias",
+        "/optional-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-bytes-alias-and-validation-alias",
+        "/optional-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/optional-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/optional-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_val_alias", b"hello")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "file_size": 5
+    }  # /optional-*-alias-and-validation-alias fail here
diff --git a/tests/test_request_params/test_file/test_optional_list.py b/tests/test_request_params/test_file/test_optional_list.py
new file mode 100644 (file)
index 0000000..f266642
--- /dev/null
@@ -0,0 +1,487 @@
+from typing import List, Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, File, UploadFile
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/optional-list-bytes")
+async def read_optional_list_bytes(p: Annotated[Optional[List[bytes]], File()] = None):
+    return {"file_size": [len(file) for file in p] if p else None}
+
+
+@app.post("/optional-list-uploadfile")
+async def read_optional_list_uploadfile(
+    p: Annotated[Optional[List[UploadFile]], File()] = None,
+):
+    return {"file_size": [file.size for file in p] if p else None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes",
+        "/optional-list-uploadfile",
+    ],
+)
+def test_optional_list_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {
+                        "title": "P",
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                    },
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes",
+        "/optional-list-uploadfile",
+    ],
+)
+def test_optional_list_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-bytes",
+            marks=pytest.mark.xfail(
+                raises=(TypeError, AssertionError),
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 due to #14297",
+                strict=False,
+            ),
+        ),
+        "/optional-list-uploadfile",
+    ],
+)
+def test_optional_list(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello"), ("p", b"world")])
+    assert response.status_code == 200
+    assert response.json() == {"file_size": [5, 5]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/optional-list-bytes-alias")
+async def read_optional_list_bytes_alias(
+    p: Annotated[Optional[List[bytes]], File(alias="p_alias")] = None,
+):
+    return {"file_size": [len(file) for file in p] if p else None}
+
+
+@app.post("/optional-list-uploadfile-alias")
+async def read_optional_list_uploadfile_alias(
+    p: Annotated[Optional[List[UploadFile]], File(alias="p_alias")] = None,
+):
+    return {"file_size": [file.size for file in p] if p else None}
+
+
+@pytest.mark.xfail(
+    raises=AssertionError,
+    condition=PYDANTIC_V2,
+    reason="Fails only with PDv2",
+    strict=False,
+)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias",
+        "/optional-list-uploadfile-alias",
+    ],
+)
+def test_optional_list_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P Alias",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {
+                        "title": "P Alias",
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                    }
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias",
+        "/optional-list-uploadfile-alias",
+    ],
+)
+def test_optional_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias",
+        "/optional-list-uploadfile-alias",
+    ],
+)
+def test_optional_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello"), ("p", b"world")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-bytes-alias",
+            marks=pytest.mark.xfail(
+                raises=(TypeError, AssertionError),
+                strict=False,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 model due to #14297",
+            ),
+        ),
+        "/optional-list-uploadfile-alias",
+    ],
+)
+def test_optional_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": [5, 5]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post("/optional-list-bytes-validation-alias")
+def read_optional_list_bytes_validation_alias(
+    p: Annotated[Optional[List[bytes]], File(validation_alias="p_val_alias")] = None,
+):
+    return {"file_size": [len(file) for file in p] if p else None}
+
+
+@app.post("/optional-list-uploadfile-validation-alias")
+def read_optional_list_uploadfile_validation_alias(
+    p: Annotated[
+        Optional[List[UploadFile]], File(validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"file_size": [file.size for file in p] if p else None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-validation-alias",
+        "/optional-list-uploadfile-validation-alias",
+    ],
+)
+def test_optional_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P Val Alias",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {
+                        "title": "P Val Alias",
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                    }
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-validation-alias",
+        "/optional-list-uploadfile-validation-alias",
+    ],
+)
+def test_optional_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-bytes-validation-alias",
+            marks=pytest.mark.xfail(
+                raises=(TypeError, AssertionError),
+                strict=False,
+                reason="Fails due to #14297",
+            ),
+        ),
+        pytest.param(
+            "/optional-list-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello"), ("p", b"world")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {  # /optional-list-uploadfile-validation-alias fails here
+        "file_size": None
+    }
+
+
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-validation-alias",
+        "/optional-list-uploadfile-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(
+        path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")]
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "file_size": [5, 5]  # /optional-list-*-validation-alias fail here
+    }
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post("/optional-list-bytes-alias-and-validation-alias")
+def read_optional_list_bytes_alias_and_validation_alias(
+    p: Annotated[
+        Optional[List[bytes]], File(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"file_size": [len(file) for file in p] if p else None}
+
+
+@app.post("/optional-list-uploadfile-alias-and-validation-alias")
+def read_optional_list_uploadfile_alias_and_validation_alias(
+    p: Annotated[
+        Optional[List[UploadFile]],
+        File(alias="p_alias", validation_alias="p_val_alias"),
+    ] = None,
+):
+    return {"file_size": [file.size for file in p] if p else None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias-and-validation-alias",
+        "/optional-list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": (
+                IsDict(
+                    {
+                        "anyOf": [
+                            {
+                                "type": "array",
+                                "items": {"type": "string", "format": "binary"},
+                            },
+                            {"type": "null"},
+                        ],
+                        "title": "P Val Alias",
+                    }
+                )
+                | IsDict(
+                    # TODO: remove when deprecating Pydantic v1
+                    {
+                        "title": "P Val Alias",
+                        "type": "array",
+                        "items": {"type": "string", "format": "binary"},
+                    }
+                )
+            ),
+        },
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias-and-validation-alias",
+        "/optional-list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias-and-validation-alias",
+        "/optional-list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"file_size": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(
+                raises=(TypeError, AssertionError),
+                strict=False,
+                reason="Fails due to #14297",
+            ),
+        ),
+        pytest.param(
+            "/optional-list-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")])
+    assert response.status_code == 200, response.text
+    assert (  # /optional-list-uploadfile-alias-and-validation-alias fails here
+        response.json() == {"file_size": None}
+    )
+
+
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-bytes-alias-and-validation-alias",
+        "/optional-list-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(
+        path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")]
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "file_size": [5, 5]  # /optional-list-*-alias-and-validation-alias fail here
+    }
diff --git a/tests/test_request_params/test_file/test_required.py b/tests/test_request_params/test_file/test_required.py
new file mode 100644 (file)
index 0000000..e505973
--- /dev/null
@@ -0,0 +1,536 @@
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, File, UploadFile
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/required-bytes", operation_id="required_bytes")
+async def read_required_bytes(p: Annotated[bytes, File()]):
+    return {"file_size": len(p)}
+
+
+@app.post("/required-uploadfile", operation_id="required_uploadfile")
+async def read_required_uploadfile(p: Annotated[UploadFile, File()]):
+    return {"file_size": p.size}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes",
+        "/required-uploadfile",
+    ],
+)
+def test_required_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": {"title": "P", "type": "string", "format": "binary"},
+        },
+        "required": ["p"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes",
+        "/required-uploadfile",
+    ],
+)
+def test_required_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p"],
+                    "msg": "Field required",
+                    "input": None,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes",
+        "/required-uploadfile",
+    ],
+)
+def test_required(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello")])
+    assert response.status_code == 200
+    assert response.json() == {"file_size": 5}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/required-bytes-alias", operation_id="required_bytes_alias")
+async def read_required_bytes_alias(p: Annotated[bytes, File(alias="p_alias")]):
+    return {"file_size": len(p)}
+
+
+@app.post("/required-uploadfile-alias", operation_id="required_uploadfile_alias")
+async def read_required_uploadfile_alias(
+    p: Annotated[UploadFile, File(alias="p_alias")],
+):
+    return {"file_size": p.size}
+
+
+@pytest.mark.xfail(
+    raises=AssertionError,
+    condition=PYDANTIC_V2,
+    reason="Fails only with PDv2",
+    strict=False,
+)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes-alias",
+        "/required-uploadfile-alias",
+    ],
+)
+def test_required_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": {"title": "P Alias", "type": "string", "format": "binary"},
+        },
+        "required": ["p_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes-alias",
+        "/required-uploadfile-alias",
+    ],
+)
+def test_required_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": None,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes-alias",
+        "/required-uploadfile-alias",
+    ],
+)
+def test_required_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello")])
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": None,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes-alias",
+        "/required-uploadfile-alias",
+    ],
+)
+def test_required_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello")])
+    assert response.status_code == 200, response.text
+    assert response.json() == {"file_size": 5}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post(
+    "/required-bytes-validation-alias", operation_id="required_bytes_validation_alias"
+)
+def read_required_bytes_validation_alias(
+    p: Annotated[bytes, File(validation_alias="p_val_alias")],
+):
+    return {"file_size": len(p)}
+
+
+@app.post(
+    "/required-uploadfile-validation-alias",
+    operation_id="required_uploadfile_validation_alias",
+)
+def read_required_uploadfile_validation_alias(
+    p: Annotated[UploadFile, File(validation_alias="p_val_alias")],
+):
+    return {"file_size": p.size}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes-validation-alias",
+        "/required-uploadfile-validation-alias",
+    ],
+)
+def test_required_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {
+                "title": "P Val Alias",
+                "type": "string",
+                "format": "binary",
+            },
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [  # /required-*-validation-alias fail here
+                    "body",
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p", b"hello")])
+    assert response.status_code == 422, (  # /required-*-validation-alias fail here
+        response.text
+    )
+
+    assert response.json() == {  # pragma: no cover
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_val_alias", b"hello")])
+    assert response.status_code == 200, (  # all 2 fail here
+        response.text
+    )
+    assert response.json() == {"file_size": 5}  # pragma: no cover
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/required-bytes-alias-and-validation-alias",
+    operation_id="required_bytes_alias_and_validation_alias",
+)
+def read_required_bytes_alias_and_validation_alias(
+    p: Annotated[bytes, File(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"file_size": len(p)}
+
+
+@app.post(
+    "/required-uploadfile-alias-and-validation-alias",
+    operation_id="required_uploadfile_alias_and_validation_alias",
+)
+def read_required_uploadfile_alias_and_validation_alias(
+    p: Annotated[UploadFile, File(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"file_size": p.size}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-bytes-alias-and-validation-alias",
+        "/required-uploadfile-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {
+                "title": "P Val Alias",
+                "type": "string",
+                "format": "binary",
+            },
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-*-alias-and-validation-alias fail here
+                ],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, files={"p": "hello"})
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-*-alias-and-validation-alias fail here
+                ],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_alias", b"hello")])
+    assert response.status_code == 422, (
+        response.text  # /required-*-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {  # pragma: no cover
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": None,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-bytes-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        pytest.param(
+            "/required-uploadfile-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, files=[("p_val_alias", b"hello")])
+    assert response.status_code == 200, (  # all 2 fail here
+        response.text
+    )
+    assert response.json() == {"file_size": 5}  # pragma: no cover
diff --git a/tests/test_request_params/test_file/utils.py b/tests/test_request_params/test_file/utils.py
new file mode 100644 (file)
index 0000000..e33f643
--- /dev/null
@@ -0,0 +1,7 @@
+from typing import Any, Dict
+
+
+def get_body_model_name(openapi: Dict[str, Any], path: str) -> str:
+    body = openapi["paths"][path]["post"]["requestBody"]
+    body_schema = body["content"]["multipart/form-data"]["schema"]
+    return body_schema.get("$ref", "").split("/")[-1]
diff --git a/tests/test_request_params/test_form/__init__.py b/tests/test_request_params/test_form/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_form/test_list.py b/tests/test_request_params/test_form/test_list.py
new file mode 100644 (file)
index 0000000..c57180f
--- /dev/null
@@ -0,0 +1,527 @@
+from typing import List
+
+import pytest
+from dirty_equals import IsDict, IsOneOf, IsPartialDict
+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
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/required-list-str", operation_id="required_list_str")
+async def read_required_list_str(p: Annotated[List[str], Form()]):
+    return {"p": p}
+
+
+class FormModelRequiredListStr(BaseModel):
+    p: List[str]
+
+
+@app.post("/model-required-list-str", operation_id="model_required_list_str")
+def read_model_required_list_str(p: Annotated[FormModelRequiredListStr, Form()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": {
+                "items": {"type": "string"},
+                "title": "P",
+                "type": "array",
+            },
+        },
+        "required": ["p"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/required-list-alias", operation_id="required_list_alias")
+async def read_required_list_alias(p: Annotated[List[str], Form(alias="p_alias")]):
+    return {"p": p}
+
+
+class FormModelRequiredListAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias")
+
+
+@app.post("/model-required-list-alias", operation_id="model_required_list_alias")
+async def read_model_required_list_alias(
+    p: Annotated[FormModelRequiredListAlias, Form()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+        "/model-required-list-alias",
+    ],
+)
+def test_required_list_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": {
+                "items": {"type": "string"},
+                "title": "P Alias",
+                "type": "array",
+            },
+        },
+        "required": ["p_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias",
+        pytest.param(
+            "/model-required-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+    ],
+)
+def test_required_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(  # /model-required-list-alias with PDv2 fails here
+                        None, {"p": ["hello", "world"]}
+                    ),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": ["hello", "world"]})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post(
+    "/required-list-validation-alias", operation_id="required_list_validation_alias"
+)
+def read_required_list_validation_alias(
+    p: Annotated[List[str], Form(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class FormModelRequiredListValidationAlias(BaseModel):
+    p: List[str] = Field(validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-list-validation-alias",
+    operation_id="model_required_list_validation_alias",
+)
+async def read_model_required_list_validation_alias(
+    p: Annotated[FormModelRequiredListValidationAlias, Form()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {
+                "items": {"type": "string"},
+                "title": "P Val Alias",
+                "type": "array",
+            },
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-list-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 422, (
+        response.text  # /required-list-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, response.text  # both fail here
+
+    assert response.json() == {"p": ["hello", "world"]}  # pragma: no cover
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/required-list-alias-and-validation-alias",
+    operation_id="required_list_alias_and_validation_alias",
+)
+def read_required_list_alias_and_validation_alias(
+    p: Annotated[List[str], Form(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class FormModelRequiredListAliasAndValidationAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-list-alias-and-validation-alias",
+    operation_id="model_required_list_alias_and_validation_alias",
+)
+def read_model_required_list_alias_and_validation_alias(
+    p: Annotated[FormModelRequiredListAliasAndValidationAlias, Form()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {
+                "items": {"type": "string"},
+                "title": "P Val Alias",
+                "type": "array",
+            },
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    # /required-list-alias-and-validation-alias fails here
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    # /required-list-alias-and-validation-alias fails here
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(
+                    None,
+                    # /model-required-list-alias-and-validation-alias fails here
+                    {"p": ["hello", "world"]},
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": ["hello", "world"]})
+    assert (  # /required-list-alias-and-validation-alias fails here
+        response.status_code == 422
+    )
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p_alias": ["hello", "world"]}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, response.text  # both fail here
+    assert response.json() == {"p": ["hello", "world"]}  # pragma: no cover
diff --git a/tests/test_request_params/test_form/test_optional_list.py b/tests/test_request_params/test_form/test_optional_list.py
new file mode 100644 (file)
index 0000000..288a0cf
--- /dev/null
@@ -0,0 +1,454 @@
+from typing import List, Optional
+
+import pytest
+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
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/optional-list-str", operation_id="optional_list_str")
+async def read_optional_list_str(
+    p: Annotated[Optional[List[str]], Form()] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalListStr(BaseModel):
+    p: Optional[List[str]] = None
+
+
+@app.post("/model-optional-list-str", operation_id="model_optional_list_str")
+async def read_model_optional_list_str(p: Annotated[FormModelOptionalListStr, Form()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p": {"items": {"type": "string"}, "type": "array", "title": "P"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/optional-list-alias", operation_id="optional_list_alias")
+async def read_optional_list_alias(
+    p: Annotated[Optional[List[str]], Form(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalListAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, alias="p_alias")
+
+
+@app.post("/model-optional-list-alias", operation_id="model_optional_list_alias")
+async def read_model_optional_list_alias(
+    p: Annotated[FormModelOptionalListAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                strict=False,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2",
+            ),
+        ),
+        "/model-optional-list-alias",
+    ],
+)
+def test_optional_list_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_alias": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_alias": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post(
+    "/optional-list-validation-alias", operation_id="optional_list_validation_alias"
+)
+def read_optional_list_validation_alias(
+    p: Annotated[Optional[List[str]], Form(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalListValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-optional-list-validation-alias",
+    operation_id="model_optional_list_validation_alias",
+)
+def read_model_optional_list_validation_alias(
+    p: Annotated[FormModelOptionalListValidationAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-validation-alias",
+    ],
+)
+def test_optional_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-list-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, (
+        response.text  # /model-optional-list-validation-alias fails here
+    )
+    assert response.json() == {  # /optional-list-validation-alias fails here
+        "p": ["hello", "world"]
+    }
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/optional-list-alias-and-validation-alias",
+    operation_id="optional_list_alias_and_validation_alias",
+)
+def read_optional_list_alias_and_validation_alias(
+    p: Annotated[
+        Optional[List[str]], Form(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalListAliasAndValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(
+        None, alias="p_alias", validation_alias="p_val_alias"
+    )
+
+
+@app.post(
+    "/model-optional-list-alias-and-validation-alias",
+    operation_id="model_optional_list_alias_and_validation_alias",
+)
+def read_model_optional_list_alias_and_validation_alias(
+    p: Annotated[FormModelOptionalListAliasAndValidationAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": ["hello", "world"]})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-list-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": ["hello", "world"]})
+    assert response.status_code == 200, (
+        response.text  # /model-optional-list-alias-and-validation-alias fails here
+    )
+    assert response.json() == {
+        "p": [  # /optional-list-alias-and-validation-alias fails here
+            "hello",
+            "world",
+        ]
+    }
diff --git a/tests/test_request_params/test_form/test_optional_str.py b/tests/test_request_params/test_form/test_optional_str.py
new file mode 100644 (file)
index 0000000..66c003a
--- /dev/null
@@ -0,0 +1,419 @@
+from typing import Optional
+
+import pytest
+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
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/optional-str", operation_id="optional_str")
+async def read_optional_str(p: Annotated[Optional[str], Form()] = None):
+    return {"p": p}
+
+
+class FormModelOptionalStr(BaseModel):
+    p: Optional[str] = None
+
+
+@app.post("/model-optional-str", operation_id="model_optional_str")
+async def read_model_optional_str(p: Annotated[FormModelOptionalStr, Form()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p": {"type": "string", "title": "P"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/optional-alias", operation_id="optional_alias")
+async def read_optional_alias(
+    p: Annotated[Optional[str], Form(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias")
+
+
+@app.post("/model-optional-alias", operation_id="model_optional_alias")
+async def read_model_optional_alias(p: Annotated[FormModelOptionalAlias, Form()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                strict=False,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2",
+            ),
+        ),
+        "/model-optional-alias",
+    ],
+)
+def test_optional_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_alias": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_alias": {"type": "string", "title": "P Alias"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post("/optional-validation-alias", operation_id="optional_validation_alias")
+def read_optional_validation_alias(
+    p: Annotated[Optional[str], Form(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-optional-validation-alias", operation_id="model_optional_validation_alias"
+)
+def read_model_optional_validation_alias(
+    p: Annotated[FormModelOptionalValidationAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {"type": "string", "title": "P Val Alias"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /optional-validation-alias fails here
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/optional-alias-and-validation-alias",
+    operation_id="optional_alias_and_validation_alias",
+)
+def read_optional_alias_and_validation_alias(
+    p: Annotated[
+        Optional[str], Form(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class FormModelOptionalAliasAndValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-optional-alias-and-validation-alias",
+    operation_id="model_optional_alias_and_validation_alias",
+)
+def read_model_optional_alias_and_validation_alias(
+    p: Annotated[FormModelOptionalAliasAndValidationAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == IsDict(
+        {
+            "properties": {
+                "p_val_alias": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Val Alias",
+                },
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "properties": {
+                "p_val_alias": {"type": "string", "title": "P Val Alias"},
+            },
+            "title": body_model_name,
+            "type": "object",
+        }
+    )
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": "hello"  # /optional-alias-and-validation-alias fails here
+    }
diff --git a/tests/test_request_params/test_form/test_required_str.py b/tests/test_request_params/test_form/test_required_str.py
new file mode 100644 (file)
index 0000000..fcbce01
--- /dev/null
@@ -0,0 +1,502 @@
+import pytest
+from dirty_equals import IsDict, IsOneOf
+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
+
+from tests.utils import needs_pydanticv2
+
+from .utils import get_body_model_name
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.post("/required-str", operation_id="required_str")
+async def read_required_str(p: Annotated[str, Form()]):
+    return {"p": p}
+
+
+class FormModelRequiredStr(BaseModel):
+    p: str
+
+
+@app.post("/model-required-str", operation_id="model_required_str")
+async def read_model_required_str(p: Annotated[FormModelRequiredStr, Form()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p": {"title": "P", "type": "string"},
+        },
+        "required": ["p"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.post("/required-alias", operation_id="required_alias")
+async def read_required_alias(p: Annotated[str, Form(alias="p_alias")]):
+    return {"p": p}
+
+
+class FormModelRequiredAlias(BaseModel):
+    p: str = Field(alias="p_alias")
+
+
+@app.post("/model-required-alias", operation_id="model_required_alias")
+async def read_model_required_alias(p: Annotated[FormModelRequiredAlias, Form()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2",
+                strict=False,
+            ),
+        ),
+        "/model-required-alias",
+    ],
+)
+def test_required_str_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_alias": {"title": "P Alias", "type": "string"},
+        },
+        "required": ["p_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {"p": "hello"}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": "hello"})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.post("/required-validation-alias", operation_id="required_validation_alias")
+def read_required_validation_alias(
+    p: Annotated[str, Form(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class FormModelRequiredValidationAlias(BaseModel):
+    p: str = Field(validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-validation-alias", operation_id="model_required_validation_alias"
+)
+def read_model_required_validation_alias(
+    p: Annotated[FormModelRequiredValidationAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/required-validation-alias", "/model-required-validation-alias"],
+)
+def test_required_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {"title": "P Val Alias", "type": "string"},
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 422, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": "hello"})
+    assert response.status_code == 200, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.post(
+    "/required-alias-and-validation-alias",
+    operation_id="required_alias_and_validation_alias",
+)
+def read_required_alias_and_validation_alias(
+    p: Annotated[str, Form(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class FormModelRequiredAliasAndValidationAlias(BaseModel):
+    p: str = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.post(
+    "/model-required-alias-and-validation-alias",
+    operation_id="model_required_alias_and_validation_alias",
+)
+def read_model_required_alias_and_validation_alias(
+    p: Annotated[FormModelRequiredAliasAndValidationAlias, Form()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_schema(path: str):
+    openapi = app.openapi()
+    body_model_name = get_body_model_name(openapi, path)
+
+    assert app.openapi()["components"]["schemas"][body_model_name] == {
+        "properties": {
+            "p_val_alias": {"title": "P Val Alias", "type": "string"},
+        },
+        "required": ["p_val_alias"],
+        "title": body_model_name,
+        "type": "object",
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.post(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p": "hello"})
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "body",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_alias": "hello"})
+    assert response.status_code == 422, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["body", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p_alias": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.post(path, data={"p_val_alias": "hello"})
+    assert response.status_code == 200, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {"p": "hello"}
diff --git a/tests/test_request_params/test_form/utils.py b/tests/test_request_params/test_form/utils.py
new file mode 100644 (file)
index 0000000..d200650
--- /dev/null
@@ -0,0 +1,7 @@
+from typing import Any, Dict
+
+
+def get_body_model_name(openapi: Dict[str, Any], path: str) -> str:
+    body = openapi["paths"][path]["post"]["requestBody"]
+    body_schema = body["content"]["application/x-www-form-urlencoded"]["schema"]
+    return body_schema.get("$ref", "").split("/")[-1]
diff --git a/tests/test_request_params/test_header/__init__.py b/tests/test_request_params/test_header/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_header/test_list.py b/tests/test_request_params/test_header/test_list.py
new file mode 100644 (file)
index 0000000..1bd3628
--- /dev/null
@@ -0,0 +1,505 @@
+from typing import List
+
+import pytest
+from dirty_equals import AnyThing, IsDict, IsOneOf, IsPartialDict
+from fastapi import FastAPI, Header
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/required-list-str")
+async def read_required_list_str(p: Annotated[List[str], Header()]):
+    return {"p": p}
+
+
+class HeaderModelRequiredListStr(BaseModel):
+    p: List[str]
+
+
+@app.get("/model-required-list-str")
+def read_model_required_list_str(p: Annotated[HeaderModelRequiredListStr, Header()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p",
+            "in": "header",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["header", "p"],
+                    "msg": "Field required",
+                    "input": AnyThing,
+                }
+            ]
+        }
+    ) | IsDict(
+        {
+            "detail": [
+                {
+                    "loc": ["header", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/required-list-alias")
+async def read_required_list_alias(p: Annotated[List[str], Header(alias="p_alias")]):
+    return {"p": p}
+
+
+class HeaderModelRequiredListAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias")
+
+
+@app.get("/model-required-list-alias")
+async def read_model_required_list_alias(
+    p: Annotated[HeaderModelRequiredListAlias, Header()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P Alias",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p_alias",
+            "in": "header",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["header", "p_alias"],
+                    "msg": "Field required",
+                    "input": AnyThing,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["header", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias",
+        pytest.param(
+            "/model-required-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+    ],
+)
+def test_required_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["header", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(  # /model-required-list-alias with PDv2 fails here
+                        None, IsPartialDict({"p": ["hello", "world"]})
+                    ),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["header", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias",
+        pytest.param(
+            "/model-required-list-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")])
+    assert response.status_code == 200, (  # /model-required-list-alias fails here
+        response.text
+    )
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/required-list-validation-alias")
+def read_required_list_validation_alias(
+    p: Annotated[List[str], Header(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class HeaderModelRequiredListValidationAlias(BaseModel):
+    p: List[str] = Field(validation_alias="p_val_alias")
+
+
+@app.get("/model-required-list-validation-alias")
+async def read_model_required_list_validation_alias(
+    p: Annotated[HeaderModelRequiredListValidationAlias, Header()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P Val Alias",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "header",
+                    "p_val_alias",  # /required-list-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": AnyThing,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 422  # /required-list-validation-alias fails here
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["header", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(
+        path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")]
+    )
+    assert response.status_code == 200, response.text  # both fail here
+
+    assert response.json() == {"p": ["hello", "world"]}  # pragma: no cover
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/required-list-alias-and-validation-alias")
+def read_required_list_alias_and_validation_alias(
+    p: Annotated[List[str], Header(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class HeaderModelRequiredListAliasAndValidationAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-required-list-alias-and-validation-alias")
+def read_model_required_list_alias_and_validation_alias(
+    p: Annotated[HeaderModelRequiredListAliasAndValidationAlias, Header()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P Val Alias",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "header",
+                    # /required-list-alias-and-validation-alias fails here
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": AnyThing,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "header",
+                    # /required-list-alias-and-validation-alias fails here
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(
+                    None,
+                    # /model-required-list-alias-and-validation-alias fails here
+                    IsPartialDict({"p": ["hello", "world"]}),
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")])
+    assert (  # /required-list-alias-and-validation-alias fails here
+        response.status_code == 422
+    )
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["header", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(
+                    None,
+                    # /model-required-list-alias-and-validation-alias fails here
+                    IsPartialDict({"p_alias": ["hello", "world"]}),
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(
+        path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")]
+    )
+    assert response.status_code == 200, response.text  # both fail here
+    assert response.json() == {"p": ["hello", "world"]}  # pragma: no cover
diff --git a/tests/test_request_params/test_header/test_optional_list.py b/tests/test_request_params/test_header/test_optional_list.py
new file mode 100644 (file)
index 0000000..328f039
--- /dev/null
@@ -0,0 +1,407 @@
+from typing import List, Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, Header
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/optional-list-str")
+async def read_optional_list_str(
+    p: Annotated[Optional[List[str]], Header()] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalListStr(BaseModel):
+    p: Optional[List[str]] = None
+
+
+@app.get("/model-optional-list-str")
+async def read_model_optional_list_str(
+    p: Annotated[HeaderModelOptionalListStr, Header()],
+):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P",
+                },
+                "name": "p",
+                "in": "header",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"items": {"type": "string"}, "type": "array", "title": "P"},
+                "name": "p",
+                "in": "header",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/optional-list-alias")
+async def read_optional_list_alias(
+    p: Annotated[Optional[List[str]], Header(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalListAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, alias="p_alias")
+
+
+@app.get("/model-optional-list-alias")
+async def read_model_optional_list_alias(
+    p: Annotated[HeaderModelOptionalListAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "header",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "header",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias",
+        pytest.param(
+            "/model-optional-list-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")])
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": ["hello", "world"]  # /model-optional-list-alias fails here
+    }
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/optional-list-validation-alias")
+def read_optional_list_validation_alias(
+    p: Annotated[Optional[List[str]], Header(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalListValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-list-validation-alias")
+def read_model_optional_list_validation_alias(
+    p: Annotated[HeaderModelOptionalListValidationAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [
+                    {"items": {"type": "string"}, "type": "array"},
+                    {"type": "null"},
+                ],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-validation-alias",
+    ],
+)
+def test_optional_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-list-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(
+        path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")]
+    )
+    assert response.status_code == 200, (
+        response.text  # /model-optional-list-validation-alias fails here
+    )
+    assert response.json() == {  # /optional-list-validation-alias fails here
+        "p": ["hello", "world"]
+    }
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/optional-list-alias-and-validation-alias")
+def read_optional_list_alias_and_validation_alias(
+    p: Annotated[
+        Optional[List[str]], Header(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalListAliasAndValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(
+        None, alias="p_alias", validation_alias="p_val_alias"
+    )
+
+
+@app.get("/model-optional-list-alias-and-validation-alias")
+def read_model_optional_list_alias_and_validation_alias(
+    p: Annotated[HeaderModelOptionalListAliasAndValidationAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [
+                    {"items": {"type": "string"}, "type": "array"},
+                    {"type": "null"},
+                ],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p", "hello"), ("p", "world")])
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")])
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-list-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(
+        path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")]
+    )
+    assert response.status_code == 200, (
+        response.text  # /model-optional-list-alias-and-validation-alias fails here
+    )
+    assert response.json() == {
+        "p": [  # /optional-list-alias-and-validation-alias fails here
+            "hello",
+            "world",
+        ]
+    }
diff --git a/tests/test_request_params/test_header/test_optional_str.py b/tests/test_request_params/test_header/test_optional_str.py
new file mode 100644 (file)
index 0000000..d63e0a2
--- /dev/null
@@ -0,0 +1,375 @@
+from typing import Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, Header
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/optional-str")
+async def read_optional_str(p: Annotated[Optional[str], Header()] = None):
+    return {"p": p}
+
+
+class HeaderModelOptionalStr(BaseModel):
+    p: Optional[str] = None
+
+
+@app.get("/model-optional-str")
+async def read_model_optional_str(p: Annotated[HeaderModelOptionalStr, Header()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P",
+                },
+                "name": "p",
+                "in": "header",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"title": "P", "type": "string"},
+                "name": "p",
+                "in": "header",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/optional-alias")
+async def read_optional_alias(
+    p: Annotated[Optional[str], Header(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias")
+
+
+@app.get("/model-optional-alias")
+async def read_model_optional_alias(p: Annotated[HeaderModelOptionalAlias, Header()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "header",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"title": "P Alias", "type": "string"},
+                "name": "p_alias",
+                "in": "header",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias",
+        pytest.param(
+            "/model-optional-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /model-optional-alias fails here
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/optional-validation-alias")
+def read_optional_validation_alias(
+    p: Annotated[Optional[str], Header(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-validation-alias")
+def read_model_optional_validation_alias(
+    p: Annotated[HeaderModelOptionalValidationAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_val_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /optional-validation-alias fails here
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/optional-alias-and-validation-alias")
+def read_optional_alias_and_validation_alias(
+    p: Annotated[
+        Optional[str], Header(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class HeaderModelOptionalAliasAndValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-alias-and-validation-alias")
+def read_model_optional_alias_and_validation_alias(
+    p: Annotated[HeaderModelOptionalAliasAndValidationAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_val_alias": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": "hello"  # /optional-alias-and-validation-alias fails here
+    }
diff --git a/tests/test_request_params/test_header/test_required_str.py b/tests/test_request_params/test_header/test_required_str.py
new file mode 100644 (file)
index 0000000..6eb4fd6
--- /dev/null
@@ -0,0 +1,492 @@
+import pytest
+from dirty_equals import AnyThing, IsDict, IsOneOf, IsPartialDict
+from fastapi import FastAPI, Header
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/required-str")
+async def read_required_str(p: Annotated[str, Header()]):
+    return {"p": p}
+
+
+class HeaderModelRequiredStr(BaseModel):
+    p: str
+
+
+@app.get("/model-required-str")
+async def read_model_required_str(p: Annotated[HeaderModelRequiredStr, Header()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P", "type": "string"},
+            "name": "p",
+            "in": "header",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["header", "p"],
+                    "msg": "Field required",
+                    "input": AnyThing,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["header", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/required-alias")
+async def read_required_alias(p: Annotated[str, Header(alias="p_alias")]):
+    return {"p": p}
+
+
+class HeaderModelRequiredAlias(BaseModel):
+    p: str = Field(alias="p_alias")
+
+
+@app.get("/model-required-alias")
+async def read_model_required_alias(p: Annotated[HeaderModelRequiredAlias, Header()]):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Alias", "type": "string"},
+            "name": "p_alias",
+            "in": "header",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["header", "p_alias"],
+                    "msg": "Field required",
+                    "input": AnyThing,
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["header", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias",
+        pytest.param(
+            "/model-required-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+    ],
+)
+def test_required_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["header", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, IsPartialDict({"p": "hello"})),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["header", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias",
+        pytest.param(
+            "/model-required-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_alias": "hello"})
+    assert response.status_code == 200, (  # /model-required-alias fails here
+        response.text
+    )
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/required-validation-alias")
+def read_required_validation_alias(
+    p: Annotated[str, Header(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class HeaderModelRequiredValidationAlias(BaseModel):
+    p: str = Field(validation_alias="p_val_alias")
+
+
+@app.get("/model-required-validation-alias")
+def read_model_required_validation_alias(
+    p: Annotated[HeaderModelRequiredValidationAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-validation-alias", "/model-required-validation-alias"],
+)
+def test_required_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Val Alias", "type": "string"},
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "header",
+                    "p_val_alias",  # /required-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": AnyThing,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 422, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["header", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, IsPartialDict({"p": "hello"})),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_val_alias": "hello"})
+    assert response.status_code == 200, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/required-alias-and-validation-alias")
+def read_required_alias_and_validation_alias(
+    p: Annotated[str, Header(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class HeaderModelRequiredAliasAndValidationAlias(BaseModel):
+    p: str = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-required-alias-and-validation-alias")
+def read_model_required_alias_and_validation_alias(
+    p: Annotated[HeaderModelRequiredAliasAndValidationAlias, Header()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Val Alias", "type": "string"},
+            "name": "p_val_alias",
+            "in": "header",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "header",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": AnyThing,
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p": "hello"})
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "header",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(  # /model-alias-and-validation-alias fails here
+                    None,
+                    IsPartialDict({"p": "hello"}),
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_alias": "hello"})
+    assert (
+        response.status_code == 422  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["header", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(  # /model-alias-and-validation-alias fails here
+                    None,
+                    IsPartialDict({"p_alias": "hello"}),
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(path, headers={"p_val_alias": "hello"})
+    assert response.status_code == 200, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {"p": "hello"}
diff --git a/tests/test_request_params/test_path/__init__.py b/tests/test_request_params/test_path/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_path/test_list.py b/tests/test_request_params/test_path/test_list.py
new file mode 100644 (file)
index 0000000..bba055d
--- /dev/null
@@ -0,0 +1 @@
+# FastAPI doesn't currently support non-scalar Path parameters
diff --git a/tests/test_request_params/test_path/test_optional_list.py b/tests/test_request_params/test_path/test_optional_list.py
new file mode 100644 (file)
index 0000000..0719430
--- /dev/null
@@ -0,0 +1 @@
+# Optional Path parameters are not supported
diff --git a/tests/test_request_params/test_path/test_optional_str.py b/tests/test_request_params/test_path/test_optional_str.py
new file mode 100644 (file)
index 0000000..0719430
--- /dev/null
@@ -0,0 +1 @@
+# Optional Path parameters are not supported
diff --git a/tests/test_request_params/test_path/test_required_str.py b/tests/test_request_params/test_path/test_required_str.py
new file mode 100644 (file)
index 0000000..8e2e600
--- /dev/null
@@ -0,0 +1,102 @@
+import pytest
+from fastapi import FastAPI, Path
+from fastapi.testclient import TestClient
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+
+@app.get("/required-str/{p}")
+async def read_required_str(p: Annotated[str, Path()]):
+    return {"p": p}
+
+
+@app.get("/required-alias/{p_alias}")
+async def read_required_alias(p: Annotated[str, Path(alias="p_alias")]):
+    return {"p": p}
+
+
+@app.get("/required-validation-alias/{p_val_alias}")
+def read_required_validation_alias(
+    p: Annotated[str, Path(validation_alias="p_val_alias")],
+):
+    return {"p": p}  # pragma: no cover
+
+
+@app.get("/required-alias-and-validation-alias/{p_val_alias}")
+def read_required_alias_and_validation_alias(
+    p: Annotated[str, Path(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    ("path", "expected_name", "expected_title"),
+    [
+        pytest.param("/required-str/{p}", "p", "P", id="required-str"),
+        pytest.param(
+            "/required-alias/{p_alias}", "p_alias", "P Alias", id="required-alias"
+        ),
+        pytest.param(
+            "/required-validation-alias/{p_val_alias}",
+            "p_val_alias",
+            "P Val Alias",
+            id="required-validation-alias",
+            marks=(
+                needs_pydanticv2,
+                pytest.mark.xfail(raises=AssertionError, strict=False),
+            ),
+        ),
+        pytest.param(
+            "/required-alias-and-validation-alias/{p_val_alias}",
+            "p_val_alias",
+            "P Val Alias",
+            id="required-alias-and-validation-alias",
+            marks=(
+                needs_pydanticv2,
+                pytest.mark.xfail(raises=AssertionError, strict=False),
+            ),
+        ),
+    ],
+)
+def test_schema(path: str, expected_name: str, expected_title: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": expected_title, "type": "string"},
+            "name": expected_name,
+            "in": "path",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param("/required-str", id="required-str"),
+        pytest.param("/required-alias", id="required-alias"),
+        pytest.param(
+            "/required-validation-alias",
+            id="required-validation-alias",
+            marks=(
+                needs_pydanticv2,
+                pytest.mark.xfail(raises=AssertionError, strict=False),
+            ),
+        ),
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            id="required-alias-and-validation-alias",
+            marks=(
+                needs_pydanticv2,
+                pytest.mark.xfail(raises=AssertionError, strict=False),
+            ),
+        ),
+    ],
+)
+def test_success(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}/hello")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": "hello"}
diff --git a/tests/test_request_params/test_query/__init__.py b/tests/test_request_params/test_query/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_request_params/test_query/test_list.py b/tests/test_request_params/test_query/test_list.py
new file mode 100644 (file)
index 0000000..4edd192
--- /dev/null
@@ -0,0 +1,506 @@
+from typing import List
+
+import pytest
+from dirty_equals import IsDict, IsOneOf
+from fastapi import FastAPI, Query
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/required-list-str")
+async def read_required_list_str(p: Annotated[List[str], Query()]):
+    return {"p": p}
+
+
+class QueryModelRequiredListStr(BaseModel):
+    p: List[str]
+
+
+@app.get("/model-required-list-str")
+def read_model_required_list_str(p: Annotated[QueryModelRequiredListStr, Query()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p",
+            "in": "query",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["query", "p"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        {
+            "detail": [
+                {
+                    "loc": ["query", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-str", "/model-required-list-str"],
+)
+def test_required_list_str(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/required-list-alias")
+async def read_required_list_alias(p: Annotated[List[str], Query(alias="p_alias")]):
+    return {"p": p}
+
+
+class QueryModelRequiredListAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias")
+
+
+@app.get("/model-required-list-alias")
+async def read_model_required_list_alias(
+    p: Annotated[QueryModelRequiredListAlias, Query()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P Alias",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p_alias",
+            "in": "query",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-alias", "/model-required-list-alias"],
+)
+def test_required_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["query", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["query", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias",
+        pytest.param(
+            "/model-required-list-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+    ],
+)
+def test_required_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["query", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(  # /model-required-list-alias with PDv2 fails here
+                        None, {"p": ["hello", "world"]}
+                    ),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["query", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias",
+        pytest.param(
+            "/model-required-list-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello&p_alias=world")
+    assert response.status_code == 200, (  # /model-required-list-alias fails here
+        response.text
+    )
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/required-list-validation-alias")
+def read_required_list_validation_alias(
+    p: Annotated[List[str], Query(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class QueryModelRequiredListValidationAlias(BaseModel):
+    p: List[str] = Field(validation_alias="p_val_alias")
+
+
+@app.get("/model-required-list-validation-alias")
+async def read_model_required_list_validation_alias(
+    p: Annotated[QueryModelRequiredListValidationAlias, Query()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P Val Alias",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "query",
+                    "p_val_alias",  # /required-list-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-validation-alias",
+    ],
+)
+def test_required_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 422  # /required-list-validation-alias fails here
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["query", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": ["hello", "world"]}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-list-validation-alias", "/model-required-list-validation-alias"],
+)
+def test_required_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world")
+    assert response.status_code == 200, response.text  # both fail here
+
+    assert response.json() == {"p": ["hello", "world"]}  # pragma: no cover
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/required-list-alias-and-validation-alias")
+def read_required_list_alias_and_validation_alias(
+    p: Annotated[List[str], Query(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class QueryModelRequiredListAliasAndValidationAlias(BaseModel):
+    p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-required-list-alias-and-validation-alias")
+def read_model_required_list_alias_and_validation_alias(
+    p: Annotated[QueryModelRequiredListAliasAndValidationAlias, Query()],
+):
+    return {"p": p.p}  # pragma: no cover
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {
+                "title": "P Val Alias",
+                "type": "array",
+                "items": {"type": "string"},
+            },
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "query",
+                    # /required-list-alias-and-validation-alias fails here
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "query",
+                    # /required-list-alias-and-validation-alias fails here
+                    "p_val_alias",
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(
+                    None,
+                    # /model-required-list-alias-and-validation-alias fails here
+                    {
+                        "p": [
+                            "hello",
+                            "world",
+                        ]
+                    },
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello&p_alias=world")
+    assert (  # /required-list-alias-and-validation-alias fails here
+        response.status_code == 422
+    )
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["query", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(
+                    None,
+                    # /model-required-list-alias-and-validation-alias fails here
+                    {"p_alias": ["hello", "world"]},
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-list-alias-and-validation-alias",
+        "/model-required-list-alias-and-validation-alias",
+    ],
+)
+def test_required_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world")
+    assert response.status_code == 200, response.text  # both fail here
+    assert response.json() == {"p": ["hello", "world"]}  # pragma: no cover
diff --git a/tests/test_request_params/test_query/test_optional_list.py b/tests/test_request_params/test_query/test_optional_list.py
new file mode 100644 (file)
index 0000000..76f9605
--- /dev/null
@@ -0,0 +1,403 @@
+from typing import List, Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, Query
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/optional-list-str")
+async def read_optional_list_str(
+    p: Annotated[Optional[List[str]], Query()] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalListStr(BaseModel):
+    p: Optional[List[str]] = None
+
+
+@app.get("/model-optional-list-str")
+async def read_model_optional_list_str(
+    p: Annotated[QueryModelOptionalListStr, Query()],
+):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P",
+                },
+                "name": "p",
+                "in": "query",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"items": {"type": "string"}, "type": "array", "title": "P"},
+                "name": "p",
+                "in": "query",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-str", "/model-optional-list-str"],
+)
+def test_optional_list_str(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 200
+    assert response.json() == {"p": ["hello", "world"]}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/optional-list-alias")
+async def read_optional_list_alias(
+    p: Annotated[Optional[List[str]], Query(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalListAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, alias="p_alias")
+
+
+@app.get("/model-optional-list-alias")
+async def read_model_optional_list_alias(
+    p: Annotated[QueryModelOptionalListAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [
+                        {"items": {"type": "string"}, "type": "array"},
+                        {"type": "null"},
+                    ],
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "query",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {
+                    "items": {"type": "string"},
+                    "type": "array",
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "query",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-alias", "/model-optional-list-alias"],
+)
+def test_optional_list_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias",
+        pytest.param(
+            "/model-optional-list-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_list_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello&p_alias=world")
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": ["hello", "world"]  # /model-optional-list-alias fails here
+    }
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/optional-list-validation-alias")
+def read_optional_list_validation_alias(
+    p: Annotated[Optional[List[str]], Query(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalListValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(None, validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-list-validation-alias")
+def read_model_optional_list_validation_alias(
+    p: Annotated[QueryModelOptionalListValidationAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [
+                    {"items": {"type": "string"}, "type": "array"},
+                    {"type": "null"},
+                ],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-validation-alias",
+    ],
+)
+def test_optional_list_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}  # /optional-list-validation-alias fails here
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-list-validation-alias", "/model-optional-list-validation-alias"],
+)
+def test_optional_list_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world")
+    assert response.status_code == 200, (
+        response.text  # /model-optional-list-validation-alias fails here
+    )
+    assert response.json() == {  # /optional-list-validation-alias fails here
+        "p": ["hello", "world"]
+    }
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/optional-list-alias-and-validation-alias")
+def read_optional_list_alias_and_validation_alias(
+    p: Annotated[
+        Optional[List[str]], Query(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalListAliasAndValidationAlias(BaseModel):
+    p: Optional[List[str]] = Field(
+        None, alias="p_alias", validation_alias="p_val_alias"
+    )
+
+
+@app.get("/model-optional-list-alias-and-validation-alias")
+def read_model_optional_list_alias_and_validation_alias(
+    p: Annotated[QueryModelOptionalListAliasAndValidationAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [
+                    {"items": {"type": "string"}, "type": "array"},
+                    {"type": "null"},
+                ],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello&p=world")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-list-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello&p_alias=world")
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-list-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-list-alias-and-validation-alias",
+        "/model-optional-list-alias-and-validation-alias",
+    ],
+)
+def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world")
+    assert response.status_code == 200, (
+        response.text  # /model-optional-list-alias-and-validation-alias fails here
+    )
+    assert response.json() == {
+        "p": [  # /optional-list-alias-and-validation-alias fails here
+            "hello",
+            "world",
+        ]
+    }
diff --git a/tests/test_request_params/test_query/test_optional_str.py b/tests/test_request_params/test_query/test_optional_str.py
new file mode 100644 (file)
index 0000000..77da9be
--- /dev/null
@@ -0,0 +1,375 @@
+from typing import Optional
+
+import pytest
+from dirty_equals import IsDict
+from fastapi import FastAPI, Query
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/optional-str")
+async def read_optional_str(p: Optional[str] = None):
+    return {"p": p}
+
+
+class QueryModelOptionalStr(BaseModel):
+    p: Optional[str] = None
+
+
+@app.get("/model-optional-str")
+async def read_model_optional_str(p: Annotated[QueryModelOptionalStr, Query()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P",
+                },
+                "name": "p",
+                "in": "query",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"title": "P", "type": "string"},
+                "name": "p",
+                "in": "query",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-str", "/model-optional-str"],
+)
+def test_optional_str(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/optional-alias")
+async def read_optional_alias(
+    p: Annotated[Optional[str], Query(alias="p_alias")] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias")
+
+
+@app.get("/model-optional-alias")
+async def read_model_optional_alias(p: Annotated[QueryModelOptionalAlias, Query()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        IsDict(
+            {
+                "required": False,
+                "schema": {
+                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                    "title": "P Alias",
+                },
+                "name": "p_alias",
+                "in": "query",
+            }
+        )
+        | IsDict(
+            # TODO: remove when deprecating Pydantic v1
+            {
+                "required": False,
+                "schema": {"title": "P Alias", "type": "string"},
+                "name": "p_alias",
+                "in": "query",
+            }
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-alias", "/model-optional-alias"],
+)
+def test_optional_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias",
+        pytest.param(
+            "/model-optional-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_optional_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /model-optional-alias fails here
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/optional-validation-alias")
+def read_optional_validation_alias(
+    p: Annotated[Optional[str], Query(validation_alias="p_val_alias")] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-validation-alias")
+def read_model_optional_validation_alias(
+    p: Annotated[QueryModelOptionalValidationAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    ["/optional-validation-alias", "/model-optional-validation-alias"],
+)
+def test_optional_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-validation-alias",
+    ],
+)
+def test_optional_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}  # /optional-validation-alias fails here
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/optional-alias-and-validation-alias")
+def read_optional_alias_and_validation_alias(
+    p: Annotated[
+        Optional[str], Query(alias="p_alias", validation_alias="p_val_alias")
+    ] = None,
+):
+    return {"p": p}
+
+
+class QueryModelOptionalAliasAndValidationAlias(BaseModel):
+    p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-optional-alias-and-validation-alias")
+def read_model_optional_alias_and_validation_alias(
+    p: Annotated[QueryModelOptionalAliasAndValidationAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": False,
+            "schema": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "P Val Alias",
+            },
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/optional-alias-and-validation-alias",
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": None}
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello")
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": None  # /optional-alias-and-validation-alias fails here
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/optional-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-optional-alias-and-validation-alias",
+    ],
+)
+def test_optional_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello")
+    assert response.status_code == 200
+    assert response.json() == {
+        "p": "hello"  # /optional-alias-and-validation-alias fails here
+    }
diff --git a/tests/test_request_params/test_query/test_required_str.py b/tests/test_request_params/test_query/test_required_str.py
new file mode 100644 (file)
index 0000000..aa3a276
--- /dev/null
@@ -0,0 +1,495 @@
+import pytest
+from dirty_equals import IsDict, IsOneOf
+from fastapi import FastAPI, Query
+from fastapi._compat import PYDANTIC_V2
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated
+
+from tests.utils import needs_pydanticv2
+
+app = FastAPI()
+
+# =====================================================================================
+# Without aliases
+
+
+@app.get("/required-str")
+async def read_required_str(p: str):
+    return {"p": p}
+
+
+class QueryModelRequiredStr(BaseModel):
+    p: str
+
+
+@app.get("/model-required-str")
+async def read_model_required_str(p: Annotated[QueryModelRequiredStr, Query()]):
+    return {"p": p.p}
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P", "type": "string"},
+            "name": "p",
+            "in": "query",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["query", "p"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["query", "p"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-str", "/model-required-str"],
+)
+def test_required_str(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 200
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias
+
+
+@app.get("/required-alias")
+async def read_required_alias(p: Annotated[str, Query(alias="p_alias")]):
+    return {"p": p}
+
+
+class QueryModelRequiredAlias(BaseModel):
+    p: str = Field(alias="p_alias")
+
+
+@app.get("/model-required-alias")
+async def read_model_required_alias(p: Annotated[QueryModelRequiredAlias, Query()]):
+    return {"p": p.p}  # pragma: no cover
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_str_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Alias", "type": "string"},
+            "name": "p_alias",
+            "in": "query",
+        }
+    ]
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["/required-alias", "/model-required-alias"],
+)
+def test_required_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["query", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(None, {}),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["query", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias",
+        pytest.param(
+            "/model-required-alias",
+            marks=pytest.mark.xfail(
+                raises=AssertionError,
+                condition=PYDANTIC_V2,
+                reason="Fails only with PDv2 models",
+                strict=False,
+            ),
+        ),
+    ],
+)
+def test_required_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["query", "p_alias"],
+                    "msg": "Field required",
+                    "input": IsOneOf(
+                        None,
+                        {"p": "hello"},  # /model-required-alias PDv2 fails here
+                    ),
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["query", "p_alias"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias",
+        pytest.param(
+            "/model-required-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+    ],
+)
+def test_required_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello")
+    assert response.status_code == 200, (  # /model-required-alias fails here
+        response.text
+    )
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Validation alias
+
+
+@app.get("/required-validation-alias")
+def read_required_validation_alias(
+    p: Annotated[str, Query(validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class QueryModelRequiredValidationAlias(BaseModel):
+    p: str = Field(validation_alias="p_val_alias")
+
+
+@app.get("/model-required-validation-alias")
+def read_model_required_validation_alias(
+    p: Annotated[QueryModelRequiredValidationAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    ["/required-validation-alias", "/model-required-validation-alias"],
+)
+def test_required_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Val Alias", "type": "string"},
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "query",
+                    "p_val_alias",  # /required-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 422, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["query", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(None, {"p": "hello"}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-validation-alias",
+    ],
+)
+def test_required_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello")
+    assert response.status_code == 200, (  # /required-validation-alias fails here
+        response.text
+    )
+
+    assert response.json() == {"p": "hello"}
+
+
+# =====================================================================================
+# Alias and validation alias
+
+
+@app.get("/required-alias-and-validation-alias")
+def read_required_alias_and_validation_alias(
+    p: Annotated[str, Query(alias="p_alias", validation_alias="p_val_alias")],
+):
+    return {"p": p}
+
+
+class QueryModelRequiredAliasAndValidationAlias(BaseModel):
+    p: str = Field(alias="p_alias", validation_alias="p_val_alias")
+
+
+@app.get("/model-required-alias-and-validation-alias")
+def read_model_required_alias_and_validation_alias(
+    p: Annotated[QueryModelRequiredAliasAndValidationAlias, Query()],
+):
+    return {"p": p.p}
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_schema(path: str):
+    assert app.openapi()["paths"][path]["get"]["parameters"] == [
+        {
+            "required": True,
+            "schema": {"title": "P Val Alias", "type": "string"},
+            "name": "p_val_alias",
+            "in": "query",
+        }
+    ]
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_missing(path: str):
+    client = TestClient(app)
+    response = client.get(path)
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "query",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(None, {}),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_name(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p=hello")
+    assert response.status_code == 422
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": [
+                    "query",
+                    "p_val_alias",  # /required-alias-and-validation-alias fails here
+                ],
+                "msg": "Field required",
+                "input": IsOneOf(  # /model-alias-and-validation-alias fails here
+                    None,
+                    {"p": "hello"},
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.xfail(raises=AssertionError, strict=False)
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/required-alias-and-validation-alias",
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_alias=hello")
+    assert (
+        response.status_code == 422  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {
+        "detail": [
+            {
+                "type": "missing",
+                "loc": ["query", "p_val_alias"],
+                "msg": "Field required",
+                "input": IsOneOf(  # /model-alias-and-validation-alias fails here
+                    None,
+                    {"p_alias": "hello"},
+                ),
+            }
+        ]
+    }
+
+
+@needs_pydanticv2
+@pytest.mark.parametrize(
+    "path",
+    [
+        pytest.param(
+            "/required-alias-and-validation-alias",
+            marks=pytest.mark.xfail(raises=AssertionError, strict=False),
+        ),
+        "/model-required-alias-and-validation-alias",
+    ],
+)
+def test_required_alias_and_validation_alias_by_validation_alias(path: str):
+    client = TestClient(app)
+    response = client.get(f"{path}?p_val_alias=hello")
+    assert response.status_code == 200, (
+        response.text  # /required-alias-and-validation-alias fails here
+    )
+
+    assert response.json() == {"p": "hello"}