Merge pull request #405 from tbug/feature/cookiehelpers

feat(Response) Add cookie helpers
This commit is contained in:
Kurt Griffiths
2015-04-20 15:17:43 -05:00
9 changed files with 452 additions and 7 deletions

85
doc/api/cookies.rst Normal file
View File

@@ -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")

View File

@@ -6,6 +6,7 @@ Classes and Functions
api
request_and_response
cookies
status
errors
middleware

View File

@@ -466,7 +466,6 @@ Query Strings
*Coming soon...*
Introducing Hooks
-----------------

View File

@@ -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 <getting-cookies>`
"""
__slots__ = (
@@ -183,6 +188,7 @@ class Request(object):
'context',
'_wsgierrors',
'options',
'_cookies',
)
# Allow child classes to override this
@@ -231,6 +237,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
@@ -475,6 +483,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
# ------------------------------------------------------------------------

View File

@@ -14,8 +14,12 @@
import six
from falcon.response_helpers import header_property, format_range
from falcon.util import dt_to_http, uri
from falcon.response_helpers import header_property, format_range,\
is_ascii_encodable
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 +83,7 @@ class Response(object):
'_body_encoded', # Stuff
'data',
'_headers',
'_cookies',
'status',
'stream',
'stream_len'
@@ -88,6 +93,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 +145,120 @@ 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, http_only=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.
http_only (bool) (default: True):
The attribute http_only 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 <setting-cookies>`
Raises:
KeyError if ``name`` is not a valid cookie name.
"""
if not is_ascii_encodable(name):
raise KeyError('"name" is not ascii encodable')
if not is_ascii_encodable(value):
raise ValueError('"value" is not ascii encodable')
if six.PY2: # pragma: no cover
name = str(name)
value = str(value)
if self._cookies is None:
self._cookies = SimpleCookie()
try:
self._cookies[name] = value
except CookieError as e: # pragma: no cover
# NOTE(tbug): we raise a KeyError here, to avoid leaking
# the CookieError to the user. SimpleCookie (well, BaseCookie)
# only throws CookieError on issues with the cookie key
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 http_only:
self._cookies[name]["httponly"] = http_only
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 +281,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 +551,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

View File

@@ -60,3 +60,21 @@ def format_range(value):
str(value[0]) + '-' +
str(value[1]) + '/' +
str(value[2]))
def is_ascii_encodable(s): # pragma: no cover
"""Check if argument encodes to ascii without error."""
try:
s.encode("ascii")
except UnicodeEncodeError:
# NOTE(tbug): Py2 and Py3 will raise this if string contained
# chars that could not be ascii encoded
return False
except UnicodeDecodeError:
# NOTE(tbug): py2 will raise this if type is str
# and contains non-ascii chars
return False
except AttributeError:
# NOTE(tbug): s is probably not a string type
return False
return True

View File

@@ -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

16
falcon/util/time.py Normal file
View File

@@ -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

169
tests/test_cookies.py Normal file
View File

@@ -0,0 +1,169 @@
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", http_only=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", http_only=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", http_only=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_inside_ascii_range(self):
resp = falcon.Response()
# should be ok
resp.set_cookie("non_unicode_ascii_name_1", "ascii_value")
resp.set_cookie(u"unicode_ascii_name_1", "ascii_value")
resp.set_cookie("non_unicode_ascii_name_2", u"unicode_ascii_value")
resp.set_cookie(u"unicode_ascii_name_2", u"unicode_ascii_value")
def test_unicode_outside_ascii_range(self):
def set_bad_cookie_name():
resp = falcon.Response()
resp.set_cookie(u"unicode_\xc3\xa6\xc3\xb8", "ok_value")
self.assertRaises(KeyError, set_bad_cookie_name)
def set_bad_cookie_value():
resp = falcon.Response()
resp.set_cookie("ok_name", u"unicode_\xc3\xa6\xc3\xb8")
# NOTE(tbug): we need to grab the exception to check
# that it is not instance of UnicodeEncodeError, so
# we cannot simply use assertRaises
try:
set_bad_cookie_value()
except ValueError as e:
self.assertIsInstance(e, ValueError)
self.assertNotIsInstance(e, UnicodeEncodeError)
else:
self.fail("set_bad_cookie_value did not fail as expected")