feat(Response) Adding cookie helpers
Added the following new helpers for setting and getting cookies - set_cookie(name : str, value : str, **cookie_params) -> None - get_cookie(name : str) -> Morsel - get_cookies() -> List[Morsel] - unset_cookie(name : str) -> None Cookies will - if set - be rendered and merged with headers in _wsgi_headers. For responses that don't use cookies, there will no performance hit, except for a single "if cookies is not None:" check in the _wsgi_headers call. Implementation use the http.cookies.SimpleCookie (py3) or Cookie.SimpleCookie (py2) from the stdlib. Might find extra speed in doing our own implementation. Fixes #292 feat(Response): cookie fixes from PR discussion Prev PR here https://github.com/racker/falcon/pull/405 Had an unused import feat(Request) Adding `cookies` property to request object Aproach is similar to the `headers` property. Parse on first access, store a parsed cache, and return a copy. This should be able to close #292 with the rest of the commits from PR #405 fix(test): pep8 complaints docs(cookies): Finishing up cookie docstrings and adding cookie tutorial doc(cookies): Moving cookie docs to api/cookies.rst As suggested by @kgriffs Also added a reference from Request.cookies and Response.set_cookie style(request): Whitespace nitpick doc(response): Adding notes about cookies fix(cookie): Setting a unicode cookie name will now raise if err If Py2, try to encode name as ascii, if fails, raise KeyError Try set name to cookie, if fails (invalid name acording to cookie spec) raise KeyError style(wording): Fixing bad wording in set_cookie refactor(TimezoneGMT): Moving class to utils Had a weird issue when trying to place it in misc.py where suddenly i couldn't import it. Guessing circular reference, but couldn't see where. Moved to utils.time instead. Seems cleaner anyway. doc(request) Adressing docstring comment https://github.com/falconry/falcon/pull/405/files#r27823358 doc(cookies): adressing some cookie doc issues
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
|
||||
|
||||
@@ -465,7 +465,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
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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
|
||||
153
tests/test_cookies.py
Normal file
153
tests/test_cookies.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user