Skip to content

Commit

Permalink
Http exception cookies support (#5197)
Browse files Browse the repository at this point in the history
  • Loading branch information
derlih authored Nov 14, 2020
1 parent 021668a commit 54afac7
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 172 deletions.
1 change: 1 addition & 0 deletions CHANGES/4277.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add set_cookie and del_cookie methods to HTTPException
92 changes: 91 additions & 1 deletion aiohttp/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import weakref
from collections import namedtuple
from contextlib import suppress
from http.cookies import SimpleCookie
from math import ceil
from pathlib import Path
from types import TracebackType
Expand Down Expand Up @@ -44,7 +45,7 @@

import async_timeout
import attr
from multidict import MultiDict, MultiDictProxy
from multidict import CIMultiDict, MultiDict, MultiDictProxy
from typing_extensions import Protocol, final
from yarl import URL

Expand Down Expand Up @@ -705,6 +706,7 @@ class HeadersMixin:
__slots__ = ("_content_type", "_content_dict", "_stored_content_type")

def __init__(self) -> None:
super().__init__()
self._content_type = None # type: Optional[str]
self._content_dict = None # type: Optional[Dict[str, str]]
self._stored_content_type = sentinel
Expand Down Expand Up @@ -799,3 +801,91 @@ def __bool__(self) -> bool:
def __repr__(self) -> str:
content = ", ".join(map(repr, self._maps))
return f"ChainMapProxy({content})"


class CookieMixin:
def __init__(self) -> None:
super().__init__()
self._cookies = SimpleCookie() # type: SimpleCookie[str]

@property
def cookies(self) -> "SimpleCookie[str]":
return self._cookies

def set_cookie(
self,
name: str,
value: str,
*,
expires: Optional[str] = None,
domain: Optional[str] = None,
max_age: Optional[Union[int, str]] = None,
path: str = "/",
secure: Optional[bool] = None,
httponly: Optional[bool] = None,
version: Optional[str] = None,
samesite: Optional[str] = None,
) -> None:
"""Set or update response cookie.
Sets new cookie or updates existent with new value.
Also updates only those params which are not None.
"""

old = self._cookies.get(name)
if old is not None and old.coded_value == "":
# deleted cookie
self._cookies.pop(name, None)

self._cookies[name] = value
c = self._cookies[name]

if expires is not None:
c["expires"] = expires
elif c.get("expires") == "Thu, 01 Jan 1970 00:00:00 GMT":
del c["expires"]

if domain is not None:
c["domain"] = domain

if max_age is not None:
c["max-age"] = str(max_age)
elif "max-age" in c:
del c["max-age"]

c["path"] = path

if secure is not None:
c["secure"] = secure
if httponly is not None:
c["httponly"] = httponly
if version is not None:
c["version"] = version
if samesite is not None:
c["samesite"] = samesite

def del_cookie(
self, name: str, *, domain: Optional[str] = None, path: str = "/"
) -> None:
"""Delete cookie.
Creates new empty expired cookie.
"""
# TODO: do we need domain/path here?
self._cookies.pop(name, None)
self.set_cookie(
name,
"",
max_age=0,
expires="Thu, 01 Jan 1970 00:00:00 GMT",
domain=domain,
path=path,
)


def populate_with_cookies(
headers: "CIMultiDict[str]", cookies: "SimpleCookie[str]"
) -> None:
for cookie in cookies.values():
value = cookie.output(header="")[1:]
headers.add(hdrs.SET_COOKIE, value)
4 changes: 3 additions & 1 deletion aiohttp/web_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from yarl import URL

from . import hdrs
from .helpers import CookieMixin
from .typedefs import LooseHeaders, StrOrURL

__all__ = (
Expand Down Expand Up @@ -75,7 +76,7 @@
############################################################


class HTTPException(Exception):
class HTTPException(CookieMixin, Exception):

# You should set in subclasses:
# status = 200
Expand All @@ -92,6 +93,7 @@ def __init__(
text: Optional[str] = None,
content_type: Optional[str] = None,
) -> None:
super().__init__()
if reason is None:
reason = self.default_reason

Expand Down
1 change: 1 addition & 0 deletions aiohttp/web_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ async def _handle_request(
resp = Response(
status=exc.status, reason=exc.reason, text=exc.text, headers=exc.headers
)
resp._cookies = exc._cookies
reset = await self.finish_response(request, resp, start_time)
except asyncio.CancelledError:
raise
Expand Down
93 changes: 11 additions & 82 deletions aiohttp/web_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import zlib
from concurrent.futures import Executor
from email.utils import parsedate
from http.cookies import Morsel, SimpleCookie
from http.cookies import Morsel
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -27,7 +27,14 @@

from . import hdrs, payload
from .abc import AbstractStreamWriter
from .helpers import PY_38, HeadersMixin, rfc822_formatted_time, sentinel
from .helpers import (
PY_38,
CookieMixin,
HeadersMixin,
populate_with_cookies,
rfc822_formatted_time,
sentinel,
)
from .http import RESPONSES, SERVER_SOFTWARE, HttpVersion10, HttpVersion11
from .payload import Payload
from .typedefs import JSONEncoder, LooseHeaders
Expand Down Expand Up @@ -64,7 +71,7 @@ class ContentCoding(enum.Enum):
############################################################


class StreamResponse(BaseClass, HeadersMixin):
class StreamResponse(BaseClass, HeadersMixin, CookieMixin):

__slots__ = (
"_length_check",
Expand All @@ -73,7 +80,6 @@ class StreamResponse(BaseClass, HeadersMixin):
"_chunked",
"_compression",
"_compression_force",
"_cookies",
"_req",
"_payload_writer",
"_eof_sent",
Expand All @@ -99,7 +105,6 @@ def __init__(
self._chunked = False
self._compression = False
self._compression_force = None # type: Optional[ContentCoding]
self._cookies = SimpleCookie() # type: SimpleCookie[str]

self._req = None # type: Optional[BaseRequest]
self._payload_writer = None # type: Optional[AbstractStreamWriter]
Expand Down Expand Up @@ -185,80 +190,6 @@ def enable_compression(self, force: Optional[ContentCoding] = None) -> None:
def headers(self) -> "CIMultiDict[str]":
return self._headers

@property
def cookies(self) -> "SimpleCookie[str]":
return self._cookies

def set_cookie(
self,
name: str,
value: str,
*,
expires: Optional[str] = None,
domain: Optional[str] = None,
max_age: Optional[Union[int, str]] = None,
path: str = "/",
secure: Optional[bool] = None,
httponly: Optional[bool] = None,
version: Optional[str] = None,
samesite: Optional[str] = None,
) -> None:
"""Set or update response cookie.
Sets new cookie or updates existent with new value.
Also updates only those params which are not None.
"""

old = self._cookies.get(name)
if old is not None and old.coded_value == "":
# deleted cookie
self._cookies.pop(name, None)

self._cookies[name] = value
c = self._cookies[name]

if expires is not None:
c["expires"] = expires
elif c.get("expires") == "Thu, 01 Jan 1970 00:00:00 GMT":
del c["expires"]

if domain is not None:
c["domain"] = domain

if max_age is not None:
c["max-age"] = str(max_age)
elif "max-age" in c:
del c["max-age"]

c["path"] = path

if secure is not None:
c["secure"] = secure
if httponly is not None:
c["httponly"] = httponly
if version is not None:
c["version"] = version
if samesite is not None:
c["samesite"] = samesite

def del_cookie(
self, name: str, *, domain: Optional[str] = None, path: str = "/"
) -> None:
"""Delete cookie.
Creates new empty expired cookie.
"""
# TODO: do we need domain/path here?
self._cookies.pop(name, None)
self.set_cookie(
name,
"",
max_age=0,
expires="Thu, 01 Jan 1970 00:00:00 GMT",
domain=domain,
path=path,
)

@property
def content_length(self) -> Optional[int]:
# Just a placeholder for adding setter
Expand Down Expand Up @@ -399,9 +330,7 @@ async def _prepare_headers(self) -> None:
version = request.version

headers = self._headers
for cookie in self._cookies.values():
value = cookie.output(header="")[1:]
headers.add(hdrs.SET_COOKIE, value)
populate_with_cookies(headers, self.cookies)

if self._compression:
await self._start_compression(request)
Expand Down
80 changes: 80 additions & 0 deletions docs/web_exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,86 @@ Base HTTP Exception

HTTP headers for the exception, :class:`multidict.CIMultiDict`

.. attribute:: cookies

An instance of :class:`http.cookies.SimpleCookie` for *outgoing* cookies.

.. versionadded:: 4.0

.. method:: set_cookie(name, value, *, path='/', expires=None, \
domain=None, max_age=None, \
secure=None, httponly=None, version=None, \
samesite=None)

Convenient way for setting :attr:`cookies`, allows to specify
some additional properties like *max_age* in a single call.

.. versionadded:: 4.0

:param str name: cookie name

:param str value: cookie value (will be converted to
:class:`str` if value has another type).

:param expires: expiration date (optional)

:param str domain: cookie domain (optional)

:param int max_age: 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. A value of zero
means the cookie should be discarded
immediately. (optional)

:param str path: specifies the subset of URLs to
which this cookie applies. (optional, ``'/'`` by default)

:param bool secure: attribute (with no value) directs
the user agent to use only (unspecified)
secure means to contact the origin server
whenever it sends back this cookie.
The user agent (possibly under the user's
control) may determine what level of
security it considers appropriate for
"secure" cookies. The *secure* should be
considered security advice from the server
to the user agent, indicating that it is in
the session's interest to protect the cookie
contents. (optional)

:param bool httponly: ``True`` if the cookie HTTP only (optional)

:param int version: a decimal integer, identifies to which
version of the state management
specification the cookie
conforms. (Optional, *version=1* by default)

:param str samesite: Asserts that a cookie must not be sent with
cross-origin requests, providing some protection
against cross-site request forgery attacks.
Generally the value should be one of: ``None``,
``Lax`` or ``Strict``. (optional)

.. warning::

In HTTP version 1.1, ``expires`` was deprecated and replaced with
the easier-to-use ``max-age``, but Internet Explorer (IE6, IE7,
and IE8) **does not** support ``max-age``.

.. method:: del_cookie(name, *, path='/', domain=None)

Deletes cookie.

.. versionadded:: 4.0

:param str name: cookie name

:param str domain: optional cookie domain

:param str path: optional cookie path, ``'/'`` by default


Successful Exceptions
---------------------
Expand Down
Loading

0 comments on commit 54afac7

Please sign in to comment.