Repository: falconry/falcon

Issue:
Return if-modified-since, if-unmodified-since as datetime.datetime
Currently, the HTTP date string is returned. It might as well be parsed, since the app will need to do that anyway before it can use the value.



Discussion:
Adding some good links

This explains why python's email module may not be 100% correct:
http://stackoverflow.com/questions/1471987/how-do-i-parse-an-http-date-string-in-python

This is a function that implements correct parsing:
http://werkzeug.pocoo.org/docs/0.10/http/#werkzeug.http.parse_date

Line 670:
https://github.com/mitsuhiko/werkzeug/blob/master/werkzeug/http.py


Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -146,10 +146,10 @@ class Request(object):
             header is missing.
         if_none_match (str): Value of the If-None-Match header, or ``None``
             if the header is missing.
-        if_modified_since (str): Value of the If-Modified-Since header, or
-            ``None`` if the header is missing.
-        if_unmodified_since (str): Value of the If-Unmodified-Sinc header,
+        if_modified_since (datetime): Value of the If-Modified-Since header,
             or ``None`` if the header is missing.
+        if_unmodified_since (datetime): Value of the If-Unmodified-Since
+            header, or ``None`` if the header is missing.
         if_range (str): Value of the If-Range header, or ``None`` if the
             header is missing.
 
@@ -271,8 +271,6 @@ def __init__(self, env, options=None):
 
     if_match = helpers.header_property('HTTP_IF_MATCH')
     if_none_match = helpers.header_property('HTTP_IF_NONE_MATCH')
-    if_modified_since = helpers.header_property('HTTP_IF_MODIFIED_SINCE')
-    if_unmodified_since = helpers.header_property('HTTP_IF_UNMODIFIED_SINCE')
     if_range = helpers.header_property('HTTP_IF_RANGE')
 
     @property
@@ -326,16 +324,15 @@ def content_length(self):
 
     @property
     def date(self):
-        try:
-            http_date = self.env['HTTP_DATE']
-        except KeyError:
-            return None
+        return self.get_header_as_datetime('Date')
 
-        try:
-            return util.http_date_to_dt(http_date)
-        except ValueError:
-            msg = ('It must be formatted according to RFC 1123.')
-            raise HTTPInvalidHeader(msg, 'Date')
+    @property
+    def if_modified_since(self):
+        return self.get_header_as_datetime('If-Modified-Since')
+
+    @property
+    def if_unmodified_since(self):
+        return self.get_header_as_datetime('If-Unmodified-Since')
 
     @property
     def range(self):
@@ -571,6 +568,35 @@ def get_header(self, name, required=False):
 
             raise HTTPMissingParam(name)
 
+    def get_header_as_datetime(self, header, required=False):
+        """Return an HTTP header with HTTP-Date values as a datetime.
+
+        Args:
+            name (str): Header name, case-insensitive (e.g., 'Date')
+            required (bool, optional): Set to ``True`` to raise
+                ``HTTPBadRequest`` instead of returning gracefully when the
+                header is not found (default ``False``).
+
+        Returns:
+            datetime: The value of the specified header if it exists,
+                or ``None`` if the header is not found and is not required.
+
+        Raises:
+            HTTPBadRequest: The header was not found in the request, but
+                it was required.
+            HttpInvalidHeader: The header contained a malformed/invalid value.
+        """
+
+        try:
+            http_date = self.get_header(header, required=required)
+            return util.http_date_to_dt(http_date)
+        except TypeError:
+            # When the header does not exist and isn't required
+            return None
+        except ValueError:
+            msg = ('It must be formatted according to RFC 1123.')
+            raise HTTPInvalidHeader(msg, header)
+
     def get_param(self, name, required=False, store=None, default=None):
         """Return the raw value of a query string parameter as a string.
 


Test patch:
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -15,28 +15,15 @@
 import random
 import io
 import sys
-from datetime import datetime
 
 import six
 
-import falcon
 from falcon.util import uri
 
 # Constants
 DEFAULT_HOST = 'falconframework.org'
 
 
-def httpnow():
-    """Returns the current UTC time as an RFC 1123 date.
-
-    Returns:
-        str: An HTTP date string, e.g., "Tue, 15 Nov 1994 12:45:26 GMT".
-
-    """
-
-    return falcon.dt_to_http(datetime.utcnow())
-
-
 def rand_string(min, max):
     """Returns a randomly-generated string, of a random length.
 
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
@@ -497,28 +497,47 @@ def test_bogus_content_length_neg(self):
                                  falcon.HTTPInvalidHeader,
                                  'Invalid header value', expected_desc)
 
-    def test_date(self):
+    @ddt.data(('Date', 'date'),
+              ('If-Modified-since', 'if_modified_since'),
+              ('If-Unmodified-since', 'if_unmodified_since'),
+              )
+    @ddt.unpack
+    def test_date(self, header, attr):
         date = datetime.datetime(2013, 4, 4, 5, 19, 18)
-        headers = {'date': 'Thu, 04 Apr 2013 05:19:18 GMT'}
-        req = Request(testing.create_environ(headers=headers))
-        self.assertEqual(req.date, date)
+        date_str = 'Thu, 04 Apr 2013 05:19:18 GMT'
+
+        self._test_header_expected_value(header, date_str, attr, date)
+
+    @ddt.data(('Date', 'date'),
+              ('If-Modified-Since', 'if_modified_since'),
+              ('If-Unmodified-Since', 'if_unmodified_since'),
+              )
+    @ddt.unpack
+    def test_date_invalid(self, header, attr):
 
-    @ddt.data('Thu, 04 Apr 2013', '')
-    def test_date_invalid(self, http_date):
-        headers = {'date': http_date}
-        expected_desc = ('The value provided for the Date '
+        # Date formats don't conform to RFC 1123
+        headers = {header: 'Thu, 04 Apr 2013'}
+        expected_desc = ('The value provided for the {0} '
                          'header is invalid. It must be formatted '
                          'according to RFC 1123.')
-        self._test_error_details(headers, 'date',
+
+        self._test_error_details(headers, attr,
                                  falcon.HTTPInvalidHeader,
-                                 'Invalid header value', expected_desc)
+                                 'Invalid header value',
+                                 expected_desc.format(header))
 
-    def test_date_missing(self):
+        headers = {header: ''}
+        self._test_error_details(headers, attr,
+                                 falcon.HTTPInvalidHeader,
+                                 'Invalid header value',
+                                 expected_desc.format(header))
+
+    @ddt.data('date', 'if_modified_since', 'if_unmodified_since')
+    def test_date_missing(self, attr):
         req = Request(testing.create_environ())
-        self.assertIs(req.date, None)
+        self.assertIs(getattr(req, attr), None)
 
     def test_attribute_headers(self):
-        date = testing.httpnow()
         hash = 'fa0d1a60ef6616bb28038515c8ea4cb2'
         auth = 'HMAC_SHA1 c590afa9bb59191ffab30f223791e82d3fd3e3af'
         agent = 'testing/1.0.1'
@@ -534,12 +553,8 @@ def test_attribute_headers(self):
         self._test_attribute_header('Expect', '100-continue', 'expect')
 
         self._test_attribute_header('If-Match', hash, 'if_match')
-        self._test_attribute_header('If-Modified-Since', date,
-                                    'if_modified_since')
         self._test_attribute_header('If-None-Match', hash, 'if_none_match')
         self._test_attribute_header('If-Range', hash, 'if_range')
-        self._test_attribute_header('If-Unmodified-Since', date,
-                                    'if_unmodified_since')
 
         self._test_attribute_header('User-Agent', agent, 'user_agent',
                                     default=default_agent)
@@ -572,6 +587,11 @@ def _test_attribute_header(self, name, value, attr, default=None):
         req = Request(testing.create_environ())
         self.assertEqual(getattr(req, attr), default)
 
+    def _test_header_expected_value(self, name, value, attr, expected_value):
+        headers = {name: value}
+        req = Request(testing.create_environ(headers=headers))
+        self.assertEqual(getattr(req, attr), expected_value)
+
     def _test_error_details(self, headers, attr_name,
                             error_type, title, description):
         req = Request(testing.create_environ(headers=headers))