Merge pull request #335 from kgriffs/issues/265

feat(HTTPError): Support custom media types for error responses
This commit is contained in:
Kurt Griffiths
2014-10-15 17:20:24 -05:00
6 changed files with 222 additions and 55 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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):

View File

@@ -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

View File

@@ -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>' +

View File

@@ -1,6 +1,8 @@
coverage
ddt
nose
ordereddict
pyyaml
requests
six
testtools