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:
Henrik Tudborg
2015-01-07 23:59:28 +01:00
parent 0f1ef875fb
commit 1e085393e8
8 changed files with 414 additions and 6 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

@@ -465,7 +465,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
@@ -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
# ------------------------------------------------------------------------

View File

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

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

153
tests/test_cookies.py Normal file
View 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)