Repository: falconry/falcon

Issue:
Support XML response bodies from exceptions



Discussion:

Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -117,12 +117,7 @@ def __call__(self, env, start_response):
                     raise
 
         except HTTPError as ex:
-            resp.status = ex.status
-            if ex.headers is not None:
-                resp.set_headers(ex.headers)
-
-            if req.client_accepts('application/json'):
-                resp.body = ex.json()
+            helpers.compose_error_response(req, resp, ex)
 
         #
         # Set status and headers
diff --git a/falcon/api_helpers.py b/falcon/api_helpers.py
--- a/falcon/api_helpers.py
+++ b/falcon/api_helpers.py
@@ -142,6 +142,36 @@ def get_body(resp, wsgi_file_wrapper=None):
     return []
 
 
+def compose_error_response(req, resp, ex):
+    resp.status = ex.status
+
+    content_type_override = None
+
+    if ex.headers is not None:
+        resp.set_headers(ex.headers)
+
+        # PERF(kgriffs): Only call resp.content_type if
+        # we have some headers from the exception that
+        # could have caused it to be set.
+        content_type_override = resp.content_type
+
+    if content_type_override is not None:
+        # Application is forcing the response content-type
+        preferred = content_type_override
+    else:
+        preferred = req.client_prefers(('application/xml',
+                                        'text/xml',
+                                        'application/json'))
+
+    if preferred is not None:
+        resp.content_type = preferred
+
+        if preferred == 'application/json':
+            resp.body = ex.json()
+        elif preferred.endswith('/xml'):
+            resp.body = ex.xml()
+
+
 def compile_uri_template(template):
     """Compile the given URI template string into a pattern matcher.
 
diff --git a/falcon/http_error.py b/falcon/http_error.py
--- a/falcon/http_error.py
+++ b/falcon/http_error.py
@@ -14,6 +14,7 @@
 
 import json
 import sys
+import xml.etree.ElementTree as et
 
 if sys.version_info < (2, 7):  # pragma: no cover
     # NOTE(kgriffs): We could use the module from PyPI, but ordering isn't
@@ -94,8 +95,8 @@ def json(self):
         """Returns a pretty JSON-encoded version of the exception
 
         Returns:
-            A JSON representation of the exception except the status line, or
-            NONE if title was set to *None*.
+            A JSON representation of the exception, or
+            None if title was set to *None* in the initializer.
 
         """
 
@@ -116,3 +117,33 @@ def json(self):
 
         return json.dumps(obj, indent=4, separators=(',', ': '),
                           ensure_ascii=False)
+
+    def xml(self):
+        """Returns an XML-encoded version of the exception
+
+        Returns:
+            An XML representation of the exception, or
+            None if title was set to *None* in the initializer.
+
+        """
+
+        if self.title is None:
+            return None
+
+        error_element = et.Element('error')
+        et.SubElement(error_element, 'title').text = self.title
+
+        if self.description:
+            et.SubElement(error_element, 'description').text = self.description
+
+        if self.code:
+            et.SubElement(error_element, 'code').text = str(self.code)
+
+        if self.link:
+            link_element = et.SubElement(error_element, 'link')
+
+            for key in ('text', 'href', 'rel'):
+                et.SubElement(link_element, key).text = self.link[key]
+
+        return (b'<?xml version="1.0" encoding="UTF-8"?>' +
+                et.tostring(error_element, encoding='utf-8'))


Test patch:
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -1,4 +1,7 @@
+# -*- coding: utf-8
+
 import json
+import xml.etree.ElementTree as et
 
 from testtools.matchers import raises, Not
 
@@ -28,7 +31,8 @@ def on_put(self, req, resp):
             'Internet crashed',
             'Catastrophic weather event due to climate change.',
             href='http://example.com/api/climate',
-            href_text='Drill baby drill!')
+            href_text='Drill baby drill!',
+            code=8733224)
 
     def on_patch(self, req, resp):
         raise falcon.HTTPError(falcon.HTTP_400, 'No-can-do')
@@ -159,12 +163,22 @@ def test_base_class(self):
         self.assertThat(lambda: json.loads(body), Not(raises(ValueError)))
         self.assertEqual(json.loads(body), expected_body)
 
-    def test_no_description(self):
+    def test_no_description_json(self):
         body = self.simulate_request('/fail', method='PATCH')
         self.assertEqual(self.srmock.status, falcon.HTTP_400)
         self.assertEqual(body, [b'{\n    "title": "No-can-do"\n}'])
 
-    def test_client_does_not_accept_json(self):
+    def test_no_description_xml(self):
+        body = self.simulate_request('/fail', method='PATCH',
+                                     headers={'Accept': 'application/xml'})
+        self.assertEqual(self.srmock.status, falcon.HTTP_400)
+
+        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',
             'X-Error-Title': 'Storage service down',
@@ -215,7 +229,7 @@ def test_forbidden(self):
         self.assertThat(lambda: json.loads(body), Not(raises(ValueError)))
         self.assertEqual(json.loads(body), expected_body)
 
-    def test_epic_fail(self):
+    def test_epic_fail_json(self):
         headers = {
             'Accept': 'application/json'
         }
@@ -223,6 +237,7 @@ def test_epic_fail(self):
         expected_body = {
             'title': 'Internet crashed',
             'description': 'Catastrophic weather event due to climate change.',
+            'code': 8733224,
             'link': {
                 'text': 'Drill baby drill!',
                 'href': 'http://example.com/api/climate',
@@ -237,7 +252,33 @@ def test_epic_fail(self):
         self.assertThat(lambda: json.loads(body), Not(raises(ValueError)))
         self.assertEqual(json.loads(body), expected_body)
 
-    def test_unicode(self):
+    def test_epic_fail_xml(self):
+        headers = {
+            'Accept': 'text/xml'
+        }
+
+        expected_body = ('<?xml version="1.0" encoding="UTF-8"?>' +
+                         '<error>' +
+                         '<title>Internet crashed</title>' +
+                         '<description>' +
+                         'Catastrophic weather event due to climate change.' +
+                         '</description>' +
+                         '<code>8733224</code>' +
+                         '<link>' +
+                         '<text>Drill baby drill!</text>' +
+                         '<href>http://example.com/api/climate</href>' +
+                         '<rel>help</rel>' +
+                         '</link>' +
+                         '</error>')
+
+        body = self.simulate_request('/fail', headers=headers, method='PUT',
+                                     decode='utf-8')
+
+        self.assertEqual(self.srmock.status, falcon.HTTP_792)
+        self.assertThat(lambda: et.fromstring(body), Not(raises(ValueError)))
+        self.assertEqual(body, expected_body)
+
+    def test_unicode_json(self):
         unicode_resource = UnicodeFaultyResource()
 
         expected_body = {
@@ -257,6 +298,30 @@ def test_unicode(self):
         self.assertEqual(self.srmock.status, falcon.HTTP_792)
         self.assertEqual(expected_body, json.loads(body))
 
+    def test_unicode_xml(self):
+        unicode_resource = UnicodeFaultyResource()
+
+        expected_body = (u'<?xml version="1.0" encoding="UTF-8"?>' +
+                         u'<error>' +
+                         u'<title>Internet çrashed!</title>' +
+                         u'<description>' +
+                         u'Çatastrophic weather event' +
+                         u'</description>' +
+                         u'<link>' +
+                         u'<text>Drill báby drill!</text>' +
+                         u'<href>http://example.com/api/%C3%A7limate</href>' +
+                         u'<rel>help</rel>' +
+                         u'</link>' +
+                         u'</error>')
+
+        self.api.add_route('/unicode', unicode_resource)
+        body = self.simulate_request('/unicode', decode='utf-8',
+                                     headers={'accept': 'application/xml'})
+
+        self.assertTrue(unicode_resource.called)
+        self.assertEqual(self.srmock.status, falcon.HTTP_792)
+        self.assertEqual(expected_body, body)
+
     def test_401(self):
         self.api.add_route('/401', UnauthorizedResource())
         self.simulate_request('/401')
@@ -306,14 +371,14 @@ def test_411(self):
         self.assertEqual(parsed_body['description'], 'description')
 
     def test_416_default_media_type(self):
-        self.api = falcon.API('application/xml')
+        self.api = falcon.API()
         self.api.add_route('/416', RangeNotSatisfiableResource())
-        body = self.simulate_request('/416')
+        body = self.simulate_request('/416', headers={'accept': 'text/xml'})
 
         self.assertEqual(self.srmock.status, falcon.HTTP_416)
         self.assertEqual(body, [])
         self.assertIn(('content-range', 'bytes */123456'), self.srmock.headers)
-        self.assertIn(('content-type', 'application/xml'), self.srmock.headers)
+        self.assertIn(('content-type', 'text/xml'), self.srmock.headers)
         self.assertNotIn(('content-length', '0'), self.srmock.headers)
 
     def test_416_custom_media_type(self):
diff --git a/tests/test_req_vars.py b/tests/test_req_vars.py
--- a/tests/test_req_vars.py
+++ b/tests/test_req_vars.py
@@ -140,6 +140,12 @@ def test_client_accepts(self):
         self.assertTrue(req.client_accepts('text/plain'))
         self.assertTrue(req.client_accepts('application/json'))
 
+    def test_client_accepts_bogus(self):
+        headers = {'Accept': '~'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertFalse(req.client_accepts('text/plain'))
+        self.assertFalse(req.client_accepts('application/json'))
+
     def test_client_accepts_props(self):
         headers = {'Accept': 'application/xml'}
         req = Request(testing.create_environ(headers=headers))