Skip to content

Commit dedb27d

Browse files
authored
Follow RFC 7807 when responding with JSONDecodeException (#234)
- JSONDecodeExceptions thrown while decoding request data (body json, get attributes) are now caught and re-thrown as @web.problem annotated RequestDataDecodingException to comply with RFC7807 - DecodeExceptionHandler is removed - uncaught JSONDecodeExceptions will be eventually responded as http 500
1 parent 5e83e8a commit dedb27d

16 files changed

+142
-42
lines changed

Diff for: CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [12.0.0]
8+
### Changed
9+
- Adjust response body to comply with RFC7807 when request data decoding fails
10+
711
## [11.0.0]
812
### Added
913
- get_all_subclasses function in module_discovery module

Diff for: pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "winter"
3-
version = "11.0.0"
3+
version = "12.0.0"
44
homepage = "https://github.com/WinterFramework/winter"
55
description = "Web Framework inspired by Spring Framework"
66
authors = ["Alexander Egorov <[email protected]>"]

Diff for: tests/api/api_with_problem_exceptions.py

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import winter.web
77
from winter.data.exceptions import NotFoundException
8+
from winter.web.exceptions import RequestDataDecodeException
89

910

1011
@winter.web.problem(status=HTTPStatus.FORBIDDEN)
@@ -87,3 +88,18 @@ def custom_handler_problem_exists_exception(self) -> str:
8788
@winter.route_get('not_found_exception/')
8889
def not_found_exception(self) -> str:
8990
raise NotFoundException(entity_id=1, entity_cls=MyEntity)
91+
92+
@winter.route_get('request_data_decoding_exception_with_dict_errors/')
93+
def json_decoder_errors_as_dict_exception(self) -> None:
94+
errors = {
95+
'non_field_error': 'Missing fields: "id", "status", "int_status", "birthday"',
96+
'contact': {
97+
'phones': 'Cannot decode "123" to set',
98+
},
99+
}
100+
raise RequestDataDecodeException(errors)
101+
102+
@winter.route_get('request_data_decoding_exception_with_str_errors/')
103+
def json_decoder_errors_as_str_exception(self) -> None:
104+
errors = 'Cannot decode "data1" to integer'
105+
raise RequestDataDecodeException(errors)

Diff for: tests/pagination/test_page_position_argument_resolver.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from winter.core.json import decoder
1010
from winter.data.pagination import PagePosition
1111
from winter.data.pagination import Sort
12+
from winter.web.exceptions import RequestDataDecodeException
1213
from winter.web.pagination import PagePositionArgumentResolver
1314

1415

@@ -111,19 +112,30 @@ def method(page_position: PagePosition): # pragma: no cover
111112

112113

113114
@pytest.mark.parametrize(
114-
('query_string', 'exception_type', 'message'), (
115-
('limit=none', decoder.JSONDecodeException, 'Cannot decode "none" to PositiveInteger'),
116-
('offset=-20', decoder.JSONDecodeException, 'Cannot decode "-20" to PositiveInteger'),
117-
('order_by=id,', ParseError, 'Invalid field for order: ""'),
118-
('order_by=-', ParseError, 'Invalid field for order: "-"'),
115+
('query_string', 'exception_type', 'message', 'errors_dict'), (
116+
(
117+
'limit=none',
118+
RequestDataDecodeException,
119+
'Failed to decode request data',
120+
{'error': 'Cannot decode "none" to PositiveInteger'}
121+
),
122+
(
123+
'offset=-20',
124+
RequestDataDecodeException,
125+
'Failed to decode request data',
126+
{'error': 'Cannot decode "-20" to PositiveInteger'}
127+
),
128+
('order_by=id,', ParseError, 'Invalid field for order: ""', None),
129+
('order_by=-', ParseError, 'Invalid field for order: "-"', None),
119130
(
120131
'order_by=not_allowed_order_by_field',
121132
ParseError,
122133
'Fields do not allowed as order by fields: "not_allowed_order_by_field"',
134+
None,
123135
),
124136
),
125137
)
126-
def test_resolve_argument_fails_in_page_position_argument_resolver(query_string, exception_type, message):
138+
def test_resolve_argument_fails_in_page_position_argument_resolver(query_string, exception_type, message, errors_dict):
127139
@winter.web.pagination.order_by(['id'])
128140
def method(arg1: int): # pragma: no cover
129141
return arg1
@@ -140,3 +152,4 @@ def method(arg1: int): # pragma: no cover
140152
resolver.resolve_argument(argument, request, {})
141153

142154
assert exception_info.value.args[0] == message
155+
assert not hasattr(exception_info.value, 'errors') or exception_info.value.errors == errors_dict

Diff for: tests/routing/test_query_parameters.py

+18-11
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from tests.entities import AuthorizedUser
1515
from tests.utils import get_request
1616
from winter.core.annotations import AlreadyAnnotated
17-
from winter.core.json import decoder
1817
from winter.web.argument_resolver import ArgumentNotSupported
18+
from winter.web.exceptions import RequestDataDecodeException
1919
from winter.web.query_parameters.query_parameters_argument_resolver import QueryParameterArgumentResolver
2020

2121

@@ -47,12 +47,16 @@ def method(
4747

4848

4949
@pytest.mark.parametrize(
50-
('query_string', 'expected_exception_message'), (
51-
('query_param=invalid_int', 'Cannot decode "invalid_int" to integer'),
52-
('', 'Missing required query parameter "query_param"'),
50+
('query_string', 'expected_exception_message', 'expected_errors'), (
51+
(
52+
'query_param=invalid_int',
53+
'Failed to decode request data',
54+
{'error': 'Cannot decode "invalid_int" to integer'}
55+
),
56+
('', 'Failed to decode request data', {'error': 'Missing required query parameter "query_param"'}),
5357
),
5458
)
55-
def test_query_parameter_resolver_with_raises_parse_error(query_string, expected_exception_message):
59+
def test_query_parameter_resolver_with_raises_parse_error(query_string, expected_exception_message, expected_errors):
5660
@winter.route_get('{?query_param}')
5761
def method(query_param: int): # pragma: no cover
5862
return query_param
@@ -62,10 +66,11 @@ def method(query_param: int): # pragma: no cover
6266
argument = method.get_argument('query_param')
6367
request = get_request(query_string)
6468

65-
with pytest.raises(decoder.JSONDecodeException) as exception:
69+
with pytest.raises(RequestDataDecodeException) as exception_info:
6670
resolver.resolve_argument(argument, request, {})
6771

68-
assert str(exception.value) == expected_exception_message
72+
assert str(exception_info.value) == expected_exception_message
73+
assert exception_info.value.errors == expected_errors
6974

7075

7176
def test_query_parameter_resolver_with_raises_parse_uuid_error():
@@ -78,10 +83,11 @@ def method(query_param: UUID): # pragma: no cover
7883
argument = method.get_argument('query_param')
7984
request = get_request('query_param=invalid_uuid')
8085

81-
with pytest.raises(decoder.JSONDecodeException) as exception:
86+
with pytest.raises(RequestDataDecodeException) as exception_info:
8287
resolver.resolve_argument(argument, request, {})
8388

84-
assert str(exception.value) == 'Cannot decode "invalid_uuid" to uuid'
89+
assert str(exception_info.value) == 'Failed to decode request data'
90+
assert exception_info.value.errors == {'error': 'Cannot decode "invalid_uuid" to uuid'}
8591

8692

8793
@pytest.mark.parametrize(
@@ -160,10 +166,11 @@ def method(x_param: int, y_param: int = 1): # pragma: no cover
160166
argument = method.get_argument('x_param')
161167
request = get_request('?x=1')
162168

163-
with pytest.raises(decoder.JSONDecodeException) as exception:
169+
with pytest.raises(RequestDataDecodeException) as exception_info:
164170
resolver.resolve_argument(argument, request, {})
165171

166-
assert str(exception.value) == 'Missing required query parameter "x"'
172+
assert str(exception_info.value) == 'Failed to decode request data'
173+
assert exception_info.value.errors == {'error': 'Missing required query parameter "x"'}
167174

168175

169176
@pytest.mark.parametrize(

Diff for: tests/test_exception_handling.py

+29
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,35 @@ def test_exception_handler_with_unknown_argument():
123123
'detail': 'MyEntity with ID=1 not found',
124124
},
125125
),
126+
(
127+
'request_data_decoding_exception_with_str_errors',
128+
HTTPStatus.BAD_REQUEST,
129+
'application/json+problem',
130+
{
131+
'status': 400,
132+
'type': 'urn:problem-type:request-data-decode',
133+
'title': 'Request data decode',
134+
'detail': 'Failed to decode request data',
135+
'errors': {'error': 'Cannot decode "data1" to integer'},
136+
},
137+
),
138+
(
139+
'request_data_decoding_exception_with_dict_errors',
140+
HTTPStatus.BAD_REQUEST,
141+
'application/json+problem',
142+
{
143+
'status': 400,
144+
'type': 'urn:problem-type:request-data-decode',
145+
'title': 'Request data decode',
146+
'detail': 'Failed to decode request data',
147+
'errors': {
148+
'non_field_error': 'Missing fields: "id", "status", "int_status", "birthday"',
149+
'contact': {
150+
'phones': 'Cannot decode "123" to set',
151+
},
152+
}
153+
},
154+
),
126155
),
127156
)
128157
def test_api_with_problem_exceptions(url_path, expected_status, expected_content_type, expected_body):

Diff for: tests/web/test_request_body.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,16 @@ def test_request_body_with_errors():
8484
}
8585

8686
expected_data = {
87-
'id': 'Cannot decode "invalid integer" to integer',
88-
'status': 'Value not in allowed values("active", "super_active"): "invalid status"',
89-
'items': 'Cannot decode "invalid integer" to integer',
90-
'non_field_error': 'Missing fields: "name"',
87+
'status': 400,
88+
'type': 'urn:problem-type:request-data-decode',
89+
'title': 'Request data decode',
90+
'detail': 'Failed to decode request data',
91+
'errors': {
92+
'id': 'Cannot decode "invalid integer" to integer',
93+
'status': 'Value not in allowed values("active", "super_active"): "invalid status"',
94+
'items': 'Cannot decode "invalid integer" to integer',
95+
'non_field_error': 'Missing fields: "name"',
96+
}
9197
}
9298
data = json.dumps(data)
9399

Diff for: winter/web/__init__.py

-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323

2424

2525
def setup():
26-
from winter.core.json.decoder import JSONDecodeException
2726
from winter.data.exceptions import NotFoundException
2827
from .configurer import run_configurers
2928
from .exceptions import RedirectException
@@ -32,7 +31,6 @@ def setup():
3231
from .exceptions.problem_handling import ProblemExceptionMapper
3332
from .exceptions.problem_handling_info import ProblemHandlingInfo
3433
from .exception_handlers import RedirectExceptionHandler
35-
from .exception_handlers import DecodeExceptionHandler
3634
from .pagination.page_processor_resolver import PageOutputProcessorResolver
3735
from .pagination.page_position_argument_resolver import PagePositionArgumentResolver
3836
from .path_parameters_argument_resolver import PathParametersArgumentResolver
@@ -53,7 +51,6 @@ def setup():
5351
exception_handler_generator = ProblemExceptionHandlerGenerator(exception_mapper)
5452
autodiscover_problem_annotations(exception_handler_generator)
5553
auto_handle_exceptions = {
56-
JSONDecodeException: DecodeExceptionHandler,
5754
RedirectException: RedirectExceptionHandler,
5855
NotFoundException: exception_handler_generator.generate(NotFoundException, ProblemHandlingInfo(status=404)),
5956
}

Diff for: winter/web/exception_handlers.py

-8
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
11
from http import HTTPStatus
2-
from typing import Dict
32

4-
from winter.core.json.decoder import JSONDecodeException
53
from .exceptions import ExceptionHandler
64
from .exceptions import RedirectException
75
from .response_header_annotation import ResponseHeader
86
from .response_header_annotation import response_header
97
from .response_status_annotation import response_status
108

119

12-
class DecodeExceptionHandler(ExceptionHandler):
13-
@response_status(HTTPStatus.BAD_REQUEST)
14-
def handle(self, exception: JSONDecodeException) -> Dict:
15-
return exception.errors
16-
17-
1810
class RedirectExceptionHandler(ExceptionHandler):
1911
@response_status(HTTPStatus.FOUND)
2012
@response_header('Location', 'location_header')

Diff for: winter/web/exceptions/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .exception_mapper import ExceptionMapper
33
from .exceptions import RedirectException
44
from .exceptions import ThrottleException
5+
from .exceptions import RequestDataDecodeException
56
from .handlers import ExceptionHandler
67
from .handlers import MethodExceptionsManager
78
from .handlers import exception_handlers_registry

Diff for: winter/web/exceptions/exceptions.py

+18
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
from http import HTTPStatus
2+
from typing import Dict
3+
from typing import Optional
4+
from typing import Union
5+
6+
import dataclasses
27

38
from .problem import problem
49

@@ -12,3 +17,16 @@ class RedirectException(Exception):
1217
def __init__(self, redirect_to: str):
1318
super().__init__()
1419
self.redirect_to = redirect_to
20+
21+
22+
@problem(status=HTTPStatus.BAD_REQUEST)
23+
@dataclasses.dataclass
24+
class RequestDataDecodeException(Exception):
25+
errors: dict = dataclasses.field(default_factory=dict)
26+
27+
def __init__(self, errors: Optional[Union[str, Dict]]):
28+
super().__init__('Failed to decode request data')
29+
if type(errors) == dict:
30+
self.errors = errors
31+
elif type(errors) == str:
32+
self.errors = {'error': errors}

Diff for: winter/web/exceptions/problem_handling.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import re
2-
from typing import Dict
3-
from typing import Type
4-
51
import dataclasses
2+
import re
63
from dataclasses import asdict
74
from dataclasses import dataclass
85
from dataclasses import is_dataclass
6+
from typing import Dict
7+
from typing import Type
8+
99
from rest_framework.request import Request
1010

1111
from winter.core import Component

Diff for: winter/web/pagination/page_position_argument_resolver.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
from winter.core import ComponentMethod
88
from winter.core import ComponentMethodArgument
9+
from winter.core.json import JSONDecodeException
910
from winter.core.json import json_decode
1011
from winter.core.utils import PositiveInteger
1112
from winter.data.pagination import PagePosition
1213
from winter.data.pagination import Sort
1314
from winter.web.argument_resolver import ArgumentResolver
1415
from winter.web.exceptions import RedirectException
16+
from winter.web.exceptions import RequestDataDecodeException
1517
from winter.web.pagination.check_sort import check_sort
1618
from winter.web.pagination.limits import Limits
1719
from winter.web.pagination.limits import LimitsAnnotation
@@ -74,8 +76,11 @@ def _parse_page_position(self, argument: ComponentMethodArgument, http_request:
7476
raw_limit = http_request.query_params.get(self.limit_name) or None
7577
raw_offset = http_request.query_params.get(self.offset_name) or None
7678
raw_order_by = http_request.query_params.get(self.order_by_name, '')
77-
limit = json_decode(raw_limit, Optional[PositiveInteger])
78-
offset = json_decode(raw_offset, Optional[PositiveInteger])
79+
try:
80+
limit = json_decode(raw_limit, Optional[PositiveInteger])
81+
offset = json_decode(raw_offset, Optional[PositiveInteger])
82+
except JSONDecodeException as e:
83+
raise RequestDataDecodeException(e.errors)
7984
sort = self._parse_sort_properties(raw_order_by, argument)
8085
return PagePosition(limit=limit, offset=offset, sort=sort)
8186

Diff for: winter/web/query_parameters/query_parameters_argument_resolver.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .query_parameter import QueryParameter
1212
from ..argument_resolver import ArgumentNotSupported
1313
from ..argument_resolver import ArgumentResolver
14+
from ..exceptions import RequestDataDecodeException
1415
from ..routing import get_route
1516

1617

@@ -44,10 +45,14 @@ def resolve_argument(
4445
try:
4546
return argument.get_default()
4647
except ArgumentDoesNotHaveDefault:
47-
raise JSONDecodeException(f'Missing required query parameter "{parameter_name}"')
48+
raise RequestDataDecodeException(f'Missing required query parameter "{parameter_name}"')
4849

4950
value = self._get_value(query_parameters, parameter_name, is_iterable, explode)
50-
return json_decode(value, argument.type_)
51+
52+
try:
53+
return json_decode(value, argument.type_)
54+
except JSONDecodeException as e:
55+
raise RequestDataDecodeException(e.errors)
5156

5257
def _get_query_parameter(self, argument: ComponentMethodArgument) -> Optional[QueryParameter]:
5358
if argument in self._query_parameters:

Diff for: winter/web/request_body_resolver.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from rest_framework.request import Request
44

55
from .argument_resolver import ArgumentResolver
6+
from .exceptions.exceptions import RequestDataDecodeException
67
from .request_body_annotation import RequestBodyAnnotation
78
from ..core import ComponentMethodArgument
9+
from ..core.json import JSONDecodeException
810
from ..core.json import json_decode
911

1012

@@ -22,4 +24,7 @@ def resolve_argument(
2224
request: Request,
2325
response_headers: MutableMapping[str, str],
2426
):
25-
return json_decode(request.data, argument.type_)
27+
try:
28+
return json_decode(request.data, argument.type_)
29+
except JSONDecodeException as e:
30+
raise RequestDataDecodeException(e.errors)

0 commit comments

Comments
 (0)