From 6b907a57f841a955322f377e3dc20e87159ae890 Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Thu, 18 Sep 2025 23:25:57 +0200 Subject: [PATCH 01/10] feat: enhance JSON parse errors with line/column info and snippets column positions and error snippets to JSON decode errors for better debugging experience. Updates error location format and provides context around the problematic JSON. --- fastapi/routing.py | 80 ++++++++++---- tests/test_json_error_improvements.py | 103 ++++++++++++++++++ .../test_body/test_tutorial001.py | 14 ++- 3 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 tests/test_json_error_improvements.py diff --git a/fastapi/routing.py b/fastapi/routing.py index 54c75a027..a887a362e 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -85,7 +85,8 @@ def _prepare_response_content( exclude_none: bool = False, ) -> Any: if isinstance(res, BaseModel): - read_with_orm_mode = getattr(_get_model_config(res), "read_with_orm_mode", None) + read_with_orm_mode = getattr( + _get_model_config(res), "read_with_orm_mode", None) if read_with_orm_mode: # Let from_orm extract the data from this model instead of converting # it now to a dict. @@ -164,7 +165,8 @@ async def serialize_response( exclude_none=exclude_none, ) 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",) @@ -219,7 +221,8 @@ def get_request_handler( dependant: Dependant, body_field: Optional[ModelField] = None, status_code: Optional[int] = None, - response_class: Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse), + response_class: Union[Type[Response], + DefaultPlaceholder] = Default(JSONResponse), response_field: Optional[ModelField] = None, response_model_include: Optional[IncEx] = None, response_model_exclude: Optional[IncEx] = None, @@ -232,7 +235,8 @@ def get_request_handler( ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = asyncio.iscoroutinefunction(dependant.call) - is_body_form = body_field and isinstance(body_field.field_info, params.Form) + is_body_form = body_field and isinstance( + body_field.field_info, params.Form) if isinstance(response_class, DefaultPlaceholder): actual_response_class: Type[Response] = response_class.value else: @@ -251,7 +255,8 @@ def get_request_handler( body_bytes = await request.body() if body_bytes: 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: json_body = await request.json() else: @@ -266,14 +271,32 @@ def get_request_handler( else: body = body_bytes except json.JSONDecodeError as e: + lines_before = e.doc[: e.pos].split("\n") + line_number = len(lines_before) + column_number = len( + lines_before[-1]) + 1 if lines_before else 1 + + 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( [ { "type": "json_invalid", - "loc": ("body", e.pos), - "msg": "JSON decode error", - "input": {}, - "ctx": {"error": e.msg}, + "loc": ("body", line_number, column_number), + "msg": f"JSON decode error - {e.msg} at line {line_number}, column {column_number}", + "input": error_snippet, + "ctx": { + "error": e.msg, + "position": e.pos, + "line": line_number, + "column": column_number, + }, } ], body=e.doc, @@ -336,10 +359,12 @@ def get_request_handler( exclude_none=response_model_exclude_none, is_coroutine=is_coroutine, ) - response = actual_response_class(content, **response_args) + response = actual_response_class( + content, **response_args) if not is_body_allowed_for_status_code(response.status_code): response.body = b"" - response.headers.raw.extend(solved_result.response.headers.raw) + response.headers.raw.extend( + solved_result.response.headers.raw) if errors: validation_error = RequestValidationError( _normalize_errors(errors), body=body @@ -400,12 +425,15 @@ class APIWebSocketRoute(routing.WebSocketRoute): self.endpoint = endpoint self.name = get_name(endpoint) if name is None else name self.dependencies = list(dependencies or []) - self.path_regex, self.path_format, self.param_convertors = compile_path(path) - self.dependant = get_dependant(path=self.path_format, call=self.endpoint) + self.path_regex, self.path_format, self.param_convertors = compile_path( + path) + self.dependant = get_dependant( + path=self.path_format, call=self.endpoint) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant(depends=depends, path=self.path_format), + get_parameterless_sub_dependant( + depends=depends, path=self.path_format), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -489,7 +517,8 @@ class APIRoute(routing.Route): self.tags = tags or [] self.responses = responses or {} self.name = get_name(endpoint) if name is None else name - self.path_regex, self.path_format, self.param_convertors = compile_path(path) + self.path_regex, self.path_format, self.param_convertors = compile_path( + path) if methods is None: methods = ["GET"] self.methods: Set[str] = {method.upper() for method in methods} @@ -529,13 +558,15 @@ class APIRoute(routing.Route): self.response_field = None # type: ignore self.secure_cloned_response_field = None self.dependencies = list(dependencies or []) - self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") + self.description = description or inspect.cleandoc( + self.endpoint.__doc__ or "") # if a "form feed" character (page break) is found in the description text, # truncate description text to the content preceding the first "form feed" self.description = self.description.split("\f")[0].strip() response_fields = {} for additional_status_code, response in self.responses.items(): - assert isinstance(response, dict), "An additional response must be a dict" + assert isinstance( + response, dict), "An additional response must be a dict" model = response.get("model") if model: assert is_body_allowed_for_status_code(additional_status_code), ( @@ -547,16 +578,19 @@ class APIRoute(routing.Route): ) response_fields[additional_status_code] = response_field if response_fields: - self.response_fields: Dict[Union[int, str], ModelField] = response_fields + self.response_fields: Dict[Union[int, + str], ModelField] = response_fields else: self.response_fields = {} assert callable(endpoint), "An endpoint must be a callable" - self.dependant = get_dependant(path=self.path_format, call=self.endpoint) + self.dependant = get_dependant( + path=self.path_format, call=self.endpoint) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant(depends=depends, path=self.path_format), + get_parameterless_sub_dependant( + depends=depends, path=self.path_format), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -623,7 +657,8 @@ class APIRouter(routing.Router): def __init__( self, *, - prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", + prefix: Annotated[str, Doc( + "An optional path prefix for the router.")] = "", tags: Annotated[ Optional[List[Union[str, Enum]]], Doc( @@ -1124,7 +1159,8 @@ class APIRouter(routing.Router): self, router: Annotated["APIRouter", Doc("The `APIRouter` to include.")], *, - prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", + prefix: Annotated[str, Doc( + "An optional path prefix for the router.")] = "", tags: Annotated[ Optional[List[Union[str, Enum]]], Doc( diff --git a/tests/test_json_error_improvements.py b/tests/test_json_error_improvements.py new file mode 100644 index 000000000..708b5b5f6 --- /dev/null +++ b/tests/test_json_error_improvements.py @@ -0,0 +1,103 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + price: float + description: str = None + + +@app.post("/items/") +async def create_item(item: Item): + return item + + +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", 1, 27] + assert "line 1" in error["msg"] + assert "column 27" in error["msg"] + assert error["ctx"]["line"] == 1 + assert error["ctx"]["column"] == 27 + assert "None" in error["input"] + + +def test_json_decode_error_multiline(): + invalid_json = """ +{ + "name": "Test", + "price": 'invalid' +}""" + + 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"] == ["body", 4, 12] + assert "line 4" in error["msg"] + assert "column 12" in error["msg"] + assert error["ctx"]["line"] == 4 + assert error["ctx"]["column"] == 12 + assert "invalid" in error["input"] + + +def test_json_decode_error_shows_snippet(): + long_json = '{"very_long_field_name_here": "some value", "another_field": invalid}' + + response = client.post( + "/items/", content=long_json, headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + assert "..." in error["input"] + assert "invalid" in error["input"] + assert len(error["input"]) <= 83 + + +def test_json_decode_error_empty_body(): + response = client.post( + "/items/", content="", headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + # Empty body is handled differently, not as a JSON decode error + assert error["loc"] == ["body"] + assert error["type"] == "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 "line" in error["msg"].lower() + assert "column" in error["msg"].lower() + assert error["type"] == "json_invalid" + assert "position" in error["ctx"] diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index f8b5aee8d..53b20d893 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -60,7 +60,8 @@ def test_post_with_str_float_description(client: TestClient): def test_post_with_str_float_description_tax(client: TestClient): response = client.post( "/items/", - json={"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, + json={"name": "Foo", "price": "50.5", + "description": "Some Foo", "tax": 0.3}, ) assert response.status_code == 200 assert response.json() == { @@ -206,11 +207,14 @@ def test_post_broken_body(client: TestClient): "detail": [ { "type": "json_invalid", - "loc": ["body", 1], - "msg": "JSON decode error", - "input": {}, + "loc": ["body", 1, 2], + "msg": "JSON decode error - Expecting property name enclosed in double quotes at line 1, column 2", + "input": "{some broken json}", "ctx": { - "error": "Expecting property name enclosed in double quotes" + "error": "Expecting property name enclosed in double quotes", + "position": 1, + "line": 1, + "column": 2, }, } ] From 9a5da94657150fcc175d683557465815f7e22034 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 21:27:12 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 57 +++++++------------ .../test_body/test_tutorial001.py | 3 +- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index a887a362e..34abac126 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -85,8 +85,7 @@ def _prepare_response_content( exclude_none: bool = False, ) -> Any: if isinstance(res, BaseModel): - read_with_orm_mode = getattr( - _get_model_config(res), "read_with_orm_mode", None) + read_with_orm_mode = getattr(_get_model_config(res), "read_with_orm_mode", None) if read_with_orm_mode: # Let from_orm extract the data from this model instead of converting # it now to a dict. @@ -165,8 +164,7 @@ async def serialize_response( exclude_none=exclude_none, ) 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",) @@ -221,8 +219,7 @@ def get_request_handler( dependant: Dependant, body_field: Optional[ModelField] = None, status_code: Optional[int] = None, - response_class: Union[Type[Response], - DefaultPlaceholder] = Default(JSONResponse), + response_class: Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse), response_field: Optional[ModelField] = None, response_model_include: Optional[IncEx] = None, response_model_exclude: Optional[IncEx] = None, @@ -235,8 +232,7 @@ def get_request_handler( ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = asyncio.iscoroutinefunction(dependant.call) - is_body_form = body_field and isinstance( - body_field.field_info, params.Form) + is_body_form = body_field and isinstance(body_field.field_info, params.Form) if isinstance(response_class, DefaultPlaceholder): actual_response_class: Type[Response] = response_class.value else: @@ -255,8 +251,7 @@ def get_request_handler( body_bytes = await request.body() if body_bytes: 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: json_body = await request.json() else: @@ -273,8 +268,7 @@ def get_request_handler( except json.JSONDecodeError as e: lines_before = e.doc[: e.pos].split("\n") line_number = len(lines_before) - column_number = len( - lines_before[-1]) + 1 if lines_before else 1 + column_number = len(lines_before[-1]) + 1 if lines_before else 1 start_pos = max(0, e.pos - 40) end_pos = min(len(e.doc), e.pos + 40) @@ -359,12 +353,10 @@ def get_request_handler( exclude_none=response_model_exclude_none, is_coroutine=is_coroutine, ) - response = actual_response_class( - content, **response_args) + response = actual_response_class(content, **response_args) if not is_body_allowed_for_status_code(response.status_code): response.body = b"" - response.headers.raw.extend( - solved_result.response.headers.raw) + response.headers.raw.extend(solved_result.response.headers.raw) if errors: validation_error = RequestValidationError( _normalize_errors(errors), body=body @@ -425,15 +417,12 @@ class APIWebSocketRoute(routing.WebSocketRoute): self.endpoint = endpoint self.name = get_name(endpoint) if name is None else name self.dependencies = list(dependencies or []) - self.path_regex, self.path_format, self.param_convertors = compile_path( - path) - self.dependant = get_dependant( - path=self.path_format, call=self.endpoint) + self.path_regex, self.path_format, self.param_convertors = compile_path(path) + self.dependant = get_dependant(path=self.path_format, call=self.endpoint) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant( - depends=depends, path=self.path_format), + get_parameterless_sub_dependant(depends=depends, path=self.path_format), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -517,8 +506,7 @@ class APIRoute(routing.Route): self.tags = tags or [] self.responses = responses or {} self.name = get_name(endpoint) if name is None else name - self.path_regex, self.path_format, self.param_convertors = compile_path( - path) + self.path_regex, self.path_format, self.param_convertors = compile_path(path) if methods is None: methods = ["GET"] self.methods: Set[str] = {method.upper() for method in methods} @@ -558,15 +546,13 @@ class APIRoute(routing.Route): self.response_field = None # type: ignore self.secure_cloned_response_field = None self.dependencies = list(dependencies or []) - self.description = description or inspect.cleandoc( - self.endpoint.__doc__ or "") + self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") # if a "form feed" character (page break) is found in the description text, # truncate description text to the content preceding the first "form feed" self.description = self.description.split("\f")[0].strip() response_fields = {} for additional_status_code, response in self.responses.items(): - assert isinstance( - response, dict), "An additional response must be a dict" + assert isinstance(response, dict), "An additional response must be a dict" model = response.get("model") if model: assert is_body_allowed_for_status_code(additional_status_code), ( @@ -578,19 +564,16 @@ class APIRoute(routing.Route): ) response_fields[additional_status_code] = response_field if response_fields: - self.response_fields: Dict[Union[int, - str], ModelField] = response_fields + self.response_fields: Dict[Union[int, str], ModelField] = response_fields else: self.response_fields = {} assert callable(endpoint), "An endpoint must be a callable" - self.dependant = get_dependant( - path=self.path_format, call=self.endpoint) + self.dependant = get_dependant(path=self.path_format, call=self.endpoint) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant( - depends=depends, path=self.path_format), + get_parameterless_sub_dependant(depends=depends, path=self.path_format), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -657,8 +640,7 @@ class APIRouter(routing.Router): def __init__( self, *, - prefix: Annotated[str, Doc( - "An optional path prefix for the router.")] = "", + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", tags: Annotated[ Optional[List[Union[str, Enum]]], Doc( @@ -1159,8 +1141,7 @@ class APIRouter(routing.Router): self, router: Annotated["APIRouter", Doc("The `APIRouter` to include.")], *, - prefix: Annotated[str, Doc( - "An optional path prefix for the router.")] = "", + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", tags: Annotated[ Optional[List[Union[str, Enum]]], Doc( diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 53b20d893..be22e1301 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -60,8 +60,7 @@ def test_post_with_str_float_description(client: TestClient): def test_post_with_str_float_description_tax(client: TestClient): response = client.post( "/items/", - json={"name": "Foo", "price": "50.5", - "description": "Some Foo", "tax": 0.3}, + json={"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, ) assert response.status_code == 200 assert response.json() == { From 93e98d5cb766d2639b28840574932b01facbbfc2 Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Thu, 18 Sep 2025 23:40:25 +0200 Subject: [PATCH 03/10] Refactor code for improved readability and update tests to handle Pydantic v1 and v2 differences. --- fastapi/routing.py | 57 +++++++------------ tests/test_json_error_improvements.py | 30 ++++++++-- .../test_body/test_tutorial001.py | 3 +- 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index a887a362e..34abac126 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -85,8 +85,7 @@ def _prepare_response_content( exclude_none: bool = False, ) -> Any: if isinstance(res, BaseModel): - read_with_orm_mode = getattr( - _get_model_config(res), "read_with_orm_mode", None) + read_with_orm_mode = getattr(_get_model_config(res), "read_with_orm_mode", None) if read_with_orm_mode: # Let from_orm extract the data from this model instead of converting # it now to a dict. @@ -165,8 +164,7 @@ async def serialize_response( exclude_none=exclude_none, ) 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",) @@ -221,8 +219,7 @@ def get_request_handler( dependant: Dependant, body_field: Optional[ModelField] = None, status_code: Optional[int] = None, - response_class: Union[Type[Response], - DefaultPlaceholder] = Default(JSONResponse), + response_class: Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse), response_field: Optional[ModelField] = None, response_model_include: Optional[IncEx] = None, response_model_exclude: Optional[IncEx] = None, @@ -235,8 +232,7 @@ def get_request_handler( ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = asyncio.iscoroutinefunction(dependant.call) - is_body_form = body_field and isinstance( - body_field.field_info, params.Form) + is_body_form = body_field and isinstance(body_field.field_info, params.Form) if isinstance(response_class, DefaultPlaceholder): actual_response_class: Type[Response] = response_class.value else: @@ -255,8 +251,7 @@ def get_request_handler( body_bytes = await request.body() if body_bytes: 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: json_body = await request.json() else: @@ -273,8 +268,7 @@ def get_request_handler( except json.JSONDecodeError as e: lines_before = e.doc[: e.pos].split("\n") line_number = len(lines_before) - column_number = len( - lines_before[-1]) + 1 if lines_before else 1 + column_number = len(lines_before[-1]) + 1 if lines_before else 1 start_pos = max(0, e.pos - 40) end_pos = min(len(e.doc), e.pos + 40) @@ -359,12 +353,10 @@ def get_request_handler( exclude_none=response_model_exclude_none, is_coroutine=is_coroutine, ) - response = actual_response_class( - content, **response_args) + response = actual_response_class(content, **response_args) if not is_body_allowed_for_status_code(response.status_code): response.body = b"" - response.headers.raw.extend( - solved_result.response.headers.raw) + response.headers.raw.extend(solved_result.response.headers.raw) if errors: validation_error = RequestValidationError( _normalize_errors(errors), body=body @@ -425,15 +417,12 @@ class APIWebSocketRoute(routing.WebSocketRoute): self.endpoint = endpoint self.name = get_name(endpoint) if name is None else name self.dependencies = list(dependencies or []) - self.path_regex, self.path_format, self.param_convertors = compile_path( - path) - self.dependant = get_dependant( - path=self.path_format, call=self.endpoint) + self.path_regex, self.path_format, self.param_convertors = compile_path(path) + self.dependant = get_dependant(path=self.path_format, call=self.endpoint) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant( - depends=depends, path=self.path_format), + get_parameterless_sub_dependant(depends=depends, path=self.path_format), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -517,8 +506,7 @@ class APIRoute(routing.Route): self.tags = tags or [] self.responses = responses or {} self.name = get_name(endpoint) if name is None else name - self.path_regex, self.path_format, self.param_convertors = compile_path( - path) + self.path_regex, self.path_format, self.param_convertors = compile_path(path) if methods is None: methods = ["GET"] self.methods: Set[str] = {method.upper() for method in methods} @@ -558,15 +546,13 @@ class APIRoute(routing.Route): self.response_field = None # type: ignore self.secure_cloned_response_field = None self.dependencies = list(dependencies or []) - self.description = description or inspect.cleandoc( - self.endpoint.__doc__ or "") + self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") # if a "form feed" character (page break) is found in the description text, # truncate description text to the content preceding the first "form feed" self.description = self.description.split("\f")[0].strip() response_fields = {} for additional_status_code, response in self.responses.items(): - assert isinstance( - response, dict), "An additional response must be a dict" + assert isinstance(response, dict), "An additional response must be a dict" model = response.get("model") if model: assert is_body_allowed_for_status_code(additional_status_code), ( @@ -578,19 +564,16 @@ class APIRoute(routing.Route): ) response_fields[additional_status_code] = response_field if response_fields: - self.response_fields: Dict[Union[int, - str], ModelField] = response_fields + self.response_fields: Dict[Union[int, str], ModelField] = response_fields else: self.response_fields = {} assert callable(endpoint), "An endpoint must be a callable" - self.dependant = get_dependant( - path=self.path_format, call=self.endpoint) + self.dependant = get_dependant(path=self.path_format, call=self.endpoint) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, - get_parameterless_sub_dependant( - depends=depends, path=self.path_format), + get_parameterless_sub_dependant(depends=depends, path=self.path_format), ) self._flat_dependant = get_flat_dependant(self.dependant) self._embed_body_fields = _should_embed_body_fields( @@ -657,8 +640,7 @@ class APIRouter(routing.Router): def __init__( self, *, - prefix: Annotated[str, Doc( - "An optional path prefix for the router.")] = "", + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", tags: Annotated[ Optional[List[Union[str, Enum]]], Doc( @@ -1159,8 +1141,7 @@ class APIRouter(routing.Router): self, router: Annotated["APIRouter", Doc("The `APIRouter` to include.")], *, - prefix: Annotated[str, Doc( - "An optional path prefix for the router.")] = "", + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", tags: Annotated[ Optional[List[Union[str, Enum]]], Doc( diff --git a/tests/test_json_error_improvements.py b/tests/test_json_error_improvements.py index 708b5b5f6..d801377d9 100644 --- a/tests/test_json_error_improvements.py +++ b/tests/test_json_error_improvements.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel @@ -80,11 +81,30 @@ def test_json_decode_error_empty_body(): ) assert response.status_code == 422 - error = response.json()["detail"][0] - - # Empty body is handled differently, not as a JSON decode error - assert error["loc"] == ["body"] - assert error["type"] == "missing" + # 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(): diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index 53b20d893..be22e1301 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -60,8 +60,7 @@ def test_post_with_str_float_description(client: TestClient): def test_post_with_str_float_description_tax(client: TestClient): response = client.post( "/items/", - json={"name": "Foo", "price": "50.5", - "description": "Some Foo", "tax": 0.3}, + json={"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, ) assert response.status_code == 200 assert response.json() == { From 7a8da3a4ad101119e8d145cea4fd72e0bb4f9bb6 Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Fri, 19 Sep 2025 16:42:42 +0200 Subject: [PATCH 04/10] Add test for JSON decode error handling in long documents with early errors. --- tests/test_json_error_improvements.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_json_error_improvements.py b/tests/test_json_error_improvements.py index d801377d9..51d3ee474 100644 --- a/tests/test_json_error_improvements.py +++ b/tests/test_json_error_improvements.py @@ -121,3 +121,21 @@ def test_json_decode_error_unclosed_brace(): assert "column" in error["msg"].lower() assert error["type"] == "json_invalid" assert "position" in error["ctx"] + + +def test_json_decode_error_in_middle_of_long_document(): + # Create a JSON with error early in a long document (need >40 chars after error) + # The error is at position for "invalid" which needs at least 41 chars after it + long_json = '{"field": invalid, "padding_field": "this needs to be long enough that we have more than forty characters after the error position"}' + + response = client.post( + "/items/", content=long_json, headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 422 + error = response.json()["detail"][0] + + # The error snippet should have "..." at the end since error is early in doc + assert error["input"].endswith("...") + assert "invalid" in error["input"] + assert error["type"] == "json_invalid" From dc1321b0d0b42bac75ac04a59bdd58b0ffe74774 Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Fri, 19 Sep 2025 16:48:11 +0200 Subject: [PATCH 05/10] Add test for successful item creation in test_json_error_improvements.py --- tests/test_json_error_improvements.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_json_error_improvements.py b/tests/test_json_error_improvements.py index 51d3ee474..530062770 100644 --- a/tests/test_json_error_improvements.py +++ b/tests/test_json_error_improvements.py @@ -139,3 +139,16 @@ def test_json_decode_error_in_middle_of_long_document(): assert error["input"].endswith("...") assert "invalid" in error["input"] assert error["type"] == "json_invalid" + + +def test_successful_item_creation(): + response = client.post( + "/items/", + json={"name": "Test Item", "price": 19.99, "description": "A test item"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Test Item" + assert data["price"] == 19.99 + assert data["description"] == "A test item" From 5c4055c3eae93839e3b19c07a9b0033bc1daf74a Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Thu, 2 Oct 2025 23:55:18 +0200 Subject: [PATCH 06/10] Refactor JSON error handling in routing and tests for improvements. --- fastapi/routing.py | 15 +++--- tests/test_json_error_improvements.py | 48 +++++++++++-------- .../test_body/test_tutorial001.py | 11 +++-- 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 34abac126..e670b07bb 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -266,10 +266,6 @@ def get_request_handler( else: body = body_bytes except json.JSONDecodeError as e: - lines_before = e.doc[: e.pos].split("\n") - line_number = len(lines_before) - column_number = len(lines_before[-1]) + 1 if lines_before else 1 - start_pos = max(0, e.pos - 40) end_pos = min(len(e.doc), e.pos + 40) error_snippet = e.doc[start_pos:end_pos] @@ -282,14 +278,15 @@ def get_request_handler( [ { "type": "json_invalid", - "loc": ("body", line_number, column_number), - "msg": f"JSON decode error - {e.msg} at line {line_number}, column {column_number}", - "input": error_snippet, + "loc": ("body", e.pos), + "msg": "JSON decode error", + "input": {}, "ctx": { "error": e.msg, "position": e.pos, - "line": line_number, - "column": column_number, + "line": e.lineno - 1, + "column": e.colno - 1, + "snippet": error_snippet, }, } ], diff --git a/tests/test_json_error_improvements.py b/tests/test_json_error_improvements.py index 530062770..764ee13bf 100644 --- a/tests/test_json_error_improvements.py +++ b/tests/test_json_error_improvements.py @@ -30,12 +30,14 @@ def test_json_decode_error_single_line(): assert response.status_code == 422 error = response.json()["detail"][0] - assert error["loc"] == ["body", 1, 27] - assert "line 1" in error["msg"] - assert "column 27" in error["msg"] - assert error["ctx"]["line"] == 1 - assert error["ctx"]["column"] == 27 - assert "None" in error["input"] + 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(): @@ -52,12 +54,13 @@ def test_json_decode_error_multiline(): assert response.status_code == 422 error = response.json()["detail"][0] - assert error["loc"] == ["body", 4, 12] - assert "line 4" in error["msg"] - assert "column 12" in error["msg"] - assert error["ctx"]["line"] == 4 - assert error["ctx"]["column"] == 12 - assert "invalid" in error["input"] + 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_shows_snippet(): @@ -70,9 +73,10 @@ def test_json_decode_error_shows_snippet(): assert response.status_code == 422 error = response.json()["detail"][0] - assert "..." in error["input"] - assert "invalid" in error["input"] - assert len(error["input"]) <= 83 + assert error["msg"] == "JSON decode error" + assert error["input"] == {} + assert "invalid" in error["ctx"]["snippet"] + assert len(error["ctx"]["snippet"]) <= 83 def test_json_decode_error_empty_body(): @@ -117,10 +121,13 @@ def test_json_decode_error_unclosed_brace(): assert response.status_code == 422 error = response.json()["detail"][0] - assert "line" in error["msg"].lower() - assert "column" in error["msg"].lower() + 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"] def test_json_decode_error_in_middle_of_long_document(): @@ -135,9 +142,10 @@ def test_json_decode_error_in_middle_of_long_document(): assert response.status_code == 422 error = response.json()["detail"][0] - # The error snippet should have "..." at the end since error is early in doc - assert error["input"].endswith("...") - assert "invalid" in error["input"] + assert error["msg"] == "JSON decode error" + assert error["input"] == {} + assert error["ctx"]["snippet"].endswith("...") + assert "invalid" in error["ctx"]["snippet"] assert error["type"] == "json_invalid" diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index be22e1301..8aaade066 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -206,14 +206,15 @@ def test_post_broken_body(client: TestClient): "detail": [ { "type": "json_invalid", - "loc": ["body", 1, 2], - "msg": "JSON decode error - Expecting property name enclosed in double quotes at line 1, column 2", - "input": "{some broken json}", + "loc": ["body", 1], + "msg": "JSON decode error", + "input": {}, "ctx": { "error": "Expecting property name enclosed in double quotes", "position": 1, - "line": 1, - "column": 2, + "line": 0, + "column": 1, + "snippet": "{some broken json}", }, } ] From 77a64320963b4baeb9a9f109800e9f3de2943f9f Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Fri, 3 Oct 2025 13:11:21 +0200 Subject: [PATCH 07/10] Refactor json error tests and improve error snippet handling in test_json_body_decode_error.py. --- ...ents.py => test_json_body_decode_error.py} | 79 +++++++++---------- 1 file changed, 37 insertions(+), 42 deletions(-) rename tests/{test_json_error_improvements.py => test_json_body_decode_error.py} (68%) diff --git a/tests/test_json_error_improvements.py b/tests/test_json_body_decode_error.py similarity index 68% rename from tests/test_json_error_improvements.py rename to tests/test_json_body_decode_error.py index 764ee13bf..2bd5f36b6 100644 --- a/tests/test_json_error_improvements.py +++ b/tests/test_json_body_decode_error.py @@ -1,3 +1,4 @@ +import pytest from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient @@ -6,6 +7,13 @@ from pydantic import BaseModel app = FastAPI() +INVALID_JSON = """ +{ + "name": "Test", + "price": 'invalid' +}""" + + class Item(BaseModel): name: str price: float @@ -41,14 +49,8 @@ def test_json_decode_error_single_line(): def test_json_decode_error_multiline(): - invalid_json = """ -{ - "name": "Test", - "price": 'invalid' -}""" - response = client.post( - "/items/", content=invalid_json, headers={"Content-Type": "application/json"} + "/items/", content=INVALID_JSON, headers={"Content-Type": "application/json"} ) assert response.status_code == 422 @@ -63,22 +65,6 @@ def test_json_decode_error_multiline(): assert "invalid" in error["ctx"]["snippet"] -def test_json_decode_error_shows_snippet(): - long_json = '{"very_long_field_name_here": "some value", "another_field": invalid}' - - response = client.post( - "/items/", content=long_json, 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 len(error["ctx"]["snippet"]) <= 83 - - def test_json_decode_error_empty_body(): response = client.post( "/items/", content="", headers={"Content-Type": "application/json"} @@ -130,13 +116,32 @@ def test_json_decode_error_unclosed_brace(): assert "snippet" in error["ctx"] -def test_json_decode_error_in_middle_of_long_document(): - # Create a JSON with error early in a long document (need >40 chars after error) - # The error is at position for "invalid" which needs at least 41 chars after it - long_json = '{"field": invalid, "padding_field": "this needs to be long enough that we have more than forty characters after the error position"}' - +@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=long_json, headers={"Content-Type": "application/json"} + "/items/", content=json_content, headers={"Content-Type": "application/json"} ) assert response.status_code == 422 @@ -144,19 +149,9 @@ def test_json_decode_error_in_middle_of_long_document(): assert error["msg"] == "JSON decode error" assert error["input"] == {} - assert error["ctx"]["snippet"].endswith("...") assert "invalid" in error["ctx"]["snippet"] assert error["type"] == "json_invalid" - -def test_successful_item_creation(): - response = client.post( - "/items/", - json={"name": "Test Item", "price": 19.99, "description": "A test item"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Test Item" - assert data["price"] == 19.99 - assert data["description"] == "A test item" + snippet = error["ctx"]["snippet"] + assert snippet.startswith("...") == expected_starts_with_ellipsis + assert snippet.endswith("...") == expected_ends_with_ellipsis From 595de316628356c96afbacdeaa7deca8d541bc21 Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Fri, 3 Oct 2025 13:43:00 +0200 Subject: [PATCH 08/10] Add back test_successful_item_creation to maintain 100% coverage --- tests/test_json_body_decode_error.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_json_body_decode_error.py b/tests/test_json_body_decode_error.py index 2bd5f36b6..551b4b933 100644 --- a/tests/test_json_body_decode_error.py +++ b/tests/test_json_body_decode_error.py @@ -155,3 +155,17 @@ def test_json_decode_error_snippet_ellipsis( snippet = error["ctx"]["snippet"] assert snippet.startswith("...") == expected_starts_with_ellipsis assert snippet.endswith("...") == expected_ends_with_ellipsis + + +def test_successful_item_creation(): + response = client.post( + "/items/", + json={"name": "Test Item", "price": 19.99, + "description": "A test item"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Test Item" + assert data["price"] == 19.99 + assert data["description"] == "A test item" From b45a03c3b7fd96f5ed9353e08edefbe944058bda Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:44:01 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_json_body_decode_error.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_json_body_decode_error.py b/tests/test_json_body_decode_error.py index 551b4b933..9ca2334a6 100644 --- a/tests/test_json_body_decode_error.py +++ b/tests/test_json_body_decode_error.py @@ -160,8 +160,7 @@ def test_json_decode_error_snippet_ellipsis( def test_successful_item_creation(): response = client.post( "/items/", - json={"name": "Test Item", "price": 19.99, - "description": "A test item"}, + json={"name": "Test Item", "price": 19.99, "description": "A test item"}, ) assert response.status_code == 200 From f85b79637e45a9cb9ecd69bfbe54562cb18bf016 Mon Sep 17 00:00:00 2001 From: Arif Dogan Date: Fri, 3 Oct 2025 21:00:14 +0200 Subject: [PATCH 10/10] test: clean up JSON decode error tests per review feedback --- tests/test_json_body_decode_error.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/test_json_body_decode_error.py b/tests/test_json_body_decode_error.py index 551b4b933..872a23bd4 100644 --- a/tests/test_json_body_decode_error.py +++ b/tests/test_json_body_decode_error.py @@ -22,7 +22,7 @@ class Item(BaseModel): @app.post("/items/") async def create_item(item: Item): - return item + return item # pragma: no cover client = TestClient(app) @@ -155,17 +155,3 @@ def test_json_decode_error_snippet_ellipsis( snippet = error["ctx"]["snippet"] assert snippet.startswith("...") == expected_starts_with_ellipsis assert snippet.endswith("...") == expected_ends_with_ellipsis - - -def test_successful_item_creation(): - response = client.post( - "/items/", - json={"name": "Test Item", "price": 19.99, - "description": "A test item"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Test Item" - assert data["price"] == 19.99 - assert data["description"] == "A test item"