diff --git a/fastapi/routing.py b/fastapi/routing.py index fe8d886093..46a8f8ca37 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -386,6 +386,14 @@ def get_request_handler( else: body = body_bytes except json.JSONDecodeError as e: + start_pos = max(0, e.pos - 40) + end_pos = min(len(e.doc), e.pos + 40) + error_snippet = e.doc[start_pos:end_pos] + if start_pos > 0: + error_snippet = "..." + error_snippet + if end_pos < len(e.doc): + error_snippet = error_snippet + "..." + validation_error = RequestValidationError( [ { @@ -393,7 +401,13 @@ def get_request_handler( "loc": ("body", e.pos), "msg": "JSON decode error", "input": {}, - "ctx": {"error": e.msg}, + "ctx": { + "error": e.msg, + "position": e.pos, + "line": e.lineno - 1, + "column": e.colno - 1, + "snippet": error_snippet, + }, } ], body=e.doc, diff --git a/tests/test_json_body_decode_error.py b/tests/test_json_body_decode_error.py new file mode 100644 index 0000000000..872a23bd4a --- /dev/null +++ b/tests/test_json_body_decode_error.py @@ -0,0 +1,157 @@ +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +INVALID_JSON = """ +{ + "name": "Test", + "price": 'invalid' +}""" + + +class Item(BaseModel): + name: str + price: float + description: str = None + + +@app.post("/items/") +async def create_item(item: Item): + return item # pragma: no cover + + +client = TestClient(app) + + +def test_json_decode_error_single_line(): + response = client.post( + "/items/", + content='{"name": "Test", "price": None}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + assert error["loc"] == ["body", 26] + assert error["msg"] == "JSON decode error" + assert error["input"] == {} + assert error["ctx"]["error"] == "Expecting value" + assert error["ctx"]["position"] == 26 + assert error["ctx"]["line"] == 0 + assert error["ctx"]["column"] == 26 + assert "None" in error["ctx"]["snippet"] + + +def test_json_decode_error_multiline(): + response = client.post( + "/items/", content=INVALID_JSON, headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + assert error["loc"][0] == "body" + assert isinstance(error["loc"][1], int) + assert error["msg"] == "JSON decode error" + assert error["input"] == {} + assert error["ctx"]["line"] == 3 + assert error["ctx"]["column"] == 11 + assert "invalid" in error["ctx"]["snippet"] + + +def test_json_decode_error_empty_body(): + response = client.post( + "/items/", content="", headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 422 + # Handle both Pydantic v1 and v2 - empty body is handled differently + assert response.json() == IsDict( + { + "detail": [ + { + "loc": ["body"], + "msg": "Field required", + "type": "missing", + "input": None, + } + ] + } + ) | IsDict( + # Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_json_decode_error_unclosed_brace(): + response = client.post( + "/items/", + content='{"name": "Test"', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + assert error["msg"] == "JSON decode error" + assert error["type"] == "json_invalid" + assert error["input"] == {} + assert "position" in error["ctx"] + assert "line" in error["ctx"] + assert "column" in error["ctx"] + assert "snippet" in error["ctx"] + + +@pytest.mark.parametrize( + "json_content,expected_starts_with_ellipsis,expected_ends_with_ellipsis", + [ + ( + '{"field": invalid, "padding_field": "this needs to be long enough that we have more than forty characters after the error position"}', + False, + True, + ), + ( + '{"very_long_field_name_here": "some value that is long enough to push us past the forty character mark", "another_field": invalid}', + True, + False, + ), + ( + '{"very_long_field_name_here": "some value", "field": invalid, "padding_field": "this needs to be long enough that we have more than forty characters after the error position"}', + True, + True, + ), + ('{"field": invalid}', False, False), + ], +) +def test_json_decode_error_snippet_ellipsis( + json_content, expected_starts_with_ellipsis, expected_ends_with_ellipsis +): + response = client.post( + "/items/", content=json_content, headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + assert error["msg"] == "JSON decode error" + assert error["input"] == {} + assert "invalid" in error["ctx"]["snippet"] + assert error["type"] == "json_invalid" + + snippet = error["ctx"]["snippet"] + assert snippet.startswith("...") == expected_starts_with_ellipsis + assert snippet.endswith("...") == expected_ends_with_ellipsis diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 9a837483f2..236f424b41 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -150,7 +150,13 @@ def test_post_broken_body(client: TestClient): "loc": ["body", 1], "msg": "JSON decode error", "input": {}, - "ctx": {"error": "Expecting property name enclosed in double quotes"}, + "ctx": { + "error": "Expecting property name enclosed in double quotes", + "position": 1, + "line": 0, + "column": 1, + "snippet": "{some broken json}", + }, } ] }