Browse Source

Improve Json field detection and simplify tests

pull/14616/head
masaaya 7 months ago
parent
commit
ca3efa2b5f
  1. 11
      fastapi/dependencies/utils.py
  2. 315
      tests/test_form_json_type.py

11
fastapi/dependencies/utils.py

@ -716,15 +716,20 @@ def _validate_value_with_model_field(
return v_, [] return v_, []
def _is_pydantic_json_field(field: ModelField) -> bool: def _is_json_field(field: ModelField) -> bool:
return any(item.__class__.__name__ == 'Json' for item in field.field_info.metadata) # 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( def _get_multidict_value(
field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None
) -> Any: ) -> Any:
alias = alias or get_validation_alias(field) alias = alias or get_validation_alias(field)
if _is_pydantic_json_field(field): if _is_json_field(field):
value = values.get(alias, None) value = values.get(alias, None)
elif is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): elif is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
value = values.getlist(alias) value = values.getlist(alias)

315
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 import json
from pathlib import Path
from typing import Annotated from typing import Annotated
from dirty_equals import IsDict from fastapi import FastAPI, Form
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import BaseModel, Json from pydantic import BaseModel, Json
app = FastAPI() app = FastAPI()
# ============================================================================ class JsonListModel(BaseModel):
# Endpoints json_list: Json[list[str]]
# ============================================================================
@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): @app.post("/form-str")
"""Pydantic model with Json field for Form().""" def form_str(json_list: Annotated[str, Form()]) -> list[str]:
model = JsonListModel(json_list=json_list) # type: ignore[arg-type]
return model.json_list
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
@app.post("/form-json-list")
def form_json_list(json_list: Annotated[Json[list[str]], Form()]) -> list[str]:
return json_list
# ============================================================================
# Test Client
# ============================================================================
client = TestClient(app) client = TestClient(app)
# ============================================================================ def test_form_str():
# 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( response = client.post(
"/form-json-list", "/form-str",
data={"items": json.dumps(["abc", "def"])}, data={"json_list": json.dumps(["abc", "def"])},
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
result = response.json() assert response.json() == ["abc", "def"]
# Verify correct parsing
assert result == ["abc", "def"]
# Explicitly verify NOT the buggy wrapped version
assert result != [['["abc", "def"]']]
def test_form_json_dict(): def test_form_json_list():
"""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( response = client.post(
"/form-json-list", "/form-json-list",
data={"items": json.dumps([])}, data={"json_list": json.dumps(["abc", "def"])},
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert response.json() == [] assert response.json() == ["abc", "def"]
# 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}"
)

Loading…
Cancel
Save