From 125a52fefd5ef6cea68f8e19a7e42b3e592a76fb Mon Sep 17 00:00:00 2001 From: masaaya <156692516+mkanetsuna@users.noreply.github.com> Date: Sun, 28 Dec 2025 02:42:43 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Form()=20with=20Pydant?= =?UTF-8?q?ic=20Json[T]=20type=20(#10997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 8 +- tests/test_form_json_type.py | 325 ++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 tests/test_form_json_type.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 45e1ff3ed1..dc168455ae 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -716,11 +716,17 @@ def _validate_value_with_model_field( return v_, [] +def _is_pydantic_json_field(field: ModelField) -> bool: + return any(item.__class__.__name__ == 'Json' for item in field.field_info.metadata) + + def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None ) -> Any: alias = alias or get_validation_alias(field) - if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): + if _is_pydantic_json_field(field): + value = values.get(alias, None) + elif is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): value = values.getlist(alias) else: value = values.get(alias, None) diff --git a/tests/test_form_json_type.py b/tests/test_form_json_type.py new file mode 100644 index 0000000000..2dd94073c0 --- /dev/null +++ b/tests/test_form_json_type.py @@ -0,0 +1,325 @@ +""" +Test cases for Form() with Pydantic Json[T] type. +Regression tests for issue #10997. +https://github.com/fastapi/fastapi/issues/10997 + +Before the fix: Json[list[str]] with Form() would wrap values in extra list +After the fix: _is_pydantic_json_field() ensures correct single value extraction +""" + +import json +from pathlib import Path +from typing import Annotated + +from dirty_equals import IsDict +from fastapi import FastAPI, File, Form, UploadFile +from fastapi.testclient import TestClient +from pydantic import BaseModel, Json + +app = FastAPI() + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@app.post("/form-json-list") +def form_json_list(items: Annotated[Json[list[str]], Form()]) -> list[str]: + """Primary bug case - Json[list[str]] with Form().""" + return items + + +@app.post("/form-json-dict") +def form_json_dict(data: Annotated[Json[dict[str, str]], Form()]) -> dict[str, str]: + """Json[dict] parsing.""" + return data + + +@app.post("/form-json-optional") +def form_json_optional( + items: Annotated[Json[list[str]] | None, Form()] = None +) -> dict: + """Optional Json field.""" + return {"items": items, "received": items is not None} + + +@app.post("/form-json-with-regular-fields") +def form_json_with_regular_fields( + username: Annotated[str, Form()], + tags: Annotated[Json[list[str]], Form()], + age: Annotated[int, Form()], +) -> dict: + """Json field mixed with regular Form fields.""" + return {"username": username, "tags": tags, "age": age} + + +class FormWithJsonModel(BaseModel): + """Pydantic model with Json field for Form().""" + + username: str + tags: Json[list[str]] + + +@app.post("/form-json-model") +def form_json_model(form: Annotated[FormWithJsonModel, Form()]) -> FormWithJsonModel: + """Json field in Pydantic model with Form().""" + return form + + +@app.post("/form-json-with-file") +def form_json_with_file( + tags: Annotated[Json[list[str]], Form()], + file: UploadFile = File(...), +) -> dict: + """Json field with File() parameter.""" + return {"tags": tags, "filename": file.filename} + + +@app.post("/form-regular-list") +def form_regular_list(items: Annotated[list[str], Form()]) -> list[str]: + """Regular list Form field (without Json wrapper) - must still work.""" + return items + + +# ============================================================================ +# Test Client +# ============================================================================ + +client = TestClient(app) + + +# ============================================================================ +# Core Functionality Tests +# ============================================================================ + + +def test_form_json_list_str(): + """Test Form() + Json[list[str]] - primary bug case for issue #10997. + + Before the fix, this would incorrectly wrap the JSON string in a list: + Input: '["abc", "def"]' + Bug: [['["abc", "def"]']] (wrapped in extra list) + Fix: ["abc", "def"] (correctly parsed) + """ + response = client.post( + "/form-json-list", + data={"items": json.dumps(["abc", "def"])}, + ) + assert response.status_code == 200, response.text + result = response.json() + + # Verify correct parsing + assert result == ["abc", "def"] + + # Explicitly verify NOT the buggy wrapped version + assert result != [['["abc", "def"]']] + + +def test_form_json_dict(): + """Test Form() + Json[dict] - dictionary parsing.""" + test_data = {"name": "John", "city": "NYC"} + response = client.post( + "/form-json-dict", + data={"data": json.dumps(test_data)}, + ) + assert response.status_code == 200, response.text + assert response.json() == test_data + + +# ============================================================================ +# Edge Cases Tests +# ============================================================================ + + +def test_form_json_optional_with_none(): + """Test optional Json field with no value sent.""" + response = client.post("/form-json-optional", data={}) + assert response.status_code == 200, response.text + result = response.json() + assert result["items"] is None + assert result["received"] is False + + +def test_form_json_optional_with_value(): + """Test optional Json field with value provided.""" + response = client.post( + "/form-json-optional", + data={"items": json.dumps(["test"])}, + ) + assert response.status_code == 200, response.text + result = response.json() + assert result["items"] == ["test"] + assert result["received"] is True + + +def test_form_json_empty_values(): + """Test Json fields with empty array and object.""" + # Empty list + response = client.post( + "/form-json-list", + data={"items": json.dumps([])}, + ) + assert response.status_code == 200, response.text + assert response.json() == [] + + # Empty dict + response = client.post( + "/form-json-dict", + data={"data": json.dumps({})}, + ) + assert response.status_code == 200, response.text + assert response.json() == {} + + +def test_form_json_with_regular_fields(): + """Test Json field mixed with regular Form fields.""" + response = client.post( + "/form-json-with-regular-fields", + data={ + "username": "alice", + "tags": json.dumps(["python", "fastapi"]), + "age": "30", + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "username": "alice", + "tags": ["python", "fastapi"], + "age": 30, + } + + +# ============================================================================ +# Error Handling Tests +# ============================================================================ + + +def test_form_json_missing_required(): + """Test missing required Json field returns validation error.""" + response = client.post("/form-json-list", data={}) + assert response.status_code == 422, response.text + error_detail = response.json() + # Note: input can be None or {} depending on form data processing + assert error_detail == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "items"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "items"], + "msg": "Field required", + "input": {}, + } + ] + } + ) | IsDict( + # Pydantic v1 compatibility + { + "detail": [ + { + "loc": ["body", "items"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +# ============================================================================ +# Integration Tests +# ============================================================================ + + +def test_form_json_with_pydantic_model(): + """Test Json field in Pydantic model with Form().""" + response = client.post( + "/form-json-model", + data={ + "username": "alice", + "tags": json.dumps(["python", "fastapi"]), + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "username": "alice", + "tags": ["python", "fastapi"], + } + + +def test_form_json_with_file_upload(tmp_path: Path): + """Test Json field with File() parameter in multipart form.""" + temp_file = tmp_path / "test.txt" + temp_file.write_text("test content") + + response = client.post( + "/form-json-with-file", + data={"tags": json.dumps(["important", "urgent"])}, + files={"file": ("test.txt", temp_file.read_bytes())}, + ) + assert response.status_code == 200, response.text + result = response.json() + assert result["tags"] == ["important", "urgent"] + assert result["filename"] == "test.txt" + + +def test_form_json_doesnt_break_regular_sequences(): + """Test that regular list Form fields still work correctly. + + This is critical: the fix for Json[T] must not break existing + Form list behavior. Regular list fields should still use getlist() + for multiple form values. + """ + response = client.post( + "/form-regular-list", + data={"items": ["x", "y", "z"]}, + ) + assert response.status_code == 200, response.text + assert response.json() == ["x", "y", "z"] + + +# ============================================================================ +# OpenAPI Schema Test +# ============================================================================ + + +def test_openapi_schema_json_field(): + """Test that OpenAPI schema correctly represents Json fields as string type. + + In OpenAPI, Json[T] form fields should be represented as string type + (not the inner type like array or object), because the form data contains + a JSON string that will be parsed by Pydantic, not the parsed structure. + """ + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + schema = response.json() + paths = schema["paths"] + + # Check /form-json-list endpoint + endpoint_schema = paths["/form-json-list"]["post"] + assert "application/x-www-form-urlencoded" in endpoint_schema["requestBody"]["content"] + + # Get body schema + body_schema_ref = endpoint_schema["requestBody"]["content"][ + "application/x-www-form-urlencoded" + ]["schema"]["$ref"] + schema_name = body_schema_ref.split("/")[-1] + body_schema = schema["components"]["schemas"][schema_name] + + # Verify Json[list[str]] is represented as string in OpenAPI + items_field = body_schema["properties"]["items"] + assert items_field["type"] == "string", ( + f"Json[list[str]] should be 'string' type in OpenAPI, got {items_field}" + ) From ca3efa2b5f8cee60c30758265c6362e318948a7c Mon Sep 17 00:00:00 2001 From: masaaya <156692516+mkanetsuna@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:13:52 +0900 Subject: [PATCH 2/8] Improve Json field detection and simplify tests --- fastapi/dependencies/utils.py | 11 +- tests/test_form_json_type.py | 323 +++------------------------------- 2 files changed, 29 insertions(+), 305 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dc168455ae..095a4ec12b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -716,15 +716,20 @@ def _validate_value_with_model_field( return v_, [] -def _is_pydantic_json_field(field: ModelField) -> bool: - return any(item.__class__.__name__ == 'Json' for item in field.field_info.metadata) +def _is_json_field(field: ModelField) -> bool: + # Pydantic v1 FieldInfo doesn't have metadata attribute + metadata = getattr(field.field_info, "metadata", []) + return any( + type(item).__name__ == "Json" and type(item).__module__ == "pydantic.types" + for item in metadata + ) def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None ) -> Any: alias = alias or get_validation_alias(field) - if _is_pydantic_json_field(field): + if _is_json_field(field): value = values.get(alias, None) elif is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): value = values.getlist(alias) diff --git a/tests/test_form_json_type.py b/tests/test_form_json_type.py index 2dd94073c0..14f549342d 100644 --- a/tests/test_form_json_type.py +++ b/tests/test_form_json_type.py @@ -1,325 +1,44 @@ -""" -Test cases for Form() with Pydantic Json[T] type. -Regression tests for issue #10997. -https://github.com/fastapi/fastapi/issues/10997 - -Before the fix: Json[list[str]] with Form() would wrap values in extra list -After the fix: _is_pydantic_json_field() ensures correct single value extraction -""" - import json -from pathlib import Path from typing import Annotated -from dirty_equals import IsDict -from fastapi import FastAPI, File, Form, UploadFile +from fastapi import FastAPI, Form from fastapi.testclient import TestClient from pydantic import BaseModel, Json app = FastAPI() -# ============================================================================ -# Endpoints -# ============================================================================ +class JsonListModel(BaseModel): + json_list: Json[list[str]] + + +@app.post("/form-str") +def form_str(json_list: Annotated[str, Form()]) -> list[str]: + model = JsonListModel(json_list=json_list) # type: ignore[arg-type] + return model.json_list @app.post("/form-json-list") -def form_json_list(items: Annotated[Json[list[str]], Form()]) -> list[str]: - """Primary bug case - Json[list[str]] with Form().""" - return items +def form_json_list(json_list: Annotated[Json[list[str]], Form()]) -> list[str]: + return json_list -@app.post("/form-json-dict") -def form_json_dict(data: Annotated[Json[dict[str, str]], Form()]) -> dict[str, str]: - """Json[dict] parsing.""" - return data - - -@app.post("/form-json-optional") -def form_json_optional( - items: Annotated[Json[list[str]] | None, Form()] = None -) -> dict: - """Optional Json field.""" - return {"items": items, "received": items is not None} - - -@app.post("/form-json-with-regular-fields") -def form_json_with_regular_fields( - username: Annotated[str, Form()], - tags: Annotated[Json[list[str]], Form()], - age: Annotated[int, Form()], -) -> dict: - """Json field mixed with regular Form fields.""" - return {"username": username, "tags": tags, "age": age} - - -class FormWithJsonModel(BaseModel): - """Pydantic model with Json field for Form().""" - - username: str - tags: Json[list[str]] - - -@app.post("/form-json-model") -def form_json_model(form: Annotated[FormWithJsonModel, Form()]) -> FormWithJsonModel: - """Json field in Pydantic model with Form().""" - return form - - -@app.post("/form-json-with-file") -def form_json_with_file( - tags: Annotated[Json[list[str]], Form()], - file: UploadFile = File(...), -) -> dict: - """Json field with File() parameter.""" - return {"tags": tags, "filename": file.filename} - - -@app.post("/form-regular-list") -def form_regular_list(items: Annotated[list[str], Form()]) -> list[str]: - """Regular list Form field (without Json wrapper) - must still work.""" - return items - - -# ============================================================================ -# Test Client -# ============================================================================ - client = TestClient(app) -# ============================================================================ -# Core Functionality Tests -# ============================================================================ +def test_form_str(): + response = client.post( + "/form-str", + data={"json_list": json.dumps(["abc", "def"])}, + ) + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] -def test_form_json_list_str(): - """Test Form() + Json[list[str]] - primary bug case for issue #10997. - - Before the fix, this would incorrectly wrap the JSON string in a list: - Input: '["abc", "def"]' - Bug: [['["abc", "def"]']] (wrapped in extra list) - Fix: ["abc", "def"] (correctly parsed) - """ +def test_form_json_list(): response = client.post( "/form-json-list", - data={"items": json.dumps(["abc", "def"])}, + data={"json_list": json.dumps(["abc", "def"])}, ) assert response.status_code == 200, response.text - result = response.json() - - # Verify correct parsing - assert result == ["abc", "def"] - - # Explicitly verify NOT the buggy wrapped version - assert result != [['["abc", "def"]']] - - -def test_form_json_dict(): - """Test Form() + Json[dict] - dictionary parsing.""" - test_data = {"name": "John", "city": "NYC"} - response = client.post( - "/form-json-dict", - data={"data": json.dumps(test_data)}, - ) - assert response.status_code == 200, response.text - assert response.json() == test_data - - -# ============================================================================ -# Edge Cases Tests -# ============================================================================ - - -def test_form_json_optional_with_none(): - """Test optional Json field with no value sent.""" - response = client.post("/form-json-optional", data={}) - assert response.status_code == 200, response.text - result = response.json() - assert result["items"] is None - assert result["received"] is False - - -def test_form_json_optional_with_value(): - """Test optional Json field with value provided.""" - response = client.post( - "/form-json-optional", - data={"items": json.dumps(["test"])}, - ) - assert response.status_code == 200, response.text - result = response.json() - assert result["items"] == ["test"] - assert result["received"] is True - - -def test_form_json_empty_values(): - """Test Json fields with empty array and object.""" - # Empty list - response = client.post( - "/form-json-list", - data={"items": json.dumps([])}, - ) - assert response.status_code == 200, response.text - assert response.json() == [] - - # Empty dict - response = client.post( - "/form-json-dict", - data={"data": json.dumps({})}, - ) - assert response.status_code == 200, response.text - assert response.json() == {} - - -def test_form_json_with_regular_fields(): - """Test Json field mixed with regular Form fields.""" - response = client.post( - "/form-json-with-regular-fields", - data={ - "username": "alice", - "tags": json.dumps(["python", "fastapi"]), - "age": "30", - }, - ) - assert response.status_code == 200, response.text - assert response.json() == { - "username": "alice", - "tags": ["python", "fastapi"], - "age": 30, - } - - -# ============================================================================ -# Error Handling Tests -# ============================================================================ - - -def test_form_json_missing_required(): - """Test missing required Json field returns validation error.""" - response = client.post("/form-json-list", data={}) - assert response.status_code == 422, response.text - error_detail = response.json() - # Note: input can be None or {} depending on form data processing - assert error_detail == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "items"], - "msg": "Field required", - "input": None, - } - ] - } - ) | IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "items"], - "msg": "Field required", - "input": {}, - } - ] - } - ) | IsDict( - # Pydantic v1 compatibility - { - "detail": [ - { - "loc": ["body", "items"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -# ============================================================================ -# Integration Tests -# ============================================================================ - - -def test_form_json_with_pydantic_model(): - """Test Json field in Pydantic model with Form().""" - response = client.post( - "/form-json-model", - data={ - "username": "alice", - "tags": json.dumps(["python", "fastapi"]), - }, - ) - assert response.status_code == 200, response.text - assert response.json() == { - "username": "alice", - "tags": ["python", "fastapi"], - } - - -def test_form_json_with_file_upload(tmp_path: Path): - """Test Json field with File() parameter in multipart form.""" - temp_file = tmp_path / "test.txt" - temp_file.write_text("test content") - - response = client.post( - "/form-json-with-file", - data={"tags": json.dumps(["important", "urgent"])}, - files={"file": ("test.txt", temp_file.read_bytes())}, - ) - assert response.status_code == 200, response.text - result = response.json() - assert result["tags"] == ["important", "urgent"] - assert result["filename"] == "test.txt" - - -def test_form_json_doesnt_break_regular_sequences(): - """Test that regular list Form fields still work correctly. - - This is critical: the fix for Json[T] must not break existing - Form list behavior. Regular list fields should still use getlist() - for multiple form values. - """ - response = client.post( - "/form-regular-list", - data={"items": ["x", "y", "z"]}, - ) - assert response.status_code == 200, response.text - assert response.json() == ["x", "y", "z"] - - -# ============================================================================ -# OpenAPI Schema Test -# ============================================================================ - - -def test_openapi_schema_json_field(): - """Test that OpenAPI schema correctly represents Json fields as string type. - - In OpenAPI, Json[T] form fields should be represented as string type - (not the inner type like array or object), because the form data contains - a JSON string that will be parsed by Pydantic, not the parsed structure. - """ - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - - schema = response.json() - paths = schema["paths"] - - # Check /form-json-list endpoint - endpoint_schema = paths["/form-json-list"]["post"] - assert "application/x-www-form-urlencoded" in endpoint_schema["requestBody"]["content"] - - # Get body schema - body_schema_ref = endpoint_schema["requestBody"]["content"][ - "application/x-www-form-urlencoded" - ]["schema"]["$ref"] - schema_name = body_schema_ref.split("/")[-1] - body_schema = schema["components"]["schemas"][schema_name] - - # Verify Json[list[str]] is represented as string in OpenAPI - items_field = body_schema["properties"]["items"] - assert items_field["type"] == "string", ( - f"Json[list[str]] should be 'string' type in OpenAPI, got {items_field}" - ) + assert response.json() == ["abc", "def"] From e69d72e320593cb1562826e53650c00a7e830673 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:25:49 +0000 Subject: [PATCH 3/8] =?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_form_json_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_form_json_type.py b/tests/test_form_json_type.py index 14f549342d..989ea005a8 100644 --- a/tests/test_form_json_type.py +++ b/tests/test_form_json_type.py @@ -14,7 +14,7 @@ class JsonListModel(BaseModel): @app.post("/form-str") def form_str(json_list: Annotated[str, Form()]) -> list[str]: - model = JsonListModel(json_list=json_list) # type: ignore[arg-type] + model = JsonListModel(json_list=json_list) # type: ignore[arg-type] return model.json_list From 01e7a617a109f98c44faebff2d94dd56257294f6 Mon Sep 17 00:00:00 2001 From: Kanetsuna Masaya <156692516+mkanetsuna@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:29:13 +0900 Subject: [PATCH 4/8] Update fastapi/dependencies/utils.py Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- fastapi/dependencies/utils.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 095a4ec12b..6dbbf6134a 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -717,12 +717,7 @@ def _validate_value_with_model_field( def _is_json_field(field: ModelField) -> bool: - # Pydantic v1 FieldInfo doesn't have metadata attribute - metadata = getattr(field.field_info, "metadata", []) - return any( - type(item).__name__ == "Json" and type(item).__module__ == "pydantic.types" - for item in metadata - ) + return any(isinstance(item, Json) for item in field.field_info.metadata) def _get_multidict_value( From 29c5e02945bbb20e35c238e394b804e16b485ccc Mon Sep 17 00:00:00 2001 From: masaaya <156692516+mkanetsuna@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:47:43 +0900 Subject: [PATCH 5/8] refactor: Simplify Json field detection --- fastapi/dependencies/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 6dbbf6134a..f086ed43f5 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -51,7 +51,7 @@ from fastapi.logger import logger from fastapi.security.oauth2 import SecurityScopes from fastapi.types import DependencyCacheKey from fastapi.utils import create_model_field, get_path_param_names -from pydantic import BaseModel +from pydantic import BaseModel, Json from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool @@ -717,16 +717,18 @@ def _validate_value_with_model_field( def _is_json_field(field: ModelField) -> bool: - return any(isinstance(item, Json) for item in field.field_info.metadata) + return any(type(item) is Json for item in field.field_info.metadata) def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None ) -> Any: alias = alias or get_validation_alias(field) - if _is_json_field(field): - value = values.get(alias, None) - elif is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): + if ( + (not _is_json_field(field)) + and is_sequence_field(field) + and isinstance(values, (ImmutableMultiDict, Headers)) + ): value = values.getlist(alias) else: value = values.get(alias, None) From cb8cb442b9a6f7b4861f870f09ed004135be48e1 Mon Sep 17 00:00:00 2001 From: masaaya <156692516+mkanetsuna@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:44:25 +0900 Subject: [PATCH 6/8] Add comprehensive tests for Json[T] with Query/Header/Cookie --- tests/test_form_json_type.py | 70 +++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/tests/test_form_json_type.py b/tests/test_form_json_type.py index 989ea005a8..34d5a4d27a 100644 --- a/tests/test_form_json_type.py +++ b/tests/test_form_json_type.py @@ -1,44 +1,64 @@ import json from typing import Annotated -from fastapi import FastAPI, Form +from fastapi import Cookie, FastAPI, Form, Header, Query from fastapi.testclient import TestClient -from pydantic import BaseModel, Json +from pydantic import Json app = FastAPI() -class JsonListModel(BaseModel): - json_list: Json[list[str]] - - -@app.post("/form-str") -def form_str(json_list: Annotated[str, Form()]) -> list[str]: - model = JsonListModel(json_list=json_list) # type: ignore[arg-type] - return model.json_list - - @app.post("/form-json-list") -def form_json_list(json_list: Annotated[Json[list[str]], Form()]) -> list[str]: - return json_list +def form_json_list(items: Annotated[Json[list[str]], Form()]) -> list[str]: + return items + + +@app.get("/query-json-list") +def query_json_list(items: Annotated[Json[list[str]], Query()]) -> list[str]: + return items + + +@app.get("/header-json-list") +def header_json_list(x_items: Annotated[Json[list[str]], Header()]) -> list[str]: + return x_items + + +@app.get("/cookie-json-dict") +def cookie_json_dict( + session: Annotated[Json[dict[str, str]], Cookie()], +) -> dict[str, str]: + return session client = TestClient(app) -def test_form_str(): - response = client.post( - "/form-str", - data={"json_list": json.dumps(["abc", "def"])}, - ) - assert response.status_code == 200, response.text - assert response.json() == ["abc", "def"] - - def test_form_json_list(): response = client.post( - "/form-json-list", - data={"json_list": json.dumps(["abc", "def"])}, + "/form-json-list", data={"items": json.dumps(["abc", "def"])} ) assert response.status_code == 200, response.text assert response.json() == ["abc", "def"] + + +def test_query_json_list(): + response = client.get( + "/query-json-list", params={"items": json.dumps(["abc", "def"])} + ) + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + + +def test_header_json_list(): + response = client.get( + "/header-json-list", headers={"x-items": json.dumps(["abc", "def"])} + ) + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + + +def test_cookie_json_dict(): + client.cookies.set("session", json.dumps({"user": "test1", "role": "admin"})) + response = client.get("/cookie-json-dict") + assert response.status_code == 200, response.text + assert response.json() == {"user": "test1", "role": "admin"} From 690f8d23c902fe30e6e75a451a8791bd54f0c30b Mon Sep 17 00:00:00 2001 From: masaaya <156692516+mkanetsuna@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:48:52 +0900 Subject: [PATCH 7/8] Remove Json[list[str]] test for Cookie --- tests/test_form_json_type.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/test_form_json_type.py b/tests/test_form_json_type.py index 34d5a4d27a..cbeb57f063 100644 --- a/tests/test_form_json_type.py +++ b/tests/test_form_json_type.py @@ -1,7 +1,7 @@ import json from typing import Annotated -from fastapi import Cookie, FastAPI, Form, Header, Query +from fastapi import FastAPI, Form, Header, Query from fastapi.testclient import TestClient from pydantic import Json @@ -23,13 +23,6 @@ def header_json_list(x_items: Annotated[Json[list[str]], Header()]) -> list[str] return x_items -@app.get("/cookie-json-dict") -def cookie_json_dict( - session: Annotated[Json[dict[str, str]], Cookie()], -) -> dict[str, str]: - return session - - client = TestClient(app) @@ -55,10 +48,3 @@ def test_header_json_list(): ) assert response.status_code == 200, response.text assert response.json() == ["abc", "def"] - - -def test_cookie_json_dict(): - client.cookies.set("session", json.dumps({"user": "test1", "role": "admin"})) - response = client.get("/cookie-json-dict") - assert response.status_code == 200, response.text - assert response.json() == {"user": "test1", "role": "admin"} From 9f713cab98c1f0691fc456b8f2bda58a5af5d89a Mon Sep 17 00:00:00 2001 From: masaaya <156692516+mkanetsuna@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:39:04 +0900 Subject: [PATCH 8/8] Add Cookie test and rename test file --- .../{test_form_json_type.py => test_json_type.py} | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) rename tests/{test_form_json_type.py => test_json_type.py} (73%) diff --git a/tests/test_form_json_type.py b/tests/test_json_type.py similarity index 73% rename from tests/test_form_json_type.py rename to tests/test_json_type.py index cbeb57f063..3e213eaca4 100644 --- a/tests/test_form_json_type.py +++ b/tests/test_json_type.py @@ -1,7 +1,7 @@ import json from typing import Annotated -from fastapi import FastAPI, Form, Header, Query +from fastapi import Cookie, FastAPI, Form, Header, Query from fastapi.testclient import TestClient from pydantic import Json @@ -23,6 +23,11 @@ def header_json_list(x_items: Annotated[Json[list[str]], Header()]) -> list[str] return x_items +@app.get("/cookie-json-list") +def cookie_json_list(items: Annotated[Json[list[str]], Cookie()]) -> list[str]: + return items + + client = TestClient(app) @@ -48,3 +53,11 @@ def test_header_json_list(): ) assert response.status_code == 200, response.text assert response.json() == ["abc", "def"] + + +def test_cookie_json_list(): + client.cookies.set("items", json.dumps(["abc", "def"])) + response = client.get("/cookie-json-list") + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + client.cookies.clear()