Maxim Martynov 2 weeks ago
committed by GitHub
parent
commit
2e083f2599
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

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" "size"
], ],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer" "type": "type_error.integer",
"input": "XL"
} }
], ],
"body": { "body": "{\n \"title\": \"towel\",\n \"size\": \"XL\"\n}"
"title": "towel",
"size": "XL"
}
} }
``` ```

15
fastapi/_compat/v2.py

@ -187,6 +187,21 @@ class ModelField:
errors=exc.errors(include_url=False), loc_prefix=loc 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( def serialize(
self, self,
value: Any, value: Any,

81
fastapi/dependencies/utils.py

@ -1,5 +1,6 @@
import dataclasses import dataclasses
import inspect import inspect
import json
import sys import sys
from collections.abc import ( from collections.abc import (
AsyncGenerator, AsyncGenerator,
@ -599,7 +600,8 @@ async def solve_dependencies(
*, *,
request: Request | WebSocket, request: Request | WebSocket,
dependant: Dependant, 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, background_tasks: StarletteBackgroundTasks | None = None,
response: Response | None = None, response: Response | None = None,
dependency_overrides_provider: Any | None = None, dependency_overrides_provider: Any | None = None,
@ -650,6 +652,7 @@ async def solve_dependencies(
request=request, request=request,
dependant=use_sub_dependant, dependant=use_sub_dependant,
body=body, body=body,
is_body_json=is_body_json,
background_tasks=background_tasks, background_tasks=background_tasks,
response=response, response=response,
dependency_overrides_provider=dependency_overrides_provider, dependency_overrides_provider=dependency_overrides_provider,
@ -706,6 +709,7 @@ async def solve_dependencies(
) = await request_body_to_args( # body_params checked above ) = await request_body_to_args( # body_params checked above
body_fields=dependant.body_params, body_fields=dependant.body_params,
received_body=body, received_body=body,
is_body_json=is_body_json,
embed_body_fields=embed_body_fields, embed_body_fields=embed_body_fields,
) )
values.update(body_values) 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( 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]]: ) -> tuple[Any, list[Any]]:
if value is None: if value is None:
if field.field_info.is_required(): if field.field_info.is_required():
return None, [get_missing_field_error(loc=loc)] return None, [get_missing_field_error(loc=loc)]
else: else:
return deepcopy(field.default), [] return deepcopy(field.default), []
return field.validate(value, values, loc=loc) return field.validate(value, loc=loc)
def _is_json_field(field: ModelField) -> bool: def _is_json_field(field: ModelField) -> bool:
@ -849,7 +868,7 @@ def request_params_to_args(
) )
loc: tuple[str, ...] = (field_info.in_.value,) loc: tuple[str, ...] = (field_info.in_.value,)
v_, errors_ = _validate_value_with_model_field( 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_ return {first_field.name: v_}, errors_
@ -861,7 +880,7 @@ def request_params_to_args(
) )
loc = (field_info.in_.value, get_validation_alias(field)) loc = (field_info.in_.value, get_validation_alias(field))
v_, errors_ = _validate_value_with_model_field( v_, errors_ = _validate_value_with_model_field(
field=field, value=value, values=values, loc=loc field=field, value=value, loc=loc
) )
if errors_: if errors_:
errors.extend(errors_) errors.extend(errors_)
@ -954,7 +973,8 @@ async def _extract_form_body(
async def request_body_to_args( async def request_body_to_args(
body_fields: list[ModelField], 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, embed_body_fields: bool,
) -> tuple[dict[str, Any], list[dict[str, Any]]]: ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
values: 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" 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 single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields
first_field = body_fields[0] 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 fields_to_extract: list[ModelField] = body_fields
@ -976,12 +996,25 @@ async def request_body_to_args(
if isinstance(received_body, FormData): if isinstance(received_body, FormData):
body_to_process = await _extract_form_body(fields_to_extract, received_body) body_to_process = await _extract_form_body(fields_to_extract, received_body)
loc: tuple[str, ...] = ("body",)
if single_not_embedded_field: if single_not_embedded_field:
loc: tuple[str, ...] = ("body",) if is_body_json:
v_, errors_ = _validate_value_with_model_field( v_, errors_ = _validate_json_body_as_model_field(
field=first_field, value=body_to_process, values=values, loc=loc 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_ 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: for field in body_fields:
loc = ("body", get_validation_alias(field)) loc = ("body", get_validation_alias(field))
value: Any | None = None value: Any | None = None
@ -993,7 +1026,7 @@ async def request_body_to_args(
errors.append(get_missing_field_error(loc)) errors.append(get_missing_field_error(loc))
continue continue
v_, errors_ = _validate_value_with_model_field( v_, errors_ = _validate_value_with_model_field(
field=field, value=value, values=values, loc=loc field=field, value=value, loc=loc
) )
if errors_: if errors_:
errors.extend(errors_) errors.extend(errors_)
@ -1059,3 +1092,27 @@ def get_body_field(
def get_validation_alias(field: ModelField) -> str: def get_validation_alias(field: ModelField) -> str:
va = getattr(field, "validation_alias", None) va = getattr(field, "validation_alias", None)
return va or field.alias 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 import params
from fastapi._compat import ( from fastapi._compat import (
ModelField, ModelField,
Undefined,
lenient_issubclass, lenient_issubclass,
) )
from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.datastructures import Default, DefaultPlaceholder
@ -290,7 +289,7 @@ async def serialize_response(
) -> Any: ) -> Any:
if field: if field:
if is_coroutine: if is_coroutine:
value, errors = field.validate(response_content, {}, loc=("response",)) value, errors = field.validate(response_content, loc=("response",))
else: else:
value, errors = await run_in_threadpool( value, errors = await run_in_threadpool(
field.validate, response_content, {}, loc=("response",) field.validate, response_content, {}, loc=("response",)
@ -399,54 +398,35 @@ def get_request_handler(
endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}" endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}"
# Read body and auto-close files # Read body and auto-close files
try: body: Any = None
body: Any = None is_body_json = False
if body_field: if body_field:
try:
if is_body_form: if is_body_form:
body = await request.form() body = await request.form()
file_stack.push_async_callback(body.close) file_stack.push_async_callback(body.close)
else: else:
body_bytes = await request.body() body = await request.body() or None
if body_bytes: if body:
json_body: Any = Undefined
content_type_value = request.headers.get("content-type") content_type_value = request.headers.get("content-type")
if not content_type_value: if content_type_value:
if not actual_strict_content_type:
json_body = await request.json()
else:
message = email.message.Message() message = email.message.Message()
message["content-type"] = content_type_value message["content-type"] = content_type_value
if message.get_content_maintype() == "application": if message.get_content_maintype() == "application":
subtype = message.get_content_subtype() subtype = message.get_content_subtype()
if subtype == "json" or subtype.endswith("+json"): is_body_json = subtype == "json" or subtype.endswith(
json_body = await request.json() "+json"
if json_body != Undefined: )
body = json_body elif not actual_strict_content_type:
else: is_body_json = True
body = body_bytes except HTTPException:
except json.JSONDecodeError as e: # If a middleware raises an HTTPException, it should be raised again
validation_error = RequestValidationError( raise
[ except Exception as e:
{ http_error = HTTPException(
"type": "json_invalid", status_code=400, detail="There was an error parsing the body"
"loc": ("body", e.pos), )
"msg": "JSON decode error", raise http_error from e
"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
# Solve dependencies and run path operation function, auto-closing dependencies # Solve dependencies and run path operation function, auto-closing dependencies
errors: list[Any] = [] errors: list[Any] = []
@ -457,7 +437,8 @@ def get_request_handler(
solved_result = await solve_dependencies( solved_result = await solve_dependencies(
request=request, request=request,
dependant=dependant, 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, dependency_overrides_provider=dependency_overrides_provider,
async_exit_stack=async_exit_stack, async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields, 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 import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
from starlette.requests import Request
from ...utils import needs_py310 from ...utils import needs_py310
@ -147,10 +148,10 @@ def test_post_broken_body(client: TestClient):
"detail": [ "detail": [
{ {
"type": "json_invalid", "type": "json_invalid",
"loc": ["body", 1], "loc": ["body"],
"msg": "JSON decode error", "msg": "Invalid JSON: key must be a string at line 1 column 2",
"input": {}, "input": "{some broken json}",
"ctx": {"error": "Expecting property name enclosed in double quotes"}, "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): 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"}) response = client.post("/items/", json={"test": "test2"})
assert response.status_code == 400, response.text 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): def test_post_no_item(client: TestClient):
response = client.put("/items/5", json={"user": {"username": "johndoe"}}) response = client.put("/items/5", json={"user": {"username": "johndoe"}})
assert response.status_code == 422 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", "url": "http://example.com/image.png",
"name": "example image", "name": "example image",
}, },
"msg": "Input should be a valid list", "msg": "Input should be a valid array",
"type": "list_type", "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", "name": "Example",
"url": "http://example.com/", "url": "http://example.com/",
}, },
"msg": "Input should be a valid list", "msg": "Input should be a valid array",
"type": "list_type", "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", "type": "list_type",
"loc": ["body"], "loc": ["body"],
"msg": "Input should be a valid list", "msg": "Input should be a valid array",
"input": {"numbers": [1, 2, 3]}, "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 fastapi.testclient import TestClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
@ -18,7 +19,11 @@ def test_post_validation_error():
"input": "XL", "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