Repository: falconry/falcon

Issue:
JSON errors only returned for one type of Accept header
I'd like to provide users of my service the ability to specify a custom media type in their Accept header.  When I do so, I lose the ability to have the errors rendered as JSON output due to this line:  https://github.com/racker/falcon/blob/master/falcon/api.py#L114

I'd submit a pull request, but am not sure what a good approach might be to signify that a JSON-formatted error is an appropriate response.  As an example, this is similar to the custom media type I'm allowing:  "application/vnd.company.system.project.resource+json;v=1.1"



Discussion:
Thanks for bringing this up. I've been wanting to make error rendering more flexible for a while now. Let's see if we can sneak something in to 0.2 before we cut the release in a few weeks. I'll target it to that milestone...

P.S. - I am super busy with OpenStack Summit prep (the conference is next week) so may not be able to circle back on this for a week or two. In the meantime, feel free to discuss with xris in #openstack-marconi on Freenode. Thanks!

Will do, thanks for looking into it.

In the meantime, my workaround would be to set the resp.body with ex.json() before raising it.

Sounds like a plan. You could do that in an "after" hook.


Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -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 @@ def __init__(self, media_type=DEFAULT_MEDIA_TYPE, before=None, after=None,
         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 @@ def __call__(self, env, start_response):
                     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 @@ def add_error_handler(self, exception, handler=None):
         # 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 @@ def _get_responder(self, req):
 
         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."""
 
diff --git a/falcon/api_helpers.py b/falcon/api_helpers.py
--- a/falcon/api_helpers.py
+++ b/falcon/api_helpers.py
@@ -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()
-
-    resp.status = ex.status
-
-    if ex.headers is not None:
-        resp.set_headers(ex.headers)
+            representation = exception.xml()
 
-    # 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):
diff --git a/falcon/http_error.py b/falcon/http_error.py
--- a/falcon/http_error.py
+++ b/falcon/http_error.py
@@ -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 @@ def __init__(self, status, title, description=None, headers=None,
         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 @@ def json(self):
         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


Test patch:
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -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 @@ def on_get(self, req, resp):
         raise falcon.HTTPServiceUnavailable('Oops', 'Stand by...', 60)
 
 
+@ddt.ddt
 class TestHTTPError(testing.TestBase):
 
     def before(self):
@@ -170,14 +173,14 @@ def test_no_description_xml(self):
                                      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 @@ def test_client_does_not_accept_json_or_xml(self):
         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 @@ def test_client_does_not_accept_anything(self):
         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 @@ def test_forbidden(self):
         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 @@ def test_epic_fail_json(self):
         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>' +