Browse Source

️ Use `TypeAdapter.validate_json` instead of `json.loads`

pull/15617/head
Martynov Maxim 1 week ago
parent
commit
dd1189a0c4
No known key found for this signature in database GPG Key ID: 9C23E39F5BBC88CC
  1. 8
      docs/de/docs/tutorial/handling-errors.md
  2. 8
      docs/en/docs/tutorial/handling-errors.md
  3. 8
      docs/es/docs/tutorial/handling-errors.md
  4. 8
      docs/fr/docs/tutorial/handling-errors.md
  5. 8
      docs/ja/docs/tutorial/handling-errors.md
  6. 8
      docs/ko/docs/tutorial/handling-errors.md
  7. 8
      docs/pt/docs/tutorial/handling-errors.md
  8. 8
      docs/ru/docs/tutorial/handling-errors.md
  9. 8
      docs/tr/docs/tutorial/handling-errors.md
  10. 8
      docs/uk/docs/tutorial/handling-errors.md
  11. 8
      docs/zh-hant/docs/tutorial/handling-errors.md
  12. 8
      docs/zh/docs/tutorial/handling-errors.md
  13. 15
      fastapi/_compat/v2.py
  14. 81
      fastapi/dependencies/utils.py
  15. 65
      fastapi/routing.py
  16. 11
      tests/test_tutorial/test_body/test_tutorial001.py
  17. 24
      tests/test_tutorial/test_body_multiple_params/test_tutorial002.py
  18. 2
      tests/test_tutorial/test_body_nested_models/test_tutorial006.py
  19. 2
      tests/test_tutorial/test_body_nested_models/test_tutorial008.py
  20. 2
      tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py
  21. 7
      tests/test_tutorial/test_handling_errors/test_tutorial005.py

8
docs/de/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Sie erhalten eine Response, die Ihnen sagt, dass die Daten ungültig sind und di
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/en/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ You will receive a response telling you that the data is invalid containing the
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/es/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Recibirás un response que te dirá que los datos son inválidos conteniendo el
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/fr/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Vous recevrez une réponse vous indiquant que les données sont invalides et con
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/ja/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to pa
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/ko/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to pa
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/pt/docs/tutorial/handling-errors.md

@ -203,13 +203,11 @@ Você receberá uma *response* informando-o de que os dados são inválidos, e c
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/ru/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to pa
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/tr/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Aldığınız body’yi de içeren, verinin geçersiz olduğunu söyleyen bir re
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/uk/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to pa
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/zh-hant/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to pa
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

8
docs/zh/docs/tutorial/handling-errors.md

@ -205,13 +205,11 @@ Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to pa
"size"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
"type": "type_error.integer",
"input": "XL"
}
],
"body": {
"title": "towel",
"size": "XL"
}
"body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
}
```

15
fastapi/_compat/v2.py

@ -187,6 +187,21 @@ class ModelField:
errors=exc.errors(include_url=False), loc_prefix=loc
)
def validate_json(
self,
data: bytes,
loc: tuple[str, ...] = (),
) -> tuple[Any, list[dict[str, Any]]]:
try:
return (
self._type_adapter.validate_json(data),
[],
)
except ValidationError as exc:
return None, _regenerate_error_with_loc(
errors=exc.errors(include_url=False), loc_prefix=loc
)
def serialize(
self,
value: Any,

81
fastapi/dependencies/utils.py

@ -1,5 +1,6 @@
import dataclasses
import inspect
import json
import sys
from collections.abc import (
AsyncGenerator,
@ -599,7 +600,8 @@ async def solve_dependencies(
*,
request: Request | WebSocket,
dependant: Dependant,
body: dict[str, Any] | FormData | bytes | None = None,
body: bytes | FormData | None = None,
is_body_json: bool = False,
background_tasks: StarletteBackgroundTasks | None = None,
response: Response | None = None,
dependency_overrides_provider: Any | None = None,
@ -650,6 +652,7 @@ async def solve_dependencies(
request=request,
dependant=use_sub_dependant,
body=body,
is_body_json=is_body_json,
background_tasks=background_tasks,
response=response,
dependency_overrides_provider=dependency_overrides_provider,
@ -706,6 +709,7 @@ async def solve_dependencies(
) = await request_body_to_args( # body_params checked above
body_fields=dependant.body_params,
received_body=body,
is_body_json=is_body_json,
embed_body_fields=embed_body_fields,
)
values.update(body_values)
@ -735,15 +739,30 @@ async def solve_dependencies(
)
def _validate_json_body_as_model_field(
*, field: ModelField, value: bytes, loc: tuple[str, ...]
) -> tuple[Any, list[Any]]:
result, errors = field.validate_json(value, loc=loc)
for i, error in enumerate(errors):
# Pydantic model.validate_json returns raw body data, bytes with json content
# Replace with parsed JSON data
if "input" in error and isinstance(error["input"], bytes):
try:
errors[i]["input"] = json.loads(error["input"])
except ValueError:
pass
return result, errors
def _validate_value_with_model_field(
*, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...]
*, field: ModelField, value: Any, loc: tuple[str, ...]
) -> tuple[Any, list[Any]]:
if value is None:
if field.field_info.is_required():
return None, [get_missing_field_error(loc=loc)]
else:
return deepcopy(field.default), []
return field.validate(value, values, loc=loc)
return field.validate(value, loc=loc)
def _is_json_field(field: ModelField) -> bool:
@ -849,7 +868,7 @@ def request_params_to_args(
)
loc: tuple[str, ...] = (field_info.in_.value,)
v_, errors_ = _validate_value_with_model_field(
field=first_field, value=params_to_process, values=values, loc=loc
field=first_field, value=params_to_process, loc=loc
)
return {first_field.name: v_}, errors_
@ -861,7 +880,7 @@ def request_params_to_args(
)
loc = (field_info.in_.value, get_validation_alias(field))
v_, errors_ = _validate_value_with_model_field(
field=field, value=value, values=values, loc=loc
field=field, value=value, loc=loc
)
if errors_:
errors.extend(errors_)
@ -954,7 +973,8 @@ async def _extract_form_body(
async def request_body_to_args(
body_fields: list[ModelField],
received_body: dict[str, Any] | FormData | bytes | None,
received_body: bytes | FormData | None,
is_body_json: bool,
embed_body_fields: bool,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
values: dict[str, Any] = {}
@ -962,7 +982,7 @@ async def request_body_to_args(
assert body_fields, "request_body_to_args() should be called with fields"
single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields
first_field = body_fields[0]
body_to_process = received_body
body_to_process: bytes | FormData | dict[str, Any] | None = received_body
fields_to_extract: list[ModelField] = body_fields
@ -976,12 +996,25 @@ async def request_body_to_args(
if isinstance(received_body, FormData):
body_to_process = await _extract_form_body(fields_to_extract, received_body)
loc: tuple[str, ...] = ("body",)
if single_not_embedded_field:
loc: tuple[str, ...] = ("body",)
v_, errors_ = _validate_value_with_model_field(
field=first_field, value=body_to_process, values=values, loc=loc
)
if is_body_json:
v_, errors_ = _validate_json_body_as_model_field(
field=first_field, value=cast("bytes", received_body), loc=loc
)
else:
v_, errors_ = _validate_value_with_model_field(
field=first_field, value=body_to_process, loc=loc
)
return {first_field.name: v_}, errors_
if is_body_json:
# for multiple fields there is no one TypeAdapter,
# fallback to parsing body with json module
body_to_process, errors = parse_json(cast("bytes", received_body), loc=loc)
if errors:
return values, errors
for field in body_fields:
loc = ("body", get_validation_alias(field))
value: Any | None = None
@ -993,7 +1026,7 @@ async def request_body_to_args(
errors.append(get_missing_field_error(loc))
continue
v_, errors_ = _validate_value_with_model_field(
field=field, value=value, values=values, loc=loc
field=field, value=value, loc=loc
)
if errors_:
errors.extend(errors_)
@ -1059,3 +1092,27 @@ def get_body_field(
def get_validation_alias(field: ModelField) -> str:
va = getattr(field, "validation_alias", None)
return va or field.alias
def parse_json(
data: bytes,
loc: tuple[str, ...],
) -> tuple[Any, list[dict[str, Any]]]:
try:
return json.loads(data), []
except json.JSONDecodeError as e:
# Pydantic JSON parser returns different error message and context,
# but other fields are the same
return None, [
{
"type": "json_invalid",
"loc": loc,
"input": data,
"msg": f"Invalid JSON: {e.msg}",
"ctx": {
"pos": e.pos,
"lineno": e.lineno,
"colno": e.colno,
},
}
]

65
fastapi/routing.py

@ -35,7 +35,6 @@ from anyio.abc import ObjectReceiveStream
from fastapi import params
from fastapi._compat import (
ModelField,
Undefined,
lenient_issubclass,
)
from fastapi.datastructures import Default, DefaultPlaceholder
@ -290,7 +289,7 @@ async def serialize_response(
) -> Any:
if field:
if is_coroutine:
value, errors = field.validate(response_content, {}, loc=("response",))
value, errors = field.validate(response_content, loc=("response",))
else:
value, errors = await run_in_threadpool(
field.validate, response_content, {}, loc=("response",)
@ -399,54 +398,35 @@ def get_request_handler(
endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}"
# Read body and auto-close files
try:
body: Any = None
if body_field:
body: Any = None
is_body_json = False
if body_field:
try:
if is_body_form:
body = await request.form()
file_stack.push_async_callback(body.close)
else:
body_bytes = await request.body()
if body_bytes:
json_body: Any = Undefined
body = await request.body() or None
if body:
content_type_value = request.headers.get("content-type")
if not content_type_value:
if not actual_strict_content_type:
json_body = await request.json()
else:
if content_type_value:
message = email.message.Message()
message["content-type"] = content_type_value
if message.get_content_maintype() == "application":
subtype = message.get_content_subtype()
if subtype == "json" or subtype.endswith("+json"):
json_body = await request.json()
if json_body != Undefined:
body = json_body
else:
body = body_bytes
except json.JSONDecodeError as e:
validation_error = RequestValidationError(
[
{
"type": "json_invalid",
"loc": ("body", e.pos),
"msg": "JSON decode error",
"input": {},
"ctx": {"error": e.msg},
}
],
body=e.doc,
endpoint_ctx=endpoint_ctx,
)
raise validation_error from e
except HTTPException:
# If a middleware raises an HTTPException, it should be raised again
raise
except Exception as e:
http_error = HTTPException(
status_code=400, detail="There was an error parsing the body"
)
raise http_error from e
is_body_json = subtype == "json" or subtype.endswith(
"+json"
)
elif not actual_strict_content_type:
is_body_json = True
except HTTPException:
# If a middleware raises an HTTPException, it should be raised again
raise
except Exception as e:
http_error = HTTPException(
status_code=400, detail="There was an error parsing the body"
)
raise http_error from e
# Solve dependencies and run path operation function, auto-closing dependencies
errors: list[Any] = []
@ -457,7 +437,8 @@ def get_request_handler(
solved_result = await solve_dependencies(
request=request,
dependant=dependant,
body=cast(dict[str, Any] | FormData | bytes | None, body),
body=cast(FormData | bytes | None, body),
is_body_json=is_body_json,
dependency_overrides_provider=dependency_overrides_provider,
async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields,

11
tests/test_tutorial/test_body/test_tutorial001.py

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from starlette.requests import Request
from ...utils import needs_py310
@ -147,10 +148,10 @@ def test_post_broken_body(client: TestClient):
"detail": [
{
"type": "json_invalid",
"loc": ["body", 1],
"msg": "JSON decode error",
"input": {},
"ctx": {"error": "Expecting property name enclosed in double quotes"},
"loc": ["body"],
"msg": "Invalid JSON: key must be a string at line 1 column 2",
"input": "{some broken json}",
"ctx": {"error": "key must be a string at line 1 column 2"},
}
]
}
@ -246,7 +247,7 @@ def test_wrong_headers(client: TestClient):
def test_other_exceptions(client: TestClient):
with patch("json.loads", side_effect=Exception):
with patch.object(Request, "body", side_effect=Exception):
response = client.post("/items/", json={"test": "test2"})
assert response.status_code == 400, response.text

24
tests/test_tutorial/test_body_multiple_params/test_tutorial002.py

@ -94,6 +94,30 @@ def test_post_no_body(client: TestClient):
}
def test_post_broken_body(client: TestClient):
response = client.put(
"/items/5",
headers={"content-type": "application/json"},
content="{some broken json}",
)
assert response.status_code == 422, response.text
assert response.json() == {
"detail": [
{
"type": "json_invalid",
"loc": ["body"],
"msg": "Invalid JSON: Expecting property name enclosed in double quotes",
"input": "{some broken json}",
"ctx": {
"colno": 2,
"lineno": 1,
"pos": 1,
},
}
]
}
def test_post_no_item(client: TestClient):
response = client.put("/items/5", json={"user": {"username": "johndoe"}})
assert response.status_code == 422

2
tests/test_tutorial/test_body_nested_models/test_tutorial006.py

@ -112,7 +112,7 @@ def test_put_images_not_list(client: TestClient):
"url": "http://example.com/image.png",
"name": "example image",
},
"msg": "Input should be a valid list",
"msg": "Input should be a valid array",
"type": "list_type",
},
]

2
tests/test_tutorial/test_body_nested_models/test_tutorial008.py

@ -57,7 +57,7 @@ def test_post_not_a_list(client: TestClient):
"name": "Example",
"url": "http://example.com/",
},
"msg": "Input should be a valid list",
"msg": "Input should be a valid array",
"type": "list_type",
}
]

2
tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py

@ -34,7 +34,7 @@ def test_exception_handler_body_access(client: TestClient):
{
"type": "list_type",
"loc": ["body"],
"msg": "Input should be a valid list",
"msg": "Input should be a valid array",
"input": {"numbers": [1, 2, 3]},
}
],

7
tests/test_tutorial/test_handling_errors/test_tutorial005.py

@ -1,3 +1,4 @@
from dirty_equals import IsOneOf
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
@ -18,7 +19,11 @@ def test_post_validation_error():
"input": "XL",
}
],
"body": {"title": "towel", "size": "XL"},
# httpx 0.28.0 switches to compact JSON https://github.com/encode/httpx/issues/3363
"body": IsOneOf(
'{"title": "towel", "size": "XL"}',
'{"title":"towel","size":"XL"}',
),
}

Loading…
Cancel
Save