Repository: falconry/falcon

Issue:
Accept parsing doesn't handle application/*



Discussion:
Also add more tests for wacky Accept strings

It does not handle quality parameter, either.  Example:

```
Accept: application/xml, */*; q=0.0
```

Should be rejected if the server only serves json, but the current "parsing" mechanism is buggy.


Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -18,6 +18,7 @@
 
 from datetime import datetime
 
+import mimeparse
 import six
 
 from falcon.exceptions import HTTPBadRequest
@@ -30,6 +31,8 @@
 TRUE_STRINGS = ('true', 'True', 'yes')
 FALSE_STRINGS = ('false', 'False', 'no')
 
+MEDIA_TYPES_XML = ('application/xml', 'text/xml')
+
 
 class InvalidHeaderValueError(HTTPBadRequest):
     def __init__(self, msg, href=None, href_text=None):
@@ -149,19 +152,73 @@ def client_accepts_json(self):
     @property
     def client_accepts_xml(self):
         """Return True if the Accept header indicates XML support."""
-        return self.client_accepts('application/xml')
+        return self.client_accepts(MEDIA_TYPES_XML)
 
-    def client_accepts(self, media_type):
-        """Return True if the Accept header indicates a media type support."""
+    def client_accepts(self, media_types):
+        """Returns the client's preferred media type.
 
-        accept = self._get_header_by_wsgi_name('ACCEPT')
-        return ((accept is not None) and
-                ((media_type in accept) or ('*/*' in accept)))
+        Args:
+            media_types: One or more media types. May be a single string (
+                of type str), or an iterable collection of strings.
+
+        Returns:
+            True IFF the client has indicated in the Accept header that
+            they accept at least one of the specified media types.
+        """
+
+        accept = self.accept
+
+        # PERF(kgriffs): Usually the following will be true, so
+        # try it first.
+        if isinstance(media_types, str):
+            if (accept == media_types) or (accept == '*/*'):
+                return accept
+
+            # NOTE(kgriffs): Convert to a collection to be compatible
+            # with mimeparse.best_matchapplication/xhtml+xml
+            media_types = (media_types,)
+
+        # NOTE(kgriffs): Heuristic to quickly check another common case. If
+        # accept is a single type, and it is found in media_types verbatim,
+        # return the media type immediately.
+        elif accept in media_types:
+            return accept
+
+        # Fall back to full-blown parsing
+        preferred_type = self.client_prefers(media_types)
+        return preferred_type is not None
+
+    def client_prefers(self, media_types):
+        """Returns the client's preferred media type given several choices.
+
+        Args:
+            media_types: One or more media types from which to choose the
+                client's preferred type. This value MUST be an iterable
+                collection of strings.
+
+        Returns:
+            The client's preferred media type, based on the Accept header,
+            or None if the client does not accept any of the specified
+            types.
+        """
+
+        try:
+            # NOTE(kgriffs): best_match will return '' if no match is found
+            preferred_type = mimeparse.best_match(media_types, self.accept)
+        except ValueError:
+            # Value for the accept header was not formatted correctly
+            preferred_type = ''
+
+        return (preferred_type if preferred_type else None)
 
     @property
     def accept(self):
-        """Value of the Accept header, or None if not found."""
-        return self._get_header_by_wsgi_name('ACCEPT')
+        """Value of the Accept header, or */* if not found per RFC."""
+        accept = self._get_header_by_wsgi_name('ACCEPT')
+
+        # NOTE(kgriffs): Per RFC, missing accept header is
+        # equivalent to '*/*'
+        return '*/*' if accept is None else accept
 
     @property
     def app(self):
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,9 @@
 VERSION = imp.load_source('version', path.join('.', 'falcon', 'version.py'))
 VERSION = VERSION.__version__
 
-REQUIRES = ['six']
+# NOTE(kgriffs): python-mimeparse is newer than mimeparse, supports Py3
+# TODO(kgriffs): Fork and optimize/modernize python-mimeparse
+REQUIRES = ['six', 'python-mimeparse']
 if sys.version_info < (2, 7):
     REQUIRES.append('ordereddict')
 


Test patch:
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -91,9 +91,7 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
         'REQUEST_METHOD': method,
         'PATH_INFO': path,
         'QUERY_STRING': query_string,
-        'HTTP_ACCEPT': '*/*',
-        'HTTP_USER_AGENT': ('curl/7.24.0 (x86_64-apple-darwin12.0) '
-                            'libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5'),
+        'HTTP_USER_AGENT': 'curl/7.24.0 (x86_64-apple-darwin12.0)',
         'REMOTE_PORT': '65133',
         'RAW_URI': '/',
         'REMOTE_ADDR': '127.0.0.1',
@@ -131,12 +129,6 @@ def _add_headers_to_environ(env, headers):
     for name, value in headers.items():
         name = name.upper().replace('-', '_')
 
-        if value is None:
-            if name == 'ACCEPT' or name == 'USER_AGENT':
-                del env['HTTP_' + name]
-
-            continue
-
         if name == 'CONTENT_TYPE':
             env[name] = value.strip()
         elif name == 'CONTENT_LENGTH':
diff --git a/falcon/tests/test_httperror.py b/falcon/tests/test_httperror.py
--- a/falcon/tests/test_httperror.py
+++ b/falcon/tests/test_httperror.py
@@ -175,7 +175,7 @@ def test_client_does_not_accept_json(self):
 
     def test_client_does_not_accept_anything(self):
         headers = {
-            'Accept': None,
+            'Accept': '45087gigo;;;;',
             'X-Error-Title': 'Storage service down',
             'X-Error-Description': ('The configured storage service is not '
                                     'responding to requests. Please contact '
diff --git a/falcon/tests/test_req_vars.py b/falcon/tests/test_req_vars.py
--- a/falcon/tests/test_req_vars.py
+++ b/falcon/tests/test_req_vars.py
@@ -87,26 +87,107 @@ def test_client_accepts(self):
         headers = {'Accept': 'application/xml'}
         req = Request(testing.create_environ(headers=headers))
         self.assertTrue(req.client_accepts('application/xml'))
+        self.assertTrue(req.client_accepts(['application/xml']))
 
         headers = {'Accept': '*/*'}
         req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts(['application/xml']))
+
+        headers = {}  # NOTE(kgriffs): Equivalent to '*/*' per RFC
+        req = Request(testing.create_environ(headers=headers))
         self.assertTrue(req.client_accepts('application/xml'))
+        self.assertTrue(req.client_accepts(['application/xml']))
 
         headers = {'Accept': 'application/json'}
         req = Request(testing.create_environ(headers=headers))
-        self.assertFalse(req.client_accepts('application/xml'))
+        self.assertFalse(req.client_accepts(['application/xml']))
 
         headers = {'Accept': 'application/xm'}
         req = Request(testing.create_environ(headers=headers))
-        self.assertFalse(req.client_accepts('application/xml'))
+        self.assertFalse(req.client_accepts(['application/xml']))
+
+        headers = {'Accept': 'application/*'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts(['application/json']))
+        self.assertTrue(req.client_accepts(['application/xml']))
+        self.assertTrue(req.client_accepts(['application/json',
+                                            'application/xml']))
+
+        headers = {'Accept': 'text/*'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts('text/plain'))
+        self.assertTrue(req.client_accepts('text/csv'))
+        self.assertFalse(req.client_accepts('application/xhtml+xml'))
+
+        headers = {'Accept': 'text/*, application/xhtml+xml; q=0.0'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts('text/plain'))
+        self.assertTrue(req.client_accepts('text/csv'))
+        self.assertTrue(req.client_accepts('application/xhtml+xml'))
+        self.assertTrue(req.client_accepts(('application/xhtml+xml',
+                                            'text/plain',
+                                            'text/csv')))
+
+        headers = {'Accept': 'text/*; q=0.1, application/xhtml+xml; q=0.5'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts('text/plain'))
+
+        headers = {'Accept': 'text/*,         application/*'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts('text/plain'))
+        self.assertTrue(req.client_accepts('application/json'))
+
+        headers = {'Accept': 'text/*,application/*'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts('text/plain'))
+        self.assertTrue(req.client_accepts('application/json'))
 
     def test_client_accepts_props(self):
         headers = {'Accept': 'application/xml'}
         req = Request(testing.create_environ(headers=headers))
-
         self.assertTrue(req.client_accepts_xml)
         self.assertFalse(req.client_accepts_json)
 
+        headers = {'Accept': 'text/xml'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts_xml)
+
+        headers = {'Accept': 'text/*'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts_xml)
+
+        headers = {'Accept': 'text/xml, application/xml'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts_xml)
+
+        headers = {'Accept': 'application/json'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertFalse(req.client_accepts_xml)
+        self.assertTrue(req.client_accepts_json)
+
+        headers = {'Accept': 'application/json, application/xml'}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertTrue(req.client_accepts_xml)
+        self.assertTrue(req.client_accepts_json)
+
+    def test_client_prefers(self):
+        headers = {'Accept': 'application/xml'}
+        req = Request(testing.create_environ(headers=headers))
+        preferred_type = req.client_prefers(['application/xml'])
+        self.assertEquals(preferred_type, 'application/xml')
+
+        headers = {'Accept': '*/*'}
+        preferred_type = req.client_prefers(('application/xml',
+                                             'application/json'))
+
+        # NOTE(kgriffs): If client doesn't care, "preferr" the first one
+        self.assertEquals(preferred_type, 'application/xml')
+
+        headers = {'Accept': 'text/*; q=0.1, application/xhtml+xml; q=0.5'}
+        req = Request(testing.create_environ(headers=headers))
+        preferred_type = req.client_prefers(['application/xhtml+xml'])
+        self.assertEquals(preferred_type, 'application/xhtml+xml')
+
     def test_range(self):
         headers = {'Range': '10-'}
         req = Request(testing.create_environ(headers=headers))
@@ -124,8 +205,7 @@ def test_range(self):
         req = Request(testing.create_environ(headers=headers))
         self.assertIs(req.range, None)
 
-        headers = {'Range': None}
-        req = Request(testing.create_environ(headers=headers))
+        req = Request(testing.create_environ())
         self.assertIs(req.range, None)
 
     def test_range_invalid(self):
@@ -230,9 +310,11 @@ def test_attribute_headers(self):
         date = testing.httpnow()
         hash = 'fa0d1a60ef6616bb28038515c8ea4cb2'
         auth = 'HMAC_SHA1 c590afa9bb59191ffab30f223791e82d3fd3e3af'
-        agent = 'curl/7.24.0 (x86_64-apple-darwin12.0)'
+        agent = 'testing/1.0.1'
+        default_agent = 'curl/7.24.0 (x86_64-apple-darwin12.0)'
 
-        self._test_attribute_header('Accept', 'x-falcon', 'accept')
+        self._test_attribute_header('Accept', 'x-falcon', 'accept',
+                                    default='*/*')
 
         self._test_attribute_header('Authorization', auth, 'auth')
 
@@ -248,7 +330,8 @@ def test_attribute_headers(self):
         self._test_attribute_header('If-Unmodified-Since', date,
                                     'if_unmodified_since')
 
-        self._test_attribute_header('User-Agent', agent, 'user_agent')
+        self._test_attribute_header('User-Agent', agent, 'user_agent',
+                                    default=default_agent)
 
     def test_method(self):
         self.assertEquals(self.req.method, 'GET')
@@ -270,11 +353,10 @@ def test_content_length_method(self):
     # Helpers
     # -------------------------------------------------------------------------
 
-    def _test_attribute_header(self, name, value, attr):
+    def _test_attribute_header(self, name, value, attr, default=None):
         headers = {name: value}
         req = Request(testing.create_environ(headers=headers))
         self.assertEquals(getattr(req, attr), value)
 
-        headers = {name: None}
-        req = Request(testing.create_environ(headers=headers))
-        self.assertEqual(getattr(req, attr), None)
+        req = Request(testing.create_environ())
+        self.assertEqual(getattr(req, attr), default)