From e0237dd2669c0fdec337b70df2689fa0765b2261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20S=C3=A1nchez?= Date: Mon, 9 Feb 2026 19:35:35 +0100 Subject: [PATCH 1/5] use use_annotated + test --- fastapi/dependencies/utils.py | 2 +- tests/test_json_form_syntaxes.py | 135 +++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/test_json_form_syntaxes.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 80f9c76e9..21efbed8a 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -445,7 +445,7 @@ def analyze_param( ) field_info = value if isinstance(field_info, FieldInfo): - field_info.annotation = type_annotation + field_info.annotation = use_annotation # Get Depends from type annotation if depends is not None and depends.dependency is None: diff --git a/tests/test_json_form_syntaxes.py b/tests/test_json_form_syntaxes.py new file mode 100644 index 000000000..e646136b6 --- /dev/null +++ b/tests/test_json_form_syntaxes.py @@ -0,0 +1,135 @@ +import json +from typing import Annotated, Any + +from fastapi import FastAPI, Form, Query +from fastapi.testclient import TestClient +from pydantic import Json + +app = FastAPI() + + +@app.post("/form-json-equals-syntax") +def form_json_equals_syntax(data: Json[dict[str, Any]] = Form()) -> dict[str, Any]: + return data + + +@app.post("/form-json-annotated-syntax") +def form_json_annotated_syntax( + data: Annotated[Json[dict[str, Any]], Form()], +) -> dict[str, Any]: + return data + + +@app.post("/form-json-list-equals") +def form_json_list_equals(items: Json[list[str]] = Form()) -> list[str]: + return items + + +@app.post("/form-json-list-annotated") +def form_json_list_annotated( + items: Annotated[Json[list[str]], Form()], +) -> list[str]: + return items + + +@app.post("/form-json-nested-equals") +def form_json_nested_equals( + config: Json[dict[str, list[int]]] = Form(), +) -> dict[str, list[int]]: + return config + + +@app.post("/form-json-nested-annotated") +def form_json_nested_annotated( + config: Annotated[Json[dict[str, list[int]]], Form()], +) -> dict[str, list[int]]: + return config + + +client = TestClient(app) + + +def test_form_json_equals_syntax(): + test_data = {"key": "value", "nested": {"inner": 123}} + response = client.post( + "/form-json-equals-syntax", data={"data": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_annotated_syntax(): + test_data = {"key": "value", "nested": {"inner": 123}} + response = client.post( + "/form-json-annotated-syntax", data={"data": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_list_equals(): + test_data = ["abc", "def", "ghi"] + response = client.post( + "/form-json-list-equals", data={"items": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_list_annotated(): + test_data = ["abc", "def", "ghi"] + response = client.post( + "/form-json-list-annotated", data={"items": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_nested_equals(): + test_data = {"groups": [1, 2, 3], "ids": [4, 5]} + response = client.post( + "/form-json-nested-equals", data={"config": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_nested_annotated(): + test_data = {"groups": [1, 2, 3], "ids": [4, 5]} + response = client.post( + "/form-json-nested-annotated", data={"config": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_invalid_json_equals(): + response = client.post( + "/form-json-equals-syntax", data={"data": "not valid json{"} + ) + assert response.status_code == 422 + + +def test_form_json_invalid_json_annotated(): + response = client.post( + "/form-json-annotated-syntax", data={"data": "not valid json{"} + ) + assert response.status_code == 422 + + +def test_form_json_empty_dict_equals(): + test_data = {} + response = client.post( + "/form-json-equals-syntax", data={"data": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +def test_form_json_empty_list_equals(): + test_data = [] + response = client.post( + "/form-json-list-equals", data={"items": json.dumps(test_data)} + ) + assert response.status_code == 200, response.text + assert response.json() == test_data From 2693081249e8d2a0b41b9b0d650edb5ba5004ec3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:38:09 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_json_form_syntaxes.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_json_form_syntaxes.py b/tests/test_json_form_syntaxes.py index e646136b6..98cb516ba 100644 --- a/tests/test_json_form_syntaxes.py +++ b/tests/test_json_form_syntaxes.py @@ -1,7 +1,7 @@ import json from typing import Annotated, Any -from fastapi import FastAPI, Form, Query +from fastapi import FastAPI, Form from fastapi.testclient import TestClient from pydantic import Json @@ -104,9 +104,7 @@ def test_form_json_nested_annotated(): def test_form_json_invalid_json_equals(): - response = client.post( - "/form-json-equals-syntax", data={"data": "not valid json{"} - ) + response = client.post("/form-json-equals-syntax", data={"data": "not valid json{"}) assert response.status_code == 422 From d29fac9e3fe3ceecf779ea5ce1ff69a0a5a4de38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20S=C3=A1nchez?= Date: Wed, 11 Feb 2026 01:15:07 +0100 Subject: [PATCH 3/5] tests from pr 13314 --- tests/test_union_body_discriminator_annotated.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_union_body_discriminator_annotated.py b/tests/test_union_body_discriminator_annotated.py index 6644d106c..462350be9 100644 --- a/tests/test_union_body_discriminator_annotated.py +++ b/tests/test_union_body_discriminator_annotated.py @@ -73,7 +73,7 @@ def test_openapi_schema(client: TestClient) -> None: "content": { "application/json": { "schema": { - "anyOf": [ + "oneOf": [ {"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}, ], From c5625da44a2d79b5cbb20021dc10789134eeed40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20S=C3=A1nchez?= Date: Wed, 11 Feb 2026 01:19:06 +0100 Subject: [PATCH 4/5] test annotated --- tests/test_annotated_validators.py | 185 +++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/test_annotated_validators.py diff --git a/tests/test_annotated_validators.py b/tests/test_annotated_validators.py new file mode 100644 index 000000000..703ae7e22 --- /dev/null +++ b/tests/test_annotated_validators.py @@ -0,0 +1,185 @@ +"""Test Pydantic validators in Annotated work correctly with FastAPI. + +This test ensures that Pydantic v2 validators like AfterValidator, BeforeValidator +work correctly in Annotated types with FastAPI parameters. + +Context: PR #13314 broke AfterValidator support and was reverted. +We need to ensure new changes preserve validator functionality. +""" + +from typing import Annotated + +from fastapi import FastAPI, Query, Body +from fastapi.testclient import TestClient +from pydantic import AfterValidator, BeforeValidator, ValidationError +import pytest + + +def validate_positive(v: int) -> int: + """Validator that ensures value is positive.""" + if v <= 0: + raise ValueError("must be positive") + return v + + +def double_value(v) -> int: + """Validator that doubles the input value.""" + # BeforeValidator receives the raw value before Pydantic coercion + # For query params, this is a string + v_int = int(v) if isinstance(v, str) else v + return v_int * 2 + + +def strip_whitespace(v: str) -> str: + """Validator that strips whitespace.""" + return v.strip() + + +app = FastAPI() + + +@app.get("/query-after-validator") +def query_after_validator( + value: Annotated[int, AfterValidator(validate_positive), Query()], +) -> int: + return value + + +@app.get("/query-before-validator") +def query_before_validator( + value: Annotated[int, BeforeValidator(double_value), Query()], +) -> int: + return value + + +@app.post("/body-after-validator") +def body_after_validator( + value: Annotated[int, AfterValidator(validate_positive), Body()], +) -> int: + return value + + +@app.post("/body-before-validator") +def body_before_validator( + name: Annotated[str, BeforeValidator(strip_whitespace), Body()], +) -> str: + return name + + +@app.get("/query-multiple-validators") +def query_multiple_validators( + value: Annotated[ + int, + BeforeValidator(double_value), + AfterValidator(validate_positive), + Query(), + ], +) -> int: + return value + + +client = TestClient(app) + + +def test_query_after_validator_valid(): + """Test AfterValidator in Query with valid value.""" + response = client.get("/query-after-validator?value=5") + assert response.status_code == 200, response.text + assert response.json() == 5 + + +def test_query_after_validator_invalid(): + """Test AfterValidator in Query with invalid value.""" + response = client.get("/query-after-validator?value=-1") + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "type": "value_error", + "loc": ["query", "value"], + "msg": "Value error, must be positive", + "input": "-1", + "ctx": {"error": {}}, + } + ] + } + + +def test_query_before_validator(): + """Test BeforeValidator in Query doubles the value.""" + response = client.get("/query-before-validator?value=5") + assert response.status_code == 200, response.text + assert response.json() == 10 # 5 * 2 + + +def test_body_after_validator_valid(): + """Test AfterValidator in Body with valid value.""" + # Body() without embed means send the value directly, not wrapped in an object + response = client.post("/body-after-validator", json=10) + assert response.status_code == 200, response.text + assert response.json() == 10 + + +def test_body_after_validator_invalid(): + """Test AfterValidator in Body with invalid value.""" + response = client.post("/body-after-validator", json=-5) + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, must be positive", + "input": -5, + "ctx": {"error": {}}, + } + ] + } + + +def test_body_before_validator(): + """Test BeforeValidator in Body strips whitespace.""" + response = client.post("/body-before-validator", json=" hello ") + assert response.status_code == 200, response.text + assert response.json() == "hello" + + +def test_query_multiple_validators(): + """Test multiple validators work in correct order.""" + # Input: 3 → BeforeValidator doubles to 6 → AfterValidator checks positive + response = client.get("/query-multiple-validators?value=3") + assert response.status_code == 200, response.text + assert response.json() == 6 + + # Input: -1 → BeforeValidator doubles to -2 → AfterValidator fails + response = client.get("/query-multiple-validators?value=-1") + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "type": "value_error", + "loc": ["query", "value"], + "msg": "Value error, must be positive", + "input": "-1", + "ctx": {"error": {}}, + } + ] + } + + +def test_query_multiple_validators_zero(): + """Test multiple validators with zero (edge case).""" + # Input: 0 → BeforeValidator doubles to 0 → AfterValidator fails (not positive) + response = client.get("/query-multiple-validators?value=0") + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "type": "value_error", + "loc": ["query", "value"], + "msg": "Value error, must be positive", + "input": "0", + "ctx": {"error": {}}, + } + ] + } From 7da6f25174e6c7de7a2c716981608b657c615a92 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:19:57 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_annotated_validators.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_annotated_validators.py b/tests/test_annotated_validators.py index 703ae7e22..b1f2ecba8 100644 --- a/tests/test_annotated_validators.py +++ b/tests/test_annotated_validators.py @@ -9,10 +9,9 @@ We need to ensure new changes preserve validator functionality. from typing import Annotated -from fastapi import FastAPI, Query, Body +from fastapi import Body, FastAPI, Query from fastapi.testclient import TestClient -from pydantic import AfterValidator, BeforeValidator, ValidationError -import pytest +from pydantic import AfterValidator, BeforeValidator def validate_positive(v: int) -> int: