diff --git a/doc/api/cookies.rst b/doc/api/cookies.rst new file mode 100644 index 0000000..42c5463 --- /dev/null +++ b/doc/api/cookies.rst @@ -0,0 +1,85 @@ + +.. _cookies: +Cookies +------------- + +Cookie support has been added to falcon from version 0.3. + +.. _getting-cookies: +Getting Cookies +~~~~~~~~~~~~~ + +Cookie can be read from a request via the :py:attr:`~.Request.cookies` request attribute: + + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + + cookies = req.cookies + + if "my_cookie" in cookies: + my_cookie_value = cookies["my_cookie"] + # .... + +The :py:attr:`~.Request.cookies` attribute is a regular +:py:class:`dict` object. + +.. tip :: :py:attr:`~.Request.cookies` returns a + copy of the response cookie dict. Assign it to a variable as in the above example + for better performance. + +.. _setting-cookies: +Setting Cookies +~~~~~~~~~~~~~~~ + +Setting cookies on a response is done via the :py:meth:`~.Response.set_cookie`. + +You should use :py:meth:`~.Response.set_cookie` instead of +:py:meth:`~.Response.set_header` or :py:meth:`~.Response.append_header`. + +With :py:meth:`~.Response.set_header` you cannot set multiple headers +with the same name (which is how multiple cookies are sent to the client). + +:py:meth:`~.Response.append_header` appends multiple values to the same +header field, which is not compatible with the format used by `Set-Cookie` +headers to send cookies to clients. + + + +Simple example: + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + # Set the cookie "my_cookie" to the value "my cookie value" + resp.set_cookie("my_cookie", "my cookie value") + + +You can of course also set the domain, path and lifetime of the cookie. + + + + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + # Set the 'max-age' of the cookie to 10 minutes (600 seconds) + # and the cookies domain to "example.com" + resp.set_cookie("my_cookie", "my cookie value", + max_age=600, domain="example.com") + + +If you set a cookie and want to get rid of it again, you can +use the :py:meth:`~.Response.unset_cookie`: + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + resp.set_cookie("bad_cookie", ":(") + # clear the bad cookie + resp.unset_cookie("bad_cookie") diff --git a/doc/api/index.rst b/doc/api/index.rst index 7a8fea3..3d07c75 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -6,6 +6,7 @@ Classes and Functions api request_and_response + cookies status errors middleware diff --git a/doc/user/tutorial.rst b/doc/user/tutorial.rst index c7cd55c..4bc418b 100644 --- a/doc/user/tutorial.rst +++ b/doc/user/tutorial.rst @@ -465,7 +465,6 @@ Query Strings *Coming soon...* - Introducing Hooks ----------------- diff --git a/falcon/request.py b/falcon/request.py index fc4994e..6da8032 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -1,5 +1,3 @@ -# Copyright 2013 by Rackspace Hosting, Inc. -# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -34,6 +32,8 @@ from falcon import util from falcon.util import uri from falcon import request_helpers as helpers +from six.moves.http_cookies import SimpleCookie + DEFAULT_ERROR_LOG_FORMAT = (u'{0:%Y-%m-%d %H:%M:%S} [FALCON] [ERROR]' u' {1} {2}{3} => ') @@ -167,6 +167,11 @@ class Request(object): all the values in the order seen. options (dict): Set of global options passed from the API handler. + + cookies (dict): + A dict of name/value cookie pairs. + See also: :ref:`Getting Cookies ` + """ __slots__ = ( @@ -183,6 +188,7 @@ class Request(object): 'context', '_wsgierrors', 'options', + '_cookies', ) # Allow child classes to override this @@ -234,6 +240,8 @@ class Request(object): else: self.query_string = six.text_type() + self._cookies = None + self._cached_headers = None self._cached_uri = None self._cached_relative_uri = None @@ -478,6 +486,21 @@ class Request(object): def params(self): return self._params + @property + def cookies(self): + if self._cookies is None: + # NOTE(tbug): We might want to look into parsing + # cookies ourselves. The SimpleCookie is doing a + # lot if stuff only required to SEND cookies. + parser = SimpleCookie(self.get_header("Cookie")) + cookies = {} + for morsel in parser.values(): + cookies[morsel.key] = morsel.value + + self._cookies = cookies + + return self._cookies.copy() + # ------------------------------------------------------------------------ # Methods # ------------------------------------------------------------------------ diff --git a/falcon/response.py b/falcon/response.py index 4ffb6e4..9a8947e 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -15,7 +15,10 @@ import six from falcon.response_helpers import header_property, format_range -from falcon.util import dt_to_http, uri +from falcon.util import dt_to_http, uri, TimezoneGMT +from six.moves.http_cookies import SimpleCookie, CookieError + +GMT_TIMEZONE = TimezoneGMT() class Response(object): @@ -79,6 +82,7 @@ class Response(object): '_body_encoded', # Stuff 'data', '_headers', + '_cookies', 'status', 'stream', 'stream_len' @@ -88,6 +92,10 @@ class Response(object): self.status = '200 OK' self._headers = {} + # NOTE(tbug): will be set to a SimpleCookie object + # when cookie is set via set_cookie + self._cookies = None + self._body = None self._body_encoded = None self.data = None @@ -136,12 +144,118 @@ class Response(object): self.stream = stream self.stream_len = stream_len + def set_cookie(self, name, value, expires=None, max_age=None, + domain=None, path=None, secure=True, httponly=True): + """Set a response cookie. + + Note: + :meth:`~.set_cookie` is capable of setting multiple cookies + with specified cookie properties on the same request. + Currently :meth:`~.set_header` and :meth:`~.append_header` are + not capable of this. + + Args: + name (str): + Cookie name + value (str): + Cookie value + expires (datetime): Defines when the cookie should expire. + the default is to expire when the browser is closed. + max_age (int): The Max-Age attribute defines the lifetime of the + cookie, in seconds. The delta-seconds value is a decimal non- + negative integer. After delta-seconds seconds elapse, + the client should discard the cookie. + domain (str): The Domain attribute specifies the domain for + which the cookie is valid. + An explicitly specified domain must always start with a dot. + A value of 0 means the cookie should be discarded immediately. + path (str): The Path attribute specifies the subset of URLs to + which this cookie applies. + secure (bool) (default: True): The Secure attribute directs the + user agent to use only secure means to contact the origin + server whenever it sends back this cookie. + Warning: You will also need to enforce HTTPS for the cookies + to be transfered securely. + httponly (bool) (default: True): + The attribute httponly specifies that the cookie + is only transferred in HTTP requests, and is not accessible + through JavaScript. This is intended to mitigate some forms + of cross-site scripting. + Note: + Kwargs and their valid values are all + specified in http://tools.ietf.org/html/rfc6265 + + See Also: + :ref:`Setting Cookies ` + + Raises: + KeyError if ``name`` is not a valid cookie name. + + """ + + if six.PY2: # pragma: no cover + # NOTE(tbug): In python 2 we want a str type name, + # as unicode will make the SimpleCookie implementation + # blow up. + if isinstance(name, unicode): + try: + name = name.encode("ascii") + except UnicodeEncodeError as e: + raise KeyError("'name' must consist of only ascii") + + if self._cookies is None: + self._cookies = SimpleCookie() + + try: + self._cookies[name] = value + except CookieError as e: # pragma: no cover + raise KeyError(str(e)) + + if expires: + # set Expires on cookie. Format is Wdy, DD Mon YYYY HH:MM:SS GMT + + # NOTE(tbug): we never actually need to + # know that GMT is named GMT when formatting cookies. + # It is a function call less to just write "GMT" in the fmt string: + fmt = "%a, %d %b %Y %H:%M:%S GMT" + if expires.tzinfo is None: + # naive + self._cookies[name]["expires"] = expires.strftime(fmt) + else: + # aware + gmt_expires = expires.astimezone(GMT_TIMEZONE) + self._cookies[name]["expires"] = gmt_expires.strftime(fmt) + + if max_age: + self._cookies[name]["max-age"] = max_age + + if domain: + self._cookies[name]["domain"] = domain + + if path: + self._cookies[name]["path"] = path + + if secure: + self._cookies[name]["secure"] = secure + + if httponly: + self._cookies[name]["httponly"] = httponly + + def unset_cookie(self, name): + """Unset a cookie from the response + """ + if self._cookies is not None and name in self._cookies: + del self._cookies[name] + def set_header(self, name, value): """Set a header for this response to a given value. Warning: Calling this method overwrites the existing value, if any. + Warning: + For setting cookies, see instead :meth:`~.set_cookie` + Args: name (str): Header name to set (case-insensitive). Must be of type ``str`` or ``StringType``, and only character values 0x00 @@ -164,6 +278,9 @@ class Response(object): to it, delimited by a comma. Most header specifications support this format, Cookie and Set-Cookie being the notable exceptions. + Warning: + For setting cookies, see :py:meth:`~.set_cookie` + Args: name (str): Header name to set (case-insensitive). Must be of type ``str`` or ``StringType``, and only character values 0x00 @@ -431,6 +548,19 @@ class Response(object): if six.PY2: # pragma: no cover # PERF(kgriffs): Don't create an extra list object if # it isn't needed. - return headers.items() + items = headers.items() + else: + items = list(headers.items()) # pragma: no cover - return list(headers.items()) # pragma: no cover + if self._cookies is not None: + # PERF(tbug): + # The below implementation is ~23% faster than + # the alternative: + # + # self._cookies.output().split("\\r\\n") + # + # Even without the .split("\\r\\n"), the below + # is still ~17% faster, so don't use .output() + items += [("set-cookie", c.OutputString()) + for c in self._cookies.values()] + return items diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index 6a9233c..219db47 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -1,5 +1,6 @@ # Hoist misc. utils from falcon.util.misc import * # NOQA +from falcon.util.time import * from falcon.util import structures CaseInsensitiveDict = structures.CaseInsensitiveDict diff --git a/falcon/util/time.py b/falcon/util/time.py new file mode 100644 index 0000000..67c4f38 --- /dev/null +++ b/falcon/util/time.py @@ -0,0 +1,16 @@ +import datetime + + +class TimezoneGMT(datetime.tzinfo): + """Used in cookie response formatting""" + + GMT_ZERO = datetime.timedelta(hours=0) + + def utcoffset(self, dt): + return self.GMT_ZERO + + def tzname(self, dt): + return "GMT" + + def dst(self, dt): + return self.GMT_ZERO diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..c4a24ca --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,153 @@ + +import falcon +import falcon.testing as testing + +from falcon.util import TimezoneGMT +from datetime import datetime, timedelta, tzinfo + +from six.moves.http_cookies import Morsel + + +class TimezoneGMTPlus1(tzinfo): + + def utcoffset(self, dt): + return timedelta(hours=1) + + def tzname(self, dt): + return "GMT+1" + + def dst(self, dt): + return timedelta(hours=1) + +GMT_PLUS_ONE = TimezoneGMTPlus1() + + +class CookieResource: + + def on_get(self, req, resp): + resp.set_cookie("foo", "bar", domain="example.com", path="/") + + def on_head(self, req, resp): + resp.set_cookie("foo", "bar", max_age=300) + resp.set_cookie("bar", "baz", httponly=False) + resp.set_cookie("bad", "cookie") + resp.unset_cookie("bad") + + def on_post(self, req, resp): + e = datetime(year=2050, month=1, day=1) # naive + resp.set_cookie("foo", "bar", httponly=False, secure=False, expires=e) + resp.unset_cookie("bad") + + def on_put(self, req, resp): + e = datetime(year=2050, month=1, day=1, tzinfo=GMT_PLUS_ONE) # aware + resp.set_cookie("foo", "bar", httponly=False, secure=False, expires=e) + resp.unset_cookie("bad") + + +class TestCookies(testing.TestBase): + + # + # Response + # + + def test_response_base_case(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="GET") + self.assertIn( + ("set-cookie", + "foo=bar; Domain=example.com; httponly; Path=/; secure"), + self.srmock.headers) + + def test_response_complex_case(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="HEAD") + self.assertIn(("set-cookie", "foo=bar; httponly; Max-Age=300; secure"), + self.srmock.headers) + self.assertIn(("set-cookie", "bar=baz; secure"), self.srmock.headers) + self.assertNotIn(("set-cookie", "bad=cookie"), self.srmock.headers) + + def test_cookie_expires_naive(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="POST") + self.assertIn( + ("set-cookie", "foo=bar; expires=Sat, 01 Jan 2050 00:00:00 GMT"), + self.srmock.headers) + + def test_cookie_expires_aware(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="PUT") + self.assertIn( + ("set-cookie", "foo=bar; expires=Fri, 31 Dec 2049 23:00:00 GMT"), + self.srmock.headers) + + def test_cookies_setable(self): + resp = falcon.Response() + + self.assertIsNone(resp._cookies) + + resp.set_cookie("foo", "wrong-cookie", max_age=301) + resp.set_cookie("foo", "bar", max_age=300) + morsel = resp._cookies["foo"] + + self.assertIsInstance(morsel, Morsel) + self.assertEqual(morsel.key, "foo") + self.assertEqual(morsel.value, "bar") + self.assertEqual(morsel["max-age"], 300) + + def test_response_unset_cookie(self): + resp = falcon.Response() + resp.unset_cookie("bad") + resp.set_cookie("bad", "cookie", max_age=301) + resp.unset_cookie("bad") + + morsels = list(resp._cookies.values()) + + self.assertEqual(len(morsels), 0) + + def test_cookie_timezone(self): + tz = TimezoneGMT() + self.assertEqual("GMT", tz.tzname(timedelta(0))) + + # + # Request + # + + def test_request_cookie_parsing(self): + # testing with a github-ish set of cookies + headers = [ + ('Cookie', '''Cookie: + logged_in=no;_gh_sess=eyJzZXXzaW9uX2lkIjoiN2; + tz=Europe/Berlin; _ga=GA1.2.332347814.1422308165; + _gat=1; + _octo=GH1.1.201722077.1422308165'''), + ] + + environ = testing.create_environ(headers=headers) + req = falcon.Request(environ) + + self.assertEqual("no", req.cookies["logged_in"]) + self.assertEqual("Europe/Berlin", req.cookies["tz"]) + self.assertEqual("GH1.1.201722077.1422308165", req.cookies["_octo"]) + + self.assertIn("logged_in", req.cookies) + self.assertIn("_gh_sess", req.cookies) + self.assertIn("tz", req.cookies) + self.assertIn("_ga", req.cookies) + self.assertIn("_gat", req.cookies) + self.assertIn("_octo", req.cookies) + + def test_unicode_ascii(self): + resp = falcon.Response() + # should be ok + resp.set_cookie(u"unicode_ascii", "foobar", max_age=60) + + def test_unicode_bad(self): + resp = falcon.Response() + + def set_bad_cookie_name(): + resp.set_cookie(u"unicode_\xc3\xa6\xc3\xb8", "foobar", max_age=60) + self.assertRaises(KeyError, set_bad_cookie_name)