From 34532c6e088a886aeccc6753d28af7bb05cc18d7 Mon Sep 17 00:00:00 2001 From: Martynov Maxim Date: Mon, 23 Feb 2026 23:36:28 +0300 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Use=20TypeAdapter.validate?= =?UTF-8?q?=5Fjson=20instead=20of=20json.loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/de/docs/tutorial/handling-errors.md | 8 +- docs/en/docs/tutorial/handling-errors.md | 8 +- docs/es/docs/tutorial/handling-errors.md | 8 +- docs/fr/docs/tutorial/handling-errors.md | 8 +- docs/ja/docs/tutorial/handling-errors.md | 8 +- docs/ko/docs/tutorial/handling-errors.md | 8 +- docs/pt/docs/tutorial/handling-errors.md | 8 +- docs/ru/docs/tutorial/handling-errors.md | 8 +- docs/tr/docs/tutorial/handling-errors.md | 8 +- docs/uk/docs/tutorial/handling-errors.md | 8 +- docs/zh-hant/docs/tutorial/handling-errors.md | 8 +- docs/zh/docs/tutorial/handling-errors.md | 8 +- fastapi/_compat/v2.py | 15 ++++ fastapi/dependencies/utils.py | 81 ++++++++++++++++--- fastapi/routing.py | 65 ++++++--------- pyproject.toml | 4 +- .../test_body/test_tutorial001.py | 11 +-- .../test_tutorial002.py | 24 ++++++ .../test_tutorial006.py | 2 +- .../test_tutorial008.py | 2 +- .../test_tutorial002.py | 2 +- .../test_handling_errors/test_tutorial005.py | 7 +- uv.lock | 4 +- 23 files changed, 186 insertions(+), 127 deletions(-) diff --git a/docs/de/docs/tutorial/handling-errors.md b/docs/de/docs/tutorial/handling-errors.md index 261831a8eb..1e5d81a23d 100644 --- a/docs/de/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/en/docs/tutorial/handling-errors.md b/docs/en/docs/tutorial/handling-errors.md index 78a5f1f20a..a212ca322c 100644 --- a/docs/en/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/es/docs/tutorial/handling-errors.md b/docs/es/docs/tutorial/handling-errors.md index 737c43e41b..09ba07fc3c 100644 --- a/docs/es/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/fr/docs/tutorial/handling-errors.md b/docs/fr/docs/tutorial/handling-errors.md index a697571f33..01d30c613b 100644 --- a/docs/fr/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/ja/docs/tutorial/handling-errors.md b/docs/ja/docs/tutorial/handling-errors.md index 8d0190cb0b..03564ba772 100644 --- a/docs/ja/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/ko/docs/tutorial/handling-errors.md b/docs/ko/docs/tutorial/handling-errors.md index efee108ef1..83b62c2af3 100644 --- a/docs/ko/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/pt/docs/tutorial/handling-errors.md b/docs/pt/docs/tutorial/handling-errors.md index c400a1e848..83a8dbce5b 100644 --- a/docs/pt/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/ru/docs/tutorial/handling-errors.md b/docs/ru/docs/tutorial/handling-errors.md index fde188f09f..bf2b2ac67c 100644 --- a/docs/ru/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/tr/docs/tutorial/handling-errors.md b/docs/tr/docs/tutorial/handling-errors.md index b90e186a6a..c44a99d6ae 100644 --- a/docs/tr/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/uk/docs/tutorial/handling-errors.md b/docs/uk/docs/tutorial/handling-errors.md index 262efa0e03..debaf5e4a2 100644 --- a/docs/uk/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/zh-hant/docs/tutorial/handling-errors.md b/docs/zh-hant/docs/tutorial/handling-errors.md index b1ffd3e038..fba66c33b0 100644 --- a/docs/zh-hant/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/docs/zh/docs/tutorial/handling-errors.md b/docs/zh/docs/tutorial/handling-errors.md index f3a23fab0a..d8f6adae78 100644 --- a/docs/zh/docs/tutorial/handling-errors.md +++ b/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}" } ``` diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 7be686d865..fba8d8fce4 100644 --- a/fastapi/_compat/v2.py +++ b/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, diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 40dffba64b..841c8c701f 100644 --- a/fastapi/dependencies/utils.py +++ b/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, + }, + } + ] diff --git a/fastapi/routing.py b/fastapi/routing.py index 21a1385a27..49c9f8c2fe 100644 --- a/fastapi/routing.py +++ b/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, diff --git a/pyproject.toml b/pyproject.toml index 9affa3d1d9..ec6296d674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ classifiers = [ ] dependencies = [ "starlette>=0.46.0", - "pydantic>=2.9.0", + "pydantic>=2.10.0", "typing-extensions>=4.8.0", "typing-inspection>=0.4.2", "annotated-doc>=0.0.2", @@ -151,7 +151,7 @@ docs-tests = [ ] github-actions = [ "httpx >=0.27.0,<1.0.0", - "pydantic >=2.9.0,<3.0.0", + "pydantic >=2.10.0,<3.0.0", "pydantic-settings >=2.1.0,<3.0.0", "pygithub >=2.3.0,<3.0.0", "pyyaml >=5.3.1,<7.0.0", diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 8c883708a3..abba18da5b 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/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 diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial002.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial002.py index b9b9928ba5..8cafc050be 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial002.py +++ b/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 diff --git a/tests/test_tutorial/test_body_nested_models/test_tutorial006.py b/tests/test_tutorial/test_body_nested_models/test_tutorial006.py index 4551a72b31..5200d84af6 100644 --- a/tests/test_tutorial/test_body_nested_models/test_tutorial006.py +++ b/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", }, ] diff --git a/tests/test_tutorial/test_body_nested_models/test_tutorial008.py b/tests/test_tutorial/test_body_nested_models/test_tutorial008.py index d038805bd7..150a9b0198 100644 --- a/tests/test_tutorial/test_body_nested_models/test_tutorial008.py +++ b/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", } ] diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py index 746d6c9d2f..f89ce9f9fe 100644 --- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py +++ b/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]}, } ], diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial005.py b/tests/test_tutorial/test_handling_errors/test_tutorial005.py index c04b510c39..e54d62cb3f 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial005.py +++ b/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"}', + ), } diff --git a/uv.lock b/uv.lock index a0608e8d76..1e5668b3eb 100644 --- a/uv.lock +++ b/uv.lock @@ -1259,7 +1259,7 @@ requires-dist = [ { name = "jinja2", marker = "extra == 'all'", specifier = ">=3.1.5" }, { name = "jinja2", marker = "extra == 'standard'", specifier = ">=3.1.5" }, { name = "jinja2", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=3.1.5" }, - { name = "pydantic", specifier = ">=2.9.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic-extra-types", marker = "extra == 'all'", specifier = ">=2.0.0" }, { name = "pydantic-extra-types", marker = "extra == 'standard'", specifier = ">=2.0.0" }, { name = "pydantic-extra-types", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=2.0.0" }, @@ -1347,7 +1347,7 @@ docs-tests = [ ] github-actions = [ { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, - { name = "pydantic", specifier = ">=2.9.0,<3.0.0" }, + { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.1.0,<3.0.0" }, { name = "pygithub", specifier = ">=2.3.0,<3.0.0" }, { name = "pyyaml", specifier = ">=5.3.1,<7.0.0" },