Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(resp): add option to disable XML serialization of errors #2356

Merged
merged 6 commits into from
Oct 6, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/_newsfragments/2355.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Added a new flag :attr:`~falcon.ResponseOptions.enable_xml_error_serialization` that
can be used to disable XML serialization of :class:`~falcon.HTTPError` when
using the default error serializer (and the client preferred it).
This flag currently defaults to ``True``, keeping the same behavior as the previous
Falcon series; Falcon 5.0 will change the default to ``False``, with the objective of
completely removing support for it.
User that whish to retain support for XML serialization of the errors in the default
error serializer should add an XML media handler.

Deprecated the :meth:`~falcon.HTTPError.to_xml` method.
18 changes: 13 additions & 5 deletions falcon/app_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,13 @@ def default_serialize_error(req: Request, resp: Response, exception: HTTPError)
resp: Instance of ``falcon.Response``
exception: Instance of ``falcon.HTTPError``
"""
predefined = [MEDIA_XML, 'text/xml', MEDIA_JSON]
media_handlers = [mt for mt in resp.options.media_handlers if mt not in predefined]
options = resp.options
predefined = (
[MEDIA_XML, 'text/xml', MEDIA_JSON]
if options.enable_xml_error_serialization
else [MEDIA_JSON]
)
media_handlers = [mt for mt in options.media_handlers if mt not in predefined]
# NOTE(caselit) add all the registered before the predefined ones. This ensures that
# in case of equal match the last one (json) is selected and that the q= is taken
# into consideration when selecting the media
Expand All @@ -315,10 +320,13 @@ def default_serialize_error(req: Request, resp: Response, exception: HTTPError)
if '+json' in accept:
preferred = MEDIA_JSON
elif '+xml' in accept:
# NOTE(caselit): ignore enable_xml_error_serialization when
# checking if the media should be xml. This gives a chance to
# a xml media handler, if any, to be used
preferred = MEDIA_XML

if preferred is not None:
handler, _, _ = resp.options.media_handlers._resolve(
handler, _, _ = options.media_handlers._resolve(
preferred, MEDIA_JSON, raise_not_found=False
)
if preferred == MEDIA_JSON:
Expand All @@ -331,8 +339,8 @@ def default_serialize_error(req: Request, resp: Response, exception: HTTPError)
# to re-get the handler, since async handlers may not have a sync
# version available.
resp.media = exception.to_dict()
else:
resp.data = exception.to_xml()
elif options.enable_xml_error_serialization:
resp.data = exception._to_xml()

# NOTE(kgriffs): No need to append the charset param, since
# utf-8 is the default for both JSON and XML.
Expand Down
25 changes: 16 additions & 9 deletions falcon/http_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from falcon.constants import MEDIA_JSON
from falcon.util import code_to_http_status
from falcon.util import deprecated
from falcon.util import http_status_to_code
from falcon.util import uri

Expand All @@ -44,8 +45,7 @@ class HTTPError(Exception):

To customize what data is passed to the serializer, subclass
``HTTPError`` and override the ``to_dict()`` method (``to_json()``
is implemented via ``to_dict()``). To also support XML, override
the ``to_xml()`` method.
is implemented via ``to_dict()``).

`status` is the only positional argument allowed,
the other arguments are defined as keyword-only.
Expand Down Expand Up @@ -215,13 +215,8 @@ def to_json(self, handler: Optional[BaseHandler] = None) -> bytes:
# NOTE: the json handler requires the sync serialize interface
return handler.serialize(obj, MEDIA_JSON)

def to_xml(self) -> bytes:
"""Return an XML-encoded representation of the error.

Returns:
bytes: An XML document for the error.

"""
def _to_xml(self) -> bytes:
"""Return an XML-encoded representation of the error."""

error_element = et.Element('error')

Expand All @@ -243,6 +238,18 @@ def to_xml(self) -> bytes:
error_element, encoding='utf-8'
)

@deprecated(
'The internal serialization to xml is deprecated. A similar output can be '
'obtained by serializing to xml the output of ``.to_dict()``'
)
def to_xml(self) -> bytes:
"""Return an XML-encoded representation of the error.

Returns:
bytes: An XML document for the error.
"""
return self._to_xml()


# NOTE: initialized in falcon.media.json, that is always imported since Request/Response
# are imported by falcon init.
Expand Down
19 changes: 18 additions & 1 deletion falcon/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -1387,26 +1387,43 @@ class ResponseOptions:
media_handlers: Handlers
"""A dict-like object for configuring the media-types to handle.

default, handlers are provided for the ``application/json``,
Default handlers are provided for the ``application/json``,
``application/x-www-form-urlencoded`` and ``multipart/form-data`` media types.
"""
static_media_types: Dict[str, str]
"""A mapping of dot-prefixed file extensions to Internet media types (RFC 2046).

Defaults to ``mimetypes.types_map`` after calling ``mimetypes.init()``.
"""
enable_xml_error_serialization: bool
"""When ``False`` disables support for serializing http errors using XML when using
the default error serializer. Defaults to ``True``.

In any case the request ``Accept`` headers it taken into consideration when
selecting the serialization format of the errors.

Note:

The default value will change to ``False`` in Falcon 5.0.

Note:

Has no effect when using a custom error serializers.
"""

__slots__ = (
'secure_cookies_by_default',
'default_media_type',
'media_handlers',
'static_media_types',
'enable_xml_error_serialization',
)

def __init__(self) -> None:
self.secure_cookies_by_default = True
self.default_media_type = DEFAULT_MEDIA_TYPE
self.media_handlers = Handlers()
self.enable_xml_error_serialization = True

if not mimetypes.inited:
mimetypes.init()
Expand Down
1 change: 1 addition & 0 deletions tests/test_error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def test_caught_error(self, client):
def test_uncaught_python_error(
self, client, get_headers, resp_content_type, resp_start
):
client.app.resp_options.enable_xml_error_serialization = True
result = client.simulate_get(headers=get_headers)
assert result.status_code == 500
assert result.headers['content-type'] == resp_content_type
Expand Down
78 changes: 71 additions & 7 deletions tests/test_httperror.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from falcon.constants import MEDIA_YAML
from falcon.media import BaseHandler
import falcon.testing as testing
from falcon.util.deprecation import DeprecatedWarning

try:
import yaml
Expand All @@ -29,6 +30,23 @@ def client(asgi, util):
return testing.TestClient(app)


@pytest.fixture(
params=[
pytest.param(True, id='with_xml'),
pytest.param(False, id='without_xml'),
pytest.param(None, id='default_xml'),
]
)
def enable_xml(request):
def go(app):
if request.param is not None:
app.resp_options.enable_xml_error_serialization = request.param
# return bool(request.param)
return request.param is not False

return go


class FaultyResource:
def on_get(self, req, resp):
status = req.get_header('X-Error-Status')
Expand Down Expand Up @@ -302,9 +320,10 @@ def test_no_description_json(self, client):
response = client.simulate_patch('/fail')
assert response.status == falcon.HTTP_400
assert response.json == {'title': '400 Bad Request'}
assert response.headers['Content-Type'] == 'application/json'
assert response.content_type == 'application/json'

def test_no_description_xml(self, client):
client.app.resp_options.enable_xml_error_serialization = True
response = client.simulate_patch(
path='/fail', headers={'Accept': 'application/xml'}
)
Expand All @@ -316,7 +335,35 @@ def test_no_description_xml(self, client):
)

assert response.content == expected_xml
assert response.headers['Content-Type'] == 'application/xml'
assert response.content_type == 'application/xml'

@pytest.mark.parametrize('custom_xml', [True, False])
def test_xml_enable(self, client, enable_xml, custom_xml):
has_xml = enable_xml(client.app)
client.app.resp_options.default_media_type = 'app/foo'
accept = 'app/falcon+xml' if custom_xml else 'application/xml'
response = client.simulate_patch(path='/fail', headers={'Accept': accept})
assert response.status == falcon.HTTP_400

if has_xml:
expected_xml = (
b'<?xml version="1.0" encoding="UTF-8"?><error>'
b'<title>400 Bad Request</title></error>'
)
assert response.content == expected_xml
else:
assert response.content == b''
if has_xml or custom_xml:
assert response.content_type == 'application/xml'
else:
assert response.content_type == 'app/foo'

def test_to_xml_deprecated(self):
with pytest.warns(
DeprecatedWarning, match='The internal serialization to xml is deprecated.'
):
res = falcon.HTTPGone().to_xml()
assert res == falcon.HTTPGone()._to_xml()

def test_client_does_not_accept_json_or_xml(self, client):
headers = {
Expand Down Expand Up @@ -499,6 +546,7 @@ def test_epic_fail_json(self, client):
],
)
def test_epic_fail_xml(self, client, media_type):
client.app.resp_options.enable_xml_error_serialization = True
headers = {'Accept': media_type}

expected_body = (
Expand Down Expand Up @@ -547,6 +595,7 @@ def test_unicode_json(self, client):
assert expected_body == response.json

def test_unicode_xml(self, client):
client.app.resp_options.enable_xml_error_serialization = True
unicode_resource = UnicodeFaultyResource()

expected_body = (
Expand Down Expand Up @@ -738,11 +787,12 @@ def test_414_with_custom_kwargs(self, client):

def test_416(self, client, asgi, util):
client.app = util.create_app(asgi)
client.app.resp_options.enable_xml_error_serialization = True
client.app.add_route('/416', RangeNotSatisfiableResource())
response = client.simulate_request(path='/416', headers={'accept': 'text/xml'})

assert response.status == falcon.HTTP_416
assert response.content == falcon.HTTPRangeNotSatisfiable(123456).to_xml()
assert response.content == falcon.HTTPRangeNotSatisfiable(123456)._to_xml()
exp = (
b'<?xml version="1.0" encoding="UTF-8"?><error>'
b'<title>416 Range Not Satisfiable</title></error>'
Expand Down Expand Up @@ -998,6 +1048,12 @@ def client(self, util, asgi):
app.add_route('/', GoneResource())
return testing.TestClient(app)

def test_unknown_accept(self, client):
res = client.simulate_get(headers={'Accept': 'foo/bar'})
assert res.content_type == 'application/json'
assert res.headers['vary'] == 'Accept'
assert res.content == b''

@pytest.mark.parametrize('has_json_handler', [True, False])
def test_defaults_to_json(self, client, has_json_handler):
if not has_json_handler:
Expand All @@ -1015,6 +1071,7 @@ def test_defaults_to_json(self, client, has_json_handler):
def test_serializes_error_to_preferred_by_sender(
self, accept, content_type, content, client, asgi
):
client.app.resp_options.enable_xml_error_serialization = True
client.app.resp_options.media_handlers[MEDIA_YAML] = FakeYamlMediaHandler()
client.app.resp_options.media_handlers[ASYNC_WITH_SYNC[0]] = (
SyncInterfaceMediaHandler()
Expand All @@ -1041,9 +1098,11 @@ def test_json_async_only_error(self, util):
with pytest.raises(NotImplementedError, match='requires the sync interface'):
client.simulate_get()

def test_add_xml_handler(self, client):
@pytest.mark.parametrize('accept', [MEDIA_XML, 'application/xhtml+xml'])
def test_add_xml_handler(self, client, enable_xml, accept):
enable_xml(client.app)
client.app.resp_options.media_handlers[MEDIA_XML] = FakeYamlMediaHandler()
res = client.simulate_get(headers={'Accept': 'application/xhtml+xml'})
res = client.simulate_get(headers={'Accept': accept})
assert res.content_type == MEDIA_XML
assert res.content == YAML[-1]

Expand All @@ -1067,7 +1126,12 @@ def test_add_xml_handler(self, client):
(f'text/html,{MEDIA_YAML};q=0.8,{MEDIA_JSON};q=0.8', MEDIA_JSON),
],
)
def test_hard_content_types(self, client, accept, content_type):
def test_hard_content_types(self, client, accept, content_type, enable_xml):
has_xml = enable_xml(client.app)
client.app.resp_options.default_media_type = 'my_type'
client.app.resp_options.media_handlers[MEDIA_YAML] = FakeYamlMediaHandler()
res = client.simulate_get(headers={'Accept': accept})
assert res.content_type == content_type
if has_xml or content_type != MEDIA_XML:
assert res.content_type == content_type
else:
assert res.content_type == MEDIA_JSON