Merge pull request #335 from kgriffs/issues/265
feat(HTTPError): Support custom media types for error responses
This commit is contained in:
@@ -3,13 +3,13 @@
|
||||
Error Handling
|
||||
==============
|
||||
|
||||
When something goes horribly (or mildly) wrong, you *could* manually set the
|
||||
When a request results in an error condition, you *could* manually set the
|
||||
error status, appropriate response headers, and even an error body using the
|
||||
``resp`` object. However, Falcon tries to make things a bit easier by
|
||||
providing a set of exceptions you can raise when something goes wrong. In fact,
|
||||
if Falcon catches any exception your responder throws that inherits from
|
||||
``falcon.HTTPError``, the framework will convert that exception to an
|
||||
appropriate HTTP error response.
|
||||
``resp`` object. However, Falcon tries to make things a bit easier and more
|
||||
consistent by providing a set of error classes you can raise from within
|
||||
your app. Falcon catches any exception that inherits from
|
||||
``falcon.HTTPError``, and automatically converts it to an appropriate HTTP
|
||||
response.
|
||||
|
||||
You may raise an instance of ``falcon.HTTPError`` directly, or use any one
|
||||
of a number of predefined error classes that try to be idiomatic in
|
||||
|
||||
@@ -53,7 +53,7 @@ class API(object):
|
||||
|
||||
__slots__ = ('_after', '_before', '_request_type', '_response_type',
|
||||
'_error_handlers', '_media_type',
|
||||
'_routes', '_sinks')
|
||||
'_routes', '_sinks', '_serialize_error')
|
||||
|
||||
def __init__(self, media_type=DEFAULT_MEDIA_TYPE, before=None, after=None,
|
||||
request_type=Request, response_type=Response):
|
||||
@@ -68,6 +68,7 @@ class API(object):
|
||||
self._response_type = response_type
|
||||
|
||||
self._error_handlers = []
|
||||
self._serialize_error = helpers.serialize_error
|
||||
|
||||
def __call__(self, env, start_response):
|
||||
"""WSGI `app` method.
|
||||
@@ -123,7 +124,7 @@ class API(object):
|
||||
raise
|
||||
|
||||
except HTTPError as ex:
|
||||
helpers.compose_error_response(req, resp, ex)
|
||||
self._compose_error_response(req, resp, ex)
|
||||
self._call_after_hooks(req, resp, resource)
|
||||
|
||||
#
|
||||
@@ -277,6 +278,35 @@ class API(object):
|
||||
# adds (will cause the most recently added one to win).
|
||||
self._error_handlers.insert(0, (exception, handler))
|
||||
|
||||
def set_error_serializer(self, serializer):
|
||||
"""Override the default serializer for instances of HTTPError.
|
||||
|
||||
When a responder raises an instance of HTTPError, Falcon converts
|
||||
it to an HTTP response automatically. The default serializer
|
||||
supports JSON and XML, but may be overridden by this method to
|
||||
use a custom serializer in order to support other media types.
|
||||
|
||||
Note:
|
||||
If a custom media type is used and the type includes a
|
||||
"+json" or "+xml" suffix, the default serializer will
|
||||
convert the error to JSON or XML, respectively. If this
|
||||
is not desirable, a custom error serializer may be used
|
||||
to override this behavior.
|
||||
|
||||
Args:
|
||||
serializer (callable): A function of the form
|
||||
``func(req, exception)``, where `req` is the request
|
||||
object that was passed to the responder method, and
|
||||
`exception` is an instance of falcon.HTTPError.
|
||||
The function must return a tuple of the form
|
||||
``(media_type, representation)``, or ``(None, None)``
|
||||
if the client does not support any of the
|
||||
available media types.
|
||||
|
||||
"""
|
||||
|
||||
self._serialize_error = serializer
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -333,6 +363,25 @@ class API(object):
|
||||
|
||||
return (responder, params, resource)
|
||||
|
||||
def _compose_error_response(self, req, resp, error):
|
||||
"""Composes a response for the given HTTPError instance."""
|
||||
|
||||
resp.status = error.status
|
||||
|
||||
if error.headers is not None:
|
||||
resp.set_headers(error.headers)
|
||||
|
||||
if error.serializable:
|
||||
media_type, body = self._serialize_error(req, error)
|
||||
|
||||
if body is not None:
|
||||
resp.body = body
|
||||
|
||||
# NOTE(kgriffs): This must be done AFTER setting the headers
|
||||
# from error.headers so that we will override Content-Type if
|
||||
# it was mistakenly set by the app.
|
||||
resp.content_type = media_type
|
||||
|
||||
def _call_after_hooks(self, req, resp, resource):
|
||||
"""Executes each of the global "after" hooks, in turn."""
|
||||
|
||||
|
||||
@@ -141,28 +141,68 @@ def get_body(resp, wsgi_file_wrapper=None):
|
||||
return []
|
||||
|
||||
|
||||
def compose_error_response(req, resp, ex):
|
||||
def serialize_error(req, exception):
|
||||
"""Serialize the given instance of HTTPError.
|
||||
|
||||
This function determines which of the supported media types, if
|
||||
any, are acceptable by the client, and serializes the error
|
||||
to the preferred type.
|
||||
|
||||
Currently, JSON and XML are the only supported media types. If the
|
||||
client accepts both JSON and XML with equal weight, JSON will be
|
||||
chosen.
|
||||
|
||||
Other media types can be supported by using a custom error serializer.
|
||||
|
||||
Note:
|
||||
If a custom media type is used and the type includes a
|
||||
"+json" or "+xml" suffix, the error will be serialized
|
||||
to JSON or XML, respectively. If this behavior is not
|
||||
desirable, a custom error serializer may be used to
|
||||
override this one.
|
||||
|
||||
Args:
|
||||
req: Instance of falcon.Request
|
||||
exception: Instance of falcon.HTTPError
|
||||
|
||||
Returns:
|
||||
A tuple of the form ``(media_type, representation)``, or
|
||||
``(None, None)`` if the client does not support any of the
|
||||
available media types.
|
||||
|
||||
"""
|
||||
representation = None
|
||||
|
||||
preferred = req.client_prefers(('application/xml',
|
||||
'text/xml',
|
||||
'application/json'))
|
||||
|
||||
if preferred is None:
|
||||
# NOTE(kgriffs): See if the client expects a custom media
|
||||
# type based on something Falcon supports. Returning something
|
||||
# is probably better than nothing, but if that is not
|
||||
# desired, this behavior can be customized by adding a
|
||||
# custom HTTPError serializer for the custom type.
|
||||
accept = req.accept.lower()
|
||||
|
||||
# NOTE(kgriffs): Simple heuristic, but it's fast, and
|
||||
# should be sufficiently accurate for our purposes. Does
|
||||
# not take into account weights if both types are
|
||||
# acceptable (simply chooses JSON). If it turns out we
|
||||
# need to be more sophisticated, we can always change it
|
||||
# later (YAGNI).
|
||||
if '+json' in accept:
|
||||
preferred = 'application/json'
|
||||
elif '+xml' in accept:
|
||||
preferred = 'application/xml'
|
||||
|
||||
if preferred is not None:
|
||||
if preferred == 'application/json':
|
||||
resp.body = ex.json()
|
||||
representation = exception.json()
|
||||
else:
|
||||
resp.body = ex.xml()
|
||||
representation = exception.xml()
|
||||
|
||||
resp.status = ex.status
|
||||
|
||||
if ex.headers is not None:
|
||||
resp.set_headers(ex.headers)
|
||||
|
||||
# NOTE(kgriffs): Do this after setting headers from ex.headers,
|
||||
# so that we will override Content-Type if it was mistakenly set
|
||||
# by the app.
|
||||
if resp.body is not None:
|
||||
resp.content_type = preferred
|
||||
return (preferred, representation)
|
||||
|
||||
|
||||
def compile_uri_template(template):
|
||||
|
||||
@@ -35,7 +35,16 @@ class HTTPError(Exception):
|
||||
|
||||
Attributes:
|
||||
status (str): HTTP status line, such as "748 Confounded by Ponies".
|
||||
title (str): Error title to send to the client.
|
||||
serializable (bool): Returns *True* IFF the error should be
|
||||
serialized when composing the HTTP response.
|
||||
|
||||
Note:
|
||||
If an app sets a custom error serializer, it will only
|
||||
be called when the error's `serializable` property is
|
||||
``True``.
|
||||
|
||||
title (str): Error title to send to the client. Will be ``None`` if
|
||||
the error should result in an HTTP response with an empty body.
|
||||
description (str): Description of the error to send to the client.
|
||||
headers (dict): Extra headers to add to the response.
|
||||
link (str): An href that the client can provide to the user for
|
||||
@@ -45,15 +54,21 @@ class HTTPError(Exception):
|
||||
|
||||
Args:
|
||||
status (str): HTTP status code and text, such as "400 Bad Request"
|
||||
title (str): Human-friendly error title. Set to *None* if you wish
|
||||
title (str): Human-friendly error title. Set to ``None`` if you wish
|
||||
Falcon to return an empty response body (all remaining args will
|
||||
be ignored except for headers.) Do this only when you don't
|
||||
wish to disclose sensitive information about why a request was
|
||||
refused, or if the status and headers are self-descriptive.
|
||||
be ignored except for headers.) This will set the error's
|
||||
`serializable` property to ``False``.
|
||||
|
||||
Note:
|
||||
Set `title` to ``None`` when you don't wish to disclose
|
||||
sensitive information about why a request was refused,
|
||||
when the status and headers are self-descriptive, or when
|
||||
the HTTP specification forbids returning a body for the
|
||||
status code in question.
|
||||
|
||||
Keyword Args:
|
||||
description (str): Human-friendly description of the error, along with
|
||||
a helpful suggestion or two (default *None*).
|
||||
a helpful suggestion or two (default ``None``).
|
||||
headers (dict or list): A dictionary of header names and values
|
||||
to set, or list of (name, value) tuples. Both names and
|
||||
values must be of type str or StringType, and only character
|
||||
@@ -72,9 +87,9 @@ class HTTPError(Exception):
|
||||
than a dict.
|
||||
|
||||
headers (dict): Extra headers to return in the
|
||||
response to the client (default *None*).
|
||||
response to the client (default ``None``).
|
||||
href (str): A URL someone can visit to find out more information
|
||||
(default *None*). Unicode characters are percent-encoded.
|
||||
(default ``None``). Unicode characters are percent-encoded.
|
||||
href_text (str): If href is given, use this as the friendly
|
||||
title/description for the link (defaults to "API documentation
|
||||
for this error").
|
||||
@@ -92,7 +107,7 @@ class HTTPError(Exception):
|
||||
'code'
|
||||
)
|
||||
|
||||
def __init__(self, status, title, description=None, headers=None,
|
||||
def __init__(self, status, title=None, description=None, headers=None,
|
||||
href=None, href_text=None, code=None):
|
||||
self.status = status
|
||||
self.title = title
|
||||
@@ -108,19 +123,28 @@ class HTTPError(Exception):
|
||||
else:
|
||||
self.link = None
|
||||
|
||||
def json(self):
|
||||
"""Returns a pretty JSON-encoded version of the exception
|
||||
@property
|
||||
def serializable(self):
|
||||
return self.title is not None
|
||||
|
||||
def raw(self, obj_type=dict):
|
||||
"""Returns a raw dictionary representing the error.
|
||||
|
||||
This method can be useful when serializing the error to hash-like
|
||||
media types, such as YAML, JSON, and MessagePack.
|
||||
|
||||
Args:
|
||||
obj_type: A dict-like type that will be used to store the
|
||||
error information (default *dict*).
|
||||
|
||||
Returns:
|
||||
A JSON representation of the exception, or
|
||||
None if title was set to *None* in the initializer.
|
||||
A dictionary populated with the error's title, description, etc.
|
||||
|
||||
"""
|
||||
|
||||
if self.title is None:
|
||||
return None
|
||||
assert self.serializable
|
||||
|
||||
obj = OrderedDict()
|
||||
obj = obj_type()
|
||||
obj['title'] = self.title
|
||||
|
||||
if self.description:
|
||||
@@ -132,20 +156,29 @@ class HTTPError(Exception):
|
||||
if self.link:
|
||||
obj['link'] = self.link
|
||||
|
||||
return obj
|
||||
|
||||
def json(self):
|
||||
"""Returns a pretty-printed JSON representation of the error.
|
||||
|
||||
Returns:
|
||||
A JSON document for the error.
|
||||
|
||||
"""
|
||||
|
||||
obj = self.raw(OrderedDict)
|
||||
return json.dumps(obj, indent=4, separators=(',', ': '),
|
||||
ensure_ascii=False)
|
||||
|
||||
def xml(self):
|
||||
"""Returns an XML-encoded version of the exception
|
||||
"""Returns an XML-encoded representation of the error.
|
||||
|
||||
Returns:
|
||||
An XML representation of the exception, or
|
||||
None if title was set to *None* in the initializer.
|
||||
An XML document for the error.
|
||||
|
||||
"""
|
||||
|
||||
if self.title is None:
|
||||
return None
|
||||
assert self.serializable
|
||||
|
||||
error_element = et.Element('error')
|
||||
et.SubElement(error_element, 'title').text = self.title
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import json
|
||||
import xml.etree.ElementTree as et
|
||||
|
||||
import ddt
|
||||
from testtools.matchers import raises, Not
|
||||
import yaml
|
||||
|
||||
import falcon.testing as testing
|
||||
import falcon
|
||||
@@ -115,6 +117,7 @@ class ServiceUnavailableResource:
|
||||
raise falcon.HTTPServiceUnavailable('Oops', 'Stand by...', 60)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestHTTPError(testing.TestBase):
|
||||
|
||||
def before(self):
|
||||
@@ -170,14 +173,14 @@ class TestHTTPError(testing.TestBase):
|
||||
headers={'Accept': 'application/xml'})
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_400)
|
||||
|
||||
expected_xml = (b'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
expected_xml = (b'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
b'<error><title>No-can-do</title></error>')
|
||||
|
||||
self.assertEqual(body, [expected_xml])
|
||||
|
||||
def test_client_does_not_accept_json_or_xml(self):
|
||||
headers = {
|
||||
'Accept': 'application/soap+xml',
|
||||
'Accept': 'application/x-yaml',
|
||||
'X-Error-Title': 'Storage service down',
|
||||
'X-Error-Description': ('The configured storage service is not '
|
||||
'responding to requests. Please contact '
|
||||
@@ -189,6 +192,41 @@ class TestHTTPError(testing.TestBase):
|
||||
self.assertEqual(self.srmock.status, headers['X-Error-Status'])
|
||||
self.assertEqual(body, [])
|
||||
|
||||
def test_custom_error_serializer(self):
|
||||
headers = {
|
||||
'Accept': 'application/x-yaml',
|
||||
'X-Error-Title': 'Storage service down',
|
||||
'X-Error-Description': ('The configured storage service is not '
|
||||
'responding to requests. Please contact '
|
||||
'your service provider'),
|
||||
'X-Error-Status': falcon.HTTP_503
|
||||
}
|
||||
|
||||
expected_yaml = (b'{code: 10042, description: The configured storage '
|
||||
b'service is not responding to requests.\n '
|
||||
b'Please contact your service provider, title: '
|
||||
b'Storage service down}\n')
|
||||
|
||||
def my_serializer(req, exception):
|
||||
representation = None
|
||||
|
||||
preferred = req.client_prefers(('application/x-yaml',
|
||||
'application/json'))
|
||||
|
||||
if preferred is not None:
|
||||
if preferred == 'application/json':
|
||||
representation = exception.json()
|
||||
else:
|
||||
representation = yaml.dump(exception.raw(),
|
||||
encoding=None)
|
||||
|
||||
return (preferred, representation)
|
||||
|
||||
self.api.set_error_serializer(my_serializer)
|
||||
body = self.simulate_request('/fail', headers=headers)
|
||||
self.assertEqual(self.srmock.status, headers['X-Error-Status'])
|
||||
self.assertEqual(body, [expected_yaml])
|
||||
|
||||
def test_client_does_not_accept_anything(self):
|
||||
headers = {
|
||||
'Accept': '45087gigo;;;;',
|
||||
@@ -203,10 +241,13 @@ class TestHTTPError(testing.TestBase):
|
||||
self.assertEqual(self.srmock.status, headers['X-Error-Status'])
|
||||
self.assertEqual(body, [])
|
||||
|
||||
def test_forbidden(self):
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
@ddt.data(
|
||||
'application/json',
|
||||
'application/vnd.company.system.project.resource+json;v=1.1',
|
||||
'application/json-patch+json',
|
||||
)
|
||||
def test_forbidden(self, media_type):
|
||||
headers = {'Accept': media_type}
|
||||
|
||||
expected_body = {
|
||||
'title': 'Request denied',
|
||||
@@ -227,9 +268,7 @@ class TestHTTPError(testing.TestBase):
|
||||
self.assertEqual(json.loads(body), expected_body)
|
||||
|
||||
def test_epic_fail_json(self):
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
headers = {'Accept': 'application/json'}
|
||||
|
||||
expected_body = {
|
||||
'title': 'Internet crashed',
|
||||
@@ -249,10 +288,14 @@ class TestHTTPError(testing.TestBase):
|
||||
self.assertThat(lambda: json.loads(body), Not(raises(ValueError)))
|
||||
self.assertEqual(json.loads(body), expected_body)
|
||||
|
||||
def test_epic_fail_xml(self):
|
||||
headers = {
|
||||
'Accept': 'text/xml'
|
||||
}
|
||||
@ddt.data(
|
||||
'text/xml',
|
||||
'application/xml',
|
||||
'application/vnd.company.system.project.resource+xml;v=1.1',
|
||||
'application/atom+xml',
|
||||
)
|
||||
def test_epic_fail_xml(self, media_type):
|
||||
headers = {'Accept': media_type}
|
||||
|
||||
expected_body = ('<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<error>' +
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
coverage
|
||||
ddt
|
||||
nose
|
||||
ordereddict
|
||||
pyyaml
|
||||
requests
|
||||
six
|
||||
testtools
|
||||
|
||||
Reference in New Issue
Block a user