From dc621d1b0ed10d9c92cacb476595565b99568026 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 17 Nov 2025 17:27:06 +0100 Subject: [PATCH] Add tests for Query, Path, Cookie, Header parameters --- tests/test_request_params/__init__.py | 0 .../test_request_params/test_body/__init__.py | 0 .../test_body/test_list.py | 522 +++++++++++ .../test_body/test_optional_list.py | 594 +++++++++++++ .../test_body/test_optional_str.py | 566 ++++++++++++ .../test_body/test_required_str.py | 509 +++++++++++ tests/test_request_params/test_body/utils.py | 7 + .../test_cookie/__init__.py | 0 .../test_cookie/test_list.py | 3 + .../test_cookie/test_optional_list.py | 3 + .../test_cookie/test_optional_str.py | 378 ++++++++ .../test_cookie/test_required_str.py | 502 +++++++++++ .../test_request_params/test_file/__init__.py | 0 .../test_file/test_list.py | 816 ++++++++++++++++++ .../test_file/test_optional.py | 635 ++++++++++++++ .../test_file/test_optional_list.py | 684 +++++++++++++++ .../test_file/test_required.py | 756 ++++++++++++++++ tests/test_request_params/test_file/utils.py | 7 + .../test_request_params/test_form/__init__.py | 0 .../test_form/test_list.py | 524 +++++++++++ .../test_form/test_optional_list.py | 449 ++++++++++ .../test_form/test_optional_str.py | 414 +++++++++ .../test_form/test_required_str.py | 501 +++++++++++ tests/test_request_params/test_form/utils.py | 7 + .../test_header/__init__.py | 0 .../test_header/test_list.py | 502 +++++++++++ .../test_header/test_optional_list.py | 400 +++++++++ .../test_header/test_optional_str.py | 370 ++++++++ .../test_header/test_required_str.py | 491 +++++++++++ .../test_request_params/test_path/__init__.py | 0 .../test_path/test_list.py | 1 + .../test_path/test_optional_list.py | 1 + .../test_path/test_optional_str.py | 1 + .../test_path/test_required_str.py | 101 +++ .../test_query/__init__.py | 0 .../test_query/test_list.py | 503 +++++++++++ .../test_query/test_optional_list.py | 396 +++++++++ .../test_query/test_optional_str.py | 370 ++++++++ .../test_query/test_required_str.py | 494 +++++++++++ 39 files changed, 11507 insertions(+) create mode 100644 tests/test_request_params/__init__.py create mode 100644 tests/test_request_params/test_body/__init__.py create mode 100644 tests/test_request_params/test_body/test_list.py create mode 100644 tests/test_request_params/test_body/test_optional_list.py create mode 100644 tests/test_request_params/test_body/test_optional_str.py create mode 100644 tests/test_request_params/test_body/test_required_str.py create mode 100644 tests/test_request_params/test_body/utils.py create mode 100644 tests/test_request_params/test_cookie/__init__.py create mode 100644 tests/test_request_params/test_cookie/test_list.py create mode 100644 tests/test_request_params/test_cookie/test_optional_list.py create mode 100644 tests/test_request_params/test_cookie/test_optional_str.py create mode 100644 tests/test_request_params/test_cookie/test_required_str.py create mode 100644 tests/test_request_params/test_file/__init__.py create mode 100644 tests/test_request_params/test_file/test_list.py create mode 100644 tests/test_request_params/test_file/test_optional.py create mode 100644 tests/test_request_params/test_file/test_optional_list.py create mode 100644 tests/test_request_params/test_file/test_required.py create mode 100644 tests/test_request_params/test_file/utils.py create mode 100644 tests/test_request_params/test_form/__init__.py create mode 100644 tests/test_request_params/test_form/test_list.py create mode 100644 tests/test_request_params/test_form/test_optional_list.py create mode 100644 tests/test_request_params/test_form/test_optional_str.py create mode 100644 tests/test_request_params/test_form/test_required_str.py create mode 100644 tests/test_request_params/test_form/utils.py create mode 100644 tests/test_request_params/test_header/__init__.py create mode 100644 tests/test_request_params/test_header/test_list.py create mode 100644 tests/test_request_params/test_header/test_optional_list.py create mode 100644 tests/test_request_params/test_header/test_optional_str.py create mode 100644 tests/test_request_params/test_header/test_required_str.py create mode 100644 tests/test_request_params/test_path/__init__.py create mode 100644 tests/test_request_params/test_path/test_list.py create mode 100644 tests/test_request_params/test_path/test_optional_list.py create mode 100644 tests/test_request_params/test_path/test_optional_str.py create mode 100644 tests/test_request_params/test_path/test_required_str.py create mode 100644 tests/test_request_params/test_query/__init__.py create mode 100644 tests/test_request_params/test_query/test_list.py create mode 100644 tests/test_request_params/test_query/test_optional_list.py create mode 100644 tests/test_request_params/test_query/test_optional_str.py create mode 100644 tests/test_request_params/test_query/test_required_str.py diff --git a/tests/test_request_params/__init__.py b/tests/test_request_params/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_request_params/test_body/__init__.py b/tests/test_request_params/test_body/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 index 0000000000..77ffe503f5 --- /dev/null +++ b/tests/test_request_params/test_body/test_list.py @@ -0,0 +1,522 @@ +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 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: List[str] = Body(..., embed=True)): + 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: FormModelRequiredListStr): + 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: List[str] = Body(..., embed=True, 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: FormModelRequiredListAlias): + 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: List[str] = Body(..., embed=True, 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: FormModelRequiredListValidationAlias, +): + 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: List[str] = Body( + ..., embed=True, 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: FormModelRequiredListAliasAndValidationAlias, +): + 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 index 0000000000..b31c495470 --- /dev/null +++ b/tests/test_request_params/test_body/test_optional_list.py @@ -0,0 +1,594 @@ +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 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: Optional[List[str]] = Body(None, embed=True)): + 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: FormModelOptionalListStr): + 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: Optional[List[str]] = Body(None, embed=True, alias="p_alias"), +): + 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: FormModelOptionalListAlias): + 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: Optional[List[str]] = Body(None, embed=True, validation_alias="p_val_alias"), +): + 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: FormModelOptionalListValidationAlias, +): + 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: Optional[List[str]] = Body( + None, embed=True, alias="p_alias", validation_alias="p_val_alias" + ), +): + 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: FormModelOptionalListAliasAndValidationAlias, +): + 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 index 0000000000..2efa595f91 --- /dev/null +++ b/tests/test_request_params/test_body/test_optional_str.py @@ -0,0 +1,566 @@ +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 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: Optional[str] = Body(None, embed=True)): + 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: FormModelOptionalStr): + 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: Optional[str] = Body(None, embed=True, alias="p_alias"), +): + 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: FormModelOptionalAlias): + 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: Optional[str] = Body(None, embed=True, validation_alias="p_val_alias"), +): + 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: FormModelOptionalValidationAlias, +): + 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: Optional[str] = Body( + None, embed=True, alias="p_alias", validation_alias="p_val_alias" + ), +): + 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: FormModelOptionalAliasAndValidationAlias, +): + 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 index 0000000000..ff7e3a2162 --- /dev/null +++ b/tests/test_request_params/test_body/test_required_str.py @@ -0,0 +1,509 @@ +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 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: str = Body(..., embed=True)): + 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: FormModelRequiredStr): + 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: str = Body(..., embed=True, 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: FormModelRequiredAlias): + 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: str = Body(..., embed=True, 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: FormModelRequiredValidationAlias, +): + 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: str = Body(..., embed=True, 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: FormModelRequiredAliasAndValidationAlias, +): + 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": {"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 index 0000000000..5151a82d36 --- /dev/null +++ b/tests/test_request_params/test_body/utils.py @@ -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 index 0000000000..e69de29bb2 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 index 0000000000..4ae80e0015 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_list.py @@ -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 index 0000000000..4ae80e0015 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_optional_list.py @@ -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 index 0000000000..5dfa0d2a48 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_optional_str.py @@ -0,0 +1,378 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-str") +async def read_optional_str(p: 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: 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: Optional[str] = Cookie(None, alias="p_alias")): + 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: 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: Optional[str] = Cookie(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[str] = Cookie(None, alias="p_alias", validation_alias="p_val_alias"), +): + 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: 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 index 0000000000..1a9611a0b0 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_required_str.py @@ -0,0 +1,502 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-str") +async def read_required_str(p: str = Cookie(...)): + return {"p": p} + + +class CookieModelRequiredStr(BaseModel): + p: str + + +@app.get("/model-required-str") +async def read_model_required_str(p: 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: 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: 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: 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: 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: 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: 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 index 0000000000..e69de29bb2 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 index 0000000000..a11a39d805 --- /dev/null +++ b/tests/test_request_params/test_file/test_list.py @@ -0,0 +1,816 @@ +from typing import List + +import pytest +from dirty_equals import IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, File, Form, UploadFile +from fastapi._compat import PYDANTIC_V2 +from fastapi.testclient import TestClient +from pydantic import BaseModel + +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: 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: List[UploadFile] = File(...)): + return {"file_size": [file.size for file in p]} + + +class FormModelListBytes(BaseModel): + p: List[bytes] = File(...) + + +@app.post("/model-list-bytes", operation_id="model_list_bytes") +async def read_model_list_bytes( + p: FormModelListBytes = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p]} + + +class FormModelListUploadFile(BaseModel): + p: List[UploadFile] = File(...) + + +@app.post("/model-list-uploadfile", operation_id="model_list_uploadfile") +async def read_model_list_uploadfile( + p: FormModelListUploadFile = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p]} + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes", + "/model-list-bytes", + "/list-uploadfile", + "/model-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", + "/model-list-bytes", + "/list-uploadfile", + "/model-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": IsOneOf(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", + "/model-list-bytes", + "/list-uploadfile", + "/model-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: 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: List[UploadFile] = File(..., alias="p_alias")): + return {"file_size": [file.size for file in p]} + + +class FormModelListBytesAlias(BaseModel): + p: List[bytes] = File(..., alias="p_alias") + + +@app.post("/model-list-bytes-alias", operation_id="model_list_bytes_alias") +async def read_model_list_bytes_alias( + p: FormModelListBytesAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p]} + + +class FormModelListUploadFileAlias(BaseModel): + p: List[UploadFile] = File(..., alias="p_alias") + + +@app.post("/model-list-uploadfile-alias", operation_id="model_list_uploadfile_alias") +async def read_model_list_uploadfile_alias( + p: FormModelListUploadFileAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p]} + + +@pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, +) +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias", + "/model-list-bytes-alias", + "/list-uploadfile-alias", + "/model-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", + pytest.param( + "/model-list-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + "/list-uploadfile-alias", + pytest.param( + "/model-list-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + ], +) +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"], # model-list-*-alias fail here + "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", + [ + "/list-bytes-alias", + pytest.param( + "/model-list-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + "/list-uploadfile-alias", + pytest.param( + "/model-list-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + ], +) +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 # model-list-uploadfile-alias fail here + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf( # model-list-bytes-alias fail here + None, + {"p": [IsPartialDict({"size": 5}), IsPartialDict({"size": 5})]}, + ), + } + ] + } + ) | 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", + pytest.param( + "/model-list-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + "/list-uploadfile-alias", + pytest.param( + "/model-list-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + ], +) +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, ( # model-list-*-alias fail here + 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: 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: List[UploadFile] = File(..., validation_alias="p_val_alias"), +): + return {"file_size": [file.size for file in p]} + + +class FormModelRequiredBytesValidationAlias(BaseModel): + p: List[bytes] = File(..., validation_alias="p_val_alias") + + +@app.post( + "/model-list-bytes-validation-alias", + operation_id="model_list_bytes_validation_alias", +) +def read_model_list_bytes_validation_alias( + p: FormModelRequiredBytesValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p]} # pragma: no cover + + +class FormModelRequiredUploadFileValidationAlias(BaseModel): + p: List[UploadFile] = File(..., validation_alias="p_val_alias") + + +@app.post( + "/model-list-uploadfile-validation-alias", + operation_id="model_list_uploadfile_validation_alias", +) +def read_model_list_uploadfile_validation_alias( + p: FormModelRequiredUploadFileValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p]} # pragma: no cover + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-validation-alias", + "/model-list-uploadfile-validation-alias", + "/list-uploadfile-validation-alias", + "/model-list-bytes-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), + ), + "/model-list-bytes-validation-alias", + pytest.param( + "/list-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-list-uploadfile-validation-alias", + ], +) +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": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/list-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-list-uploadfile-validation-alias", + ], +) +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() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( # /model-list-bytes-validation-alias fails here + None, + {"p": [IsPartialDict({"size": 5}), IsPartialDict({"size": 5})]}, + ), + } + ] + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-validation-alias", + "/model-list-bytes-validation-alias", + "/list-uploadfile-validation-alias", + "/model-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 4 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: 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: List[UploadFile] = File(..., alias="p_alias", validation_alias="p_val_alias"), +): + return {"file_size": [file.size for file in p]} + + +class FormModelRequiredBytesAliasAndValidationAlias(BaseModel): + p: List[bytes] = File(..., alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-list-bytes-alias-and-validation-alias", + operation_id="model_list_bytes_alias_and_validation_alias", +) +def read_model_list_bytes_alias_and_validation_alias( + p: FormModelRequiredBytesAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p]} # pragma: no cover + + +class FormModelRequiredUploadFileAliasAndValidationAlias(BaseModel): + p: List[UploadFile] = File(..., alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-list-uploadfile-alias-and-validation-alias", + operation_id="model_list_uploadfile_alias_and_validation_alias", +) +def read_model_list_uploadfile_alias_and_validation_alias( + p: FormModelRequiredUploadFileAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p]} # pragma: no cover + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/model-list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + "/model-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), + ), + "/model-list-bytes-alias-and-validation-alias", + pytest.param( + "/list-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-list-uploadfile-alias-and-validation-alias", + ], +) +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": IsOneOf(None, {}), + } + ] + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/model-list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + "/model-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": IsOneOf( + None, + # /model-list-*-alias-and-validation-alias fail here + {"p": [IsPartialDict({"size": 5}), IsPartialDict({"size": 5})]}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/list-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-list-uploadfile-alias-and-validation-alias", + ], +) +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() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-list-bytes-alias-and-validation-alias fails here + { + "p_alias": [ + IsPartialDict({"size": 5}), + IsPartialDict({"size": 5}), + ] + }, + ), + } + ] + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/model-list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + "/model-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 4 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 index 0000000000..7cd4614b1a --- /dev/null +++ b/tests/test_request_params/test_file/test_optional.py @@ -0,0 +1,635 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, Form, UploadFile +from fastapi._compat import PYDANTIC_V2 +from fastapi.testclient import TestClient +from pydantic import BaseModel + +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: 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: Optional[UploadFile] = File(None)): + return {"file_size": p.size if p else None} + + +class FormModelOptionalBytes(BaseModel): + p: Optional[bytes] = File(None) + + +@app.post("/model-optional-bytes", operation_id="model_optional_bytes") +async def read_model_optional_bytes( + p: FormModelOptionalBytes = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p) if p.p else None} + + +class FormModelOptionalUploadFile(BaseModel): + p: Optional[UploadFile] = File(None) + + +@app.post("/model-optional-uploadfile", operation_id="model_optional_uploadfile") +async def read_model_optional_uploadfile( + p: FormModelOptionalUploadFile = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size if p.p else None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes", + "/model-optional-bytes", + "/optional-uploadfile", + "/model-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", + "/model-optional-bytes", + "/optional-uploadfile", + "/model-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", + "/model-optional-bytes", + "/optional-uploadfile", + "/model-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: Optional[bytes] = File(None, alias="p_alias")): + 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: Optional[UploadFile] = File(None, alias="p_alias"), +): + return {"file_size": p.size if p else None} + + +class FormModelOptionalBytesAlias(BaseModel): + p: Optional[bytes] = File(None, alias="p_alias") + + +@app.post("/model-optional-bytes-alias", operation_id="model_optional_bytes_alias") +async def read_model_optional_bytes_alias( + p: FormModelOptionalBytesAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p) if p.p else None} + + +class FormModelOptionalUploadFileAlias(BaseModel): + p: Optional[UploadFile] = File(None, alias="p_alias") + + +@app.post( + "/model-optional-uploadfile-alias", operation_id="model_optional_uploadfile_alias" +) +async def read_model_optional_uploadfile_alias( + p: FormModelOptionalUploadFileAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size if p.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", + "/model-optional-bytes-alias", + "/optional-uploadfile-alias", + "/model-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", + "/model-optional-bytes-alias", + "/optional-uploadfile-alias", + "/model-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", + pytest.param( + "/model-optional-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + "/optional-uploadfile-alias", + pytest.param( + "/model-optional-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + ], +) +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} # model-optional-*-alias fail here + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias", + pytest.param( + "/model-optional-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + "/optional-uploadfile-alias", + pytest.param( + "/model-optional-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + ], +) +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} # model-optional-*-alias fail here + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/optional-bytes-validation-alias", operation_id="optional_bytes_validation_alias" +) +def read_optional_bytes_validation_alias( + p: Optional[bytes] = File(None, validation_alias="p_val_alias"), +): + 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: Optional[UploadFile] = File(None, validation_alias="p_val_alias"), +): + return {"file_size": p.size if p else None} + + +class FormModelOptionalBytesValidationAlias(BaseModel): + p: Optional[bytes] = File(None, validation_alias="p_val_alias") + + +@app.post( + "/model-optional-bytes-validation-alias", + operation_id="model_optional_bytes_validation_alias", +) +def read_model_optional_bytes_validation_alias( + p: FormModelOptionalBytesValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p) if p.p else None} + + +class FormModelOptionalUploadFileValidationAlias(BaseModel): + p: Optional[UploadFile] = File(None, validation_alias="p_val_alias") + + +@app.post( + "/model-optional-uploadfile-validation-alias", + operation_id="model_optional_uploadfile_validation_alias", +) +def read_model_optional_uploadfile_validation_alias( + p: FormModelOptionalUploadFileValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size if p.p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-validation-alias", + "/model-optional-uploadfile-validation-alias", + "/optional-uploadfile-validation-alias", + "/model-optional-bytes-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", + "/model-optional-bytes-validation-alias", + "/optional-uploadfile-validation-alias", + "/model-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), + ), + "/model-optional-bytes-validation-alias", + pytest.param( + "/optional-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-optional-uploadfile-validation-alias", + ], +) +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( + "/model-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), + ), + "/model-optional-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")]) + assert response.status_code == 200, ( + response.text # /model-optional-bytes-validation-alias fail here + ) + 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: Optional[bytes] = File(None, alias="p_alias", validation_alias="p_val_alias"), +): + 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: Optional[UploadFile] = File( + None, alias="p_alias", validation_alias="p_val_alias" + ), +): + return {"file_size": p.size if p else None} + + +class FormModelOptionalBytesAliasAndValidationAlias(BaseModel): + p: Optional[bytes] = File(None, alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-optional-bytes-alias-and-validation-alias", + operation_id="model_optional_bytes_alias_and_validation_alias", +) +def read_model_optional_bytes_alias_and_validation_alias( + p: FormModelOptionalBytesAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p) if p.p else None} + + +class FormModelOptionalUploadFileAliasAndValidationAlias(BaseModel): + p: Optional[UploadFile] = File( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.post( + "/model-optional-uploadfile-alias-and-validation-alias", + operation_id="model_optional_uploadfile_alias_and_validation_alias", +) +def read_model_optional_uploadfile_alias_and_validation_alias( + p: FormModelOptionalUploadFileAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size if p.p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias-and-validation-alias", + "/model-optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + "/model-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", + "/model-optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + "/model-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", + "/model-optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + "/model-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), + ), + "/model-optional-bytes-alias-and-validation-alias", + pytest.param( + "/optional-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-optional-uploadfile-alias-and-validation-alias", + ], +) +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 # model-optional-*-alias-and-validation-alias fail here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-optional-uploadfile-alias-and-validation-alias", + ], +) +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 # model-optional-bytes-alias-and-validation-alias fails here + ) + 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 index 0000000000..825e251483 --- /dev/null +++ b/tests/test_request_params/test_file/test_optional_list.py @@ -0,0 +1,684 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, Form, UploadFile +from fastapi._compat import PYDANTIC_V2 +from fastapi.testclient import TestClient +from pydantic import BaseModel + +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: 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: Optional[List[UploadFile]] = File(None)): + return {"file_size": [file.size for file in p] if p else None} + + +class FormModelOptionalListBytes(BaseModel): + p: Optional[List[bytes]] = File(None) + + +@app.post("/model-optional-list-bytes") +async def read_model_optional_list_bytes( + p: FormModelOptionalListBytes = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p] if p.p else None} + + +class FormModelOptionalListUploadFile(BaseModel): + p: Optional[List[UploadFile]] = File(None) + + +@app.post("/model-optional-list-uploadfile") +async def read_model_optional_list_uploadfile( + p: FormModelOptionalListUploadFile = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p] if p.p else None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes", + "/model-optional-list-bytes", + "/optional-list-uploadfile", + "/model-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", + "/model-optional-list-bytes", + "/optional-list-uploadfile", + "/model-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, + ), + ), + pytest.param( + "/model-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", + "/model-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: Optional[List[bytes]] = File(None, alias="p_alias"), +): + 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: Optional[List[UploadFile]] = File(None, alias="p_alias"), +): + return {"file_size": [file.size for file in p] if p else None} + + +class FormModelOptionalListBytesAlias(BaseModel): + p: Optional[List[bytes]] = File(None, alias="p_alias") + + +@app.post("/model-optional-list-bytes-alias") +async def read_model_optional_list_bytes_alias( + p: FormModelOptionalListBytesAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p] if p.p else None} + + +class FormModelOptionalListUploadFileAlias(BaseModel): + p: Optional[List[UploadFile]] = File(None, alias="p_alias") + + +@app.post("/model-optional-list-uploadfile-alias") +async def read_model_optional_list_uploadfile_alias( + p: FormModelOptionalListUploadFileAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p] if p.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", + "/model-optional-list-bytes-alias", + "/optional-list-uploadfile-alias", + "/model-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", + "/model-optional-list-bytes-alias", + "/optional-list-uploadfile-alias", + "/model-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", + pytest.param( + "/model-optional-list-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + "/optional-list-uploadfile-alias", + pytest.param( + "/model-optional-list-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + ], +) +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 # model-optional-list-*-alias fail here + ) + 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", + ), + ), + pytest.param( + "/model-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", + pytest.param( + "/model-optional-list-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + ], +) +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] # /model-optional-list-uploadfile-alias fails here + } + + +# ===================================================================================== +# Validation alias + + +@app.post("/optional-list-bytes-validation-alias") +def read_optional_list_bytes_validation_alias( + p: Optional[List[bytes]] = File(None, validation_alias="p_val_alias"), +): + 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: Optional[List[UploadFile]] = File(None, validation_alias="p_val_alias"), +): + return {"file_size": [file.size for file in p] if p else None} + + +class FormModelOptionalListBytesValidationAlias(BaseModel): + p: Optional[List[bytes]] = File(None, validation_alias="p_val_alias") + + +@app.post("/model-optional-list-bytes-validation-alias") +def read_model_optional_list_bytes_validation_alias( + p: FormModelOptionalListBytesValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p] if p.p else None} + + +class FormModelOptionalListUploadFileValidationAlias(BaseModel): + p: Optional[List[UploadFile]] = File(None, validation_alias="p_val_alias") + + +@app.post("/model-optional-list-uploadfile-validation-alias") +def read_model_optional_list_uploadfile_validation_alias( + p: FormModelOptionalListUploadFileValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p] if p.p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-validation-alias", + "/model-optional-list-uploadfile-validation-alias", + "/optional-list-uploadfile-validation-alias", + "/model-optional-list-bytes-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", + "/model-optional-list-bytes-validation-alias", + "/optional-list-uploadfile-validation-alias", + "/model-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( + "/model-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), + ), + "/model-optional-list-uploadfile-validation-alias", + ], +) +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", + "/model-optional-list-bytes-validation-alias", + "/optional-list-uploadfile-validation-alias", + "/model-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 # /model-optional-list-*-validation-alias fail here + ) + 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: Optional[List[bytes]] = File( + None, alias="p_alias", validation_alias="p_val_alias" + ), +): + 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: Optional[List[UploadFile]] = File( + None, alias="p_alias", validation_alias="p_val_alias" + ), +): + return {"file_size": [file.size for file in p] if p else None} + + +class FormModelOptionalListBytesAliasAndValidationAlias(BaseModel): + p: Optional[List[bytes]] = File( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.post("/model-optional-list-bytes-alias-and-validation-alias") +def read_model_optional_list_bytes_alias_and_validation_alias( + p: FormModelOptionalListBytesAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [len(file) for file in p.p] if p.p else None} + + +class FormModelOptionalListUploadFileAliasAndValidationAlias(BaseModel): + p: Optional[List[UploadFile]] = File( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.post("/model-optional-list-uploadfile-alias-and-validation-alias") +def read_model_optional_list_uploadfile_alias_and_validation_alias( + p: FormModelOptionalListUploadFileAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": [file.size for file in p.p] if p.p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias-and-validation-alias", + "/model-optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + "/model-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", + "/model-optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + "/model-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", + "/model-optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + "/model-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( + "/model-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), + ), + "/model-optional-list-uploadfile-alias-and-validation-alias", + ], +) +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", + "/model-optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + "/model-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 # /model-optional-list-*-alias-and-validation-alias fails here + ) + 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 index 0000000000..485916707a --- /dev/null +++ b/tests/test_request_params/test_file/test_required.py @@ -0,0 +1,756 @@ +import pytest +from dirty_equals import IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, File, Form, UploadFile +from fastapi._compat import PYDANTIC_V2 +from fastapi.testclient import TestClient +from pydantic import BaseModel + +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: bytes = File(...)): + return {"file_size": len(p)} + + +@app.post("/required-uploadfile", operation_id="required_uploadfile") +async def read_required_uploadfile(p: UploadFile = File(...)): + return {"file_size": p.size} + + +class FormModelRequiredBytes(BaseModel): + p: bytes = File(...) + + +@app.post("/model-required-bytes", operation_id="model_required_bytes") +async def read_model_required_bytes( + p: FormModelRequiredBytes = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p)} + + +class FormModelRequiredUploadFile(BaseModel): + p: UploadFile = File(...) + + +@app.post("/model-required-uploadfile", operation_id="model_required_uploadfile") +async def read_model_required_uploadfile( + p: FormModelRequiredUploadFile = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size} + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes", + "/model-required-bytes", + "/required-uploadfile", + "/model-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", + "/model-required-bytes", + "/required-uploadfile", + "/model-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": 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-bytes", + "/model-required-bytes", + "/required-uploadfile", + "/model-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: 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: UploadFile = File(..., alias="p_alias")): + return {"file_size": p.size} + + +class FormModelRequiredBytesAlias(BaseModel): + p: bytes = File(..., alias="p_alias") + + +@app.post("/model-required-bytes-alias", operation_id="model_required_bytes_alias") +async def read_model_required_bytes_alias( + p: FormModelRequiredBytesAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p)} + + +class FormModelRequiredUploadFileAlias(BaseModel): + p: UploadFile = File(..., alias="p_alias") + + +@app.post( + "/model-required-uploadfile-alias", operation_id="model_required_uploadfile_alias" +) +async def read_model_required_uploadfile_alias( + p: FormModelRequiredUploadFileAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size} + + +@pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, +) +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias", + "/model-required-bytes-alias", + "/required-uploadfile-alias", + "/model-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", + pytest.param( + "/model-required-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + "/required-uploadfile-alias", + pytest.param( + "/model-required-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + ], +) +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"], # model-required-*-alias fail here + "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-bytes-alias", + pytest.param( + "/model-required-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + "/required-uploadfile-alias", + pytest.param( + "/model-required-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + strict=False, + ), + ), + ], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 422 # model-required-upload-alias fail here + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf( # model-required-bytes-alias fail here + None, + {"p": IsPartialDict({"size": 5})}, + {"p": b"hello"}, # ToDo: check this + ), + } + ] + } + ) | 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", + pytest.param( + "/model-required-bytes-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + "/required-uploadfile-alias", + pytest.param( + "/model-required-uploadfile-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model", + ), + ), + ], +) +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, ( # model-required-*-alias fail here + 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: 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: UploadFile = File(..., validation_alias="p_val_alias"), +): + return {"file_size": p.size} + + +class FormModelRequiredBytesValidationAlias(BaseModel): + p: bytes = File(..., validation_alias="p_val_alias") + + +@app.post( + "/model-required-bytes-validation-alias", + operation_id="model_required_bytes_validation_alias", +) +def read_model_required_bytes_validation_alias( + p: FormModelRequiredBytesValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p)} # pragma: no cover + + +class FormModelRequiredUploadFileValidationAlias(BaseModel): + p: UploadFile = File(..., validation_alias="p_val_alias") + + +@app.post( + "/model-required-uploadfile-validation-alias", + operation_id="model_required_uploadfile_validation_alias", +) +def read_model_required_uploadfile_validation_alias( + p: FormModelRequiredUploadFileValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-validation-alias", + "/model-required-uploadfile-validation-alias", + "/required-uploadfile-validation-alias", + "/model-required-bytes-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), + ), + "/model-required-bytes-validation-alias", + pytest.param( + "/required-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-required-uploadfile-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": [ # /required-*-validation-alias fail here + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-required-uploadfile-validation-alias", + ], +) +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() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( # /model-required-bytes-validation-alias fails here + None, {"p": IsPartialDict({"size": 5})} + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-required-uploadfile-validation-alias", + ], +) +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 3 fail here + response.text + ) + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# 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: 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: UploadFile = File(..., alias="p_alias", validation_alias="p_val_alias"), +): + return {"file_size": p.size} + + +class FormModelRequiredBytesAliasAndValidationAlias(BaseModel): + p: bytes = File(..., alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-required-bytes-alias-and-validation-alias", + operation_id="model_required_bytes_alias_and_validation_alias", +) +def read_model_required_bytes_alias_and_validation_alias( + p: FormModelRequiredBytesAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": len(p.p)} # pragma: no cover + + +class FormModelRequiredUploadFileAliasAndValidationAlias(BaseModel): + p: UploadFile = File(..., alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-required-uploadfile-alias-and-validation-alias", + operation_id="model_required_uploadfile_alias_and_validation_alias", +) +def read_model_required_uploadfile_alias_and_validation_alias( + p: FormModelRequiredUploadFileAliasAndValidationAlias = Form( + media_type="multipart/form-data" # Remove media_type when https://github.com/fastapi/fastapi/pull/14343 is fixed + ), +): + return {"file_size": p.p.size} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias-and-validation-alias", + "/model-required-bytes-alias-and-validation-alias", + "/required-uploadfile-alias-and-validation-alias", + "/model-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), + ), + "/model-required-bytes-alias-and-validation-alias", + pytest.param( + "/required-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-required-uploadfile-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 fail here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-required-bytes-alias-and-validation-alias", + pytest.param( + "/required-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/model-required-uploadfile-alias-and-validation-alias", + ], +) +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": IsOneOf(None, {"p": IsPartialDict({"size": 5})}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-required-uploadfile-alias-and-validation-alias", + ], +) +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() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-required-uploadfile-alias-and-validation-alias fails here + {"p_alias": IsPartialDict({"size": 5})}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/model-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), + ), + "/model-required-uploadfile-alias-and-validation-alias", + ], +) +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 3 fail here + response.text + ) + assert response.json() == {"file_size": 5} diff --git a/tests/test_request_params/test_file/utils.py b/tests/test_request_params/test_file/utils.py new file mode 100644 index 0000000000..e33f64385f --- /dev/null +++ b/tests/test_request_params/test_file/utils.py @@ -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 index 0000000000..e69de29bb2 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 index 0000000000..14a4949136 --- /dev/null +++ b/tests/test_request_params/test_form/test_list.py @@ -0,0 +1,524 @@ +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 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: 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: 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: 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: 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: 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: 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: 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: 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 index 0000000000..5a7e844a75 --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_list.py @@ -0,0 +1,449 @@ +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 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: 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: 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: Optional[List[str]] = Form(None, alias="p_alias"), +): + 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: 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: Optional[List[str]] = Form(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[List[str]] = Form( + None, alias="p_alias", validation_alias="p_val_alias" + ), +): + 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: 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 index 0000000000..2d2d01282b --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_str.py @@ -0,0 +1,414 @@ +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 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: 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: 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: Optional[str] = Form(None, alias="p_alias")): + 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: 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: Optional[str] = Form(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[str] = Form(None, alias="p_alias", validation_alias="p_val_alias"), +): + 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: 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 index 0000000000..cff8a9bfd0 --- /dev/null +++ b/tests/test_request_params/test_form/test_required_str.py @@ -0,0 +1,501 @@ +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 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: 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: 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: 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: 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: 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: 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: 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: 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 index 0000000000..d200650dfd --- /dev/null +++ b/tests/test_request_params/test_form/utils.py @@ -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 index 0000000000..e69de29bb2 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 index 0000000000..82944f1cee --- /dev/null +++ b/tests/test_request_params/test_header/test_list.py @@ -0,0 +1,502 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-list-str") +async def read_required_list_str(p: 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: 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: 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: 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: 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: 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: 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: 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 index 0000000000..67c71c2c09 --- /dev/null +++ b/tests/test_request_params/test_header/test_optional_list.py @@ -0,0 +1,400 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-list-str") +async def read_optional_list_str(p: 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: 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: Optional[List[str]] = Header(None, alias="p_alias"), +): + 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: 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: Optional[List[str]] = Header(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[List[str]] = Header( + None, alias="p_alias", validation_alias="p_val_alias" + ), +): + 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: 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 index 0000000000..cd8daf55fb --- /dev/null +++ b/tests/test_request_params/test_header/test_optional_str.py @@ -0,0 +1,370 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-str") +async def read_optional_str(p: 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: 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: Optional[str] = Header(None, alias="p_alias")): + 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: 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: Optional[str] = Header(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[str] = Header(None, alias="p_alias", validation_alias="p_val_alias"), +): + 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: 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 index 0000000000..3f348b19b0 --- /dev/null +++ b/tests/test_request_params/test_header/test_required_str.py @@ -0,0 +1,491 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-str") +async def read_required_str(p: str = Header(...)): + return {"p": p} + + +class HeaderModelRequiredStr(BaseModel): + p: str + + +@app.get("/model-required-str") +async def read_model_required_str(p: 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: 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: 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: 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: 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: 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: 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 index 0000000000..e69de29bb2 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 index 0000000000..bba055d9a9 --- /dev/null +++ b/tests/test_request_params/test_path/test_list.py @@ -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 index 0000000000..0719430ac7 --- /dev/null +++ b/tests/test_request_params/test_path/test_optional_list.py @@ -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 index 0000000000..0719430ac7 --- /dev/null +++ b/tests/test_request_params/test_path/test_optional_str.py @@ -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 index 0000000000..54a74aea66 --- /dev/null +++ b/tests/test_request_params/test_path/test_required_str.py @@ -0,0 +1,101 @@ +import pytest +from fastapi import FastAPI, Path +from fastapi.testclient import TestClient + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + + +@app.get("/required-str/{p}") +async def read_required_str(p: str = Path(...)): + return {"p": p} + + +@app.get("/required-alias/{p_alias}") +async def read_required_alias(p: str = Path(..., alias="p_alias")): + return {"p": p} + + +@app.get("/required-validation-alias/{p_val_alias}") +def read_required_validation_alias( + p: 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: 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 index 0000000000..e69de29bb2 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 index 0000000000..840c1a5dc3 --- /dev/null +++ b/tests/test_request_params/test_query/test_list.py @@ -0,0 +1,503 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-list-str") +async def read_required_list_str(p: 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: 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: 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: 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: 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: 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: 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: 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 index 0000000000..f1350827f6 --- /dev/null +++ b/tests/test_request_params/test_query/test_optional_list.py @@ -0,0 +1,396 @@ +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 tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-list-str") +async def read_optional_list_str(p: 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: 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: Optional[List[str]] = Query(None, alias="p_alias"), +): + 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: 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: Optional[List[str]] = Query(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[List[str]] = Query( + None, alias="p_alias", validation_alias="p_val_alias" + ), +): + 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: 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 index 0000000000..71c424c6f0 --- /dev/null +++ b/tests/test_request_params/test_query/test_optional_str.py @@ -0,0 +1,370 @@ +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 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: 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: Optional[str] = Query(None, alias="p_alias")): + 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: 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: Optional[str] = Query(None, validation_alias="p_val_alias"), +): + 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: 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: Optional[str] = Query(None, alias="p_alias", validation_alias="p_val_alias"), +): + 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: 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 index 0000000000..d55bb950d9 --- /dev/null +++ b/tests/test_request_params/test_query/test_required_str.py @@ -0,0 +1,494 @@ +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 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: 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: 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: 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: 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: 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: 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: 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"}