Merge pull request #405 from tbug/feature/cookiehelpers
feat(Response) Add cookie helpers
This commit is contained in:
85
doc/api/cookies.rst
Normal file
85
doc/api/cookies.rst
Normal 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")
|
||||
@@ -6,6 +6,7 @@ Classes and Functions
|
||||
|
||||
api
|
||||
request_and_response
|
||||
cookies
|
||||
status
|
||||
errors
|
||||
middleware
|
||||
|
||||
@@ -466,7 +466,6 @@ Query Strings
|
||||
|
||||
*Coming soon...*
|
||||
|
||||
|
||||
Introducing Hooks
|
||||
-----------------
|
||||
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
16
falcon/util/time.py
Normal 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
169
tests/test_cookies.py
Normal 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")
|
||||
Reference in New Issue
Block a user