Skip to content

Commit

Permalink
Add SetCookie utility
Browse files Browse the repository at this point in the history
  • Loading branch information
lundberg committed Mar 18, 2024
1 parent b66080b commit 0980bf2
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 3 deletions.
30 changes: 28 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,16 @@ Setter for the [side effect](guide.md#mock-with-a-side-effect) to trigger.

Shortcut for creating and mocking a `HTTPX` [Response](#response).

> <code>route.<strong>respond</strong>(*status_code=200, headers=None, content=None, text=None, html=None, json=None, stream=None*)</strong></code>
> <code>route.<strong>respond</strong>(*status_code=200, headers=None, cookies=None, content=None, text=None, html=None, json=None, stream=None, content_type=None*)</strong></code>
>
> **Parameters:**
>
> * **status_code** - *(optional) int - default: `200`*
> Response status code to mock.
> * **headers** - *(optional) dict*
> * **headers** - *(optional) dict | sequence of pairs*
> Response headers to mock.
> * **cookies** - *(optional) dict | sequence of pairs | sequence of `SetCookie`*
> Response cookies to mock as `Set-Cookie` headers. See [SetCookie](#setcookie).
> * **content** - *(optional) bytes | str | iterable bytes*
> Response raw content to mock.
> * **text** - *(optional) str*
Expand All @@ -151,6 +153,8 @@ Shortcut for creating and mocking a `HTTPX` [Response](#response).
> Response *JSON* content to mock, with automatic content-type header added.
> * **stream** - *(optional) Iterable[bytes]*
> Response *stream* to mock.
> * **content_type** - *(optional) str*
> Response `Content-Type` header to mock.
>
> **Returns:** `Route`
Expand Down Expand Up @@ -191,6 +195,28 @@ Shortcut for creating and mocking a `HTTPX` [Response](#response).
> * **stream** - *(optional) Iterable[bytes]*
> Content *stream*.
!!! tip "Cookies"
Use [respx.SetCookie(...)](#setcookie) to produce `Set-Cookie` headers.

---

## SetCookie

A utility to render a `Set-Cookie` header value. See route [respond](#respond) shortcut for alternative use.

> <code>respx.<strong>SetCookie</strong>(*name, value, path=None, domain=None, expires=None, max_age=None, http_only=False, same_site=None, secure=False, partitioned=False*)</strong></code>
### .header

Returns a `("Set-Cookie", <cookie header value>)` name/value header pair.

``` python
import respx
respx.post("https://example.org/").mock(
return_value=httpx.Response(200, headers=[SetCookie("foo", "bar").header])
)
```

---

## Patterns
Expand Down
3 changes: 3 additions & 0 deletions respx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .handlers import ASGIHandler, WSGIHandler
from .models import MockResponse, Route
from .router import MockRouter, Router
from .utils import SetCookie

from .api import ( # isort:skip
mock,
Expand All @@ -24,6 +25,7 @@
options,
)


__all__ = [
"__version__",
"MockResponse",
Expand All @@ -32,6 +34,7 @@
"WSGIHandler",
"Router",
"Route",
"SetCookie",
"mock",
"routes",
"calls",
Expand Down
21 changes: 21 additions & 0 deletions respx/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@

import httpx

from respx.utils import SetCookie

from .patterns import M, Pattern
from .types import (
CallableSideEffect,
Content,
CookieTypes,
HeaderTypes,
ResolvedResponseTypes,
RouteResultTypes,
Expand Down Expand Up @@ -90,6 +93,7 @@ def __init__(
content: Optional[Content] = None,
content_type: Optional[str] = None,
http_version: Optional[str] = None,
cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None,
**kwargs: Any,
) -> None:
if not isinstance(content, (str, bytes)) and (
Expand All @@ -110,6 +114,21 @@ def __init__(
if content_type:
self.headers["Content-Type"] = content_type

if cookies:
if isinstance(cookies, dict):
cookies = tuple(cookies.items())
self.headers = httpx.Headers(
(
*self.headers.multi_items(),
*(
cookie.header
if isinstance(cookie, SetCookie)
else SetCookie(*cookie).header
for cookie in cookies
),
)
)


class Route:
def __init__(
Expand Down Expand Up @@ -256,6 +275,7 @@ def respond(
status_code: int = 200,
*,
headers: Optional[HeaderTypes] = None,
cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None,
content: Optional[Content] = None,
text: Optional[str] = None,
html: Optional[str] = None,
Expand All @@ -268,6 +288,7 @@ def respond(
response = MockResponse(
status_code,
headers=headers,
cookies=cookies,
content=content,
text=text,
html=html,
Expand Down
61 changes: 60 additions & 1 deletion respx/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import email
from datetime import datetime
from email.message import Message
from typing import List, Tuple, cast
from typing import Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union, cast
from urllib.parse import parse_qsl

import httpx
Expand Down Expand Up @@ -71,3 +72,61 @@ def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]:
files = MultiItems()

return data, files


Self = TypeVar("Self", bound="SetCookie")


class SetCookie(str):
__slots__ = ()

def __new__(
cls: Type[Self],
name: str,
/,
value: str,
*,
path: Optional[str] = None,
domain: Optional[str] = None,
expires: Optional[Union[str, datetime]] = None,
max_age: Optional[int] = None,
http_only: bool = False,
same_site: Optional[Literal["Strict", "Lax", "None"]] = None,
secure: bool = False,
partitioned: bool = False,
) -> Self:
"""
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#syntax
"""
attrs: Dict[str, Union[str, bool]] = {name: value}
if path is not None:
attrs["Path"] = path
if domain is not None:
attrs["Domain"] = domain
if expires is not None:
if isinstance(expires, datetime): # pragma: no branch
expires = expires.strftime("%a, %d %b %Y %H:%M:%S GMT")
attrs["Expires"] = expires
if max_age is not None:
attrs["Max-Age"] = str(max_age)
if http_only:
attrs["HttpOnly"] = True
if same_site is not None:
attrs["SameSite"] = same_site
if same_site == "None": # pragma: no branch
secure = True
if secure:
attrs["Secure"] = True
if partitioned:
attrs["Partitioned"] = True

string = "; ".join(
_name if _value is True else f"{_name}={_value}"
for _name, _value in attrs.items()
)
self = super().__new__(cls, string)
return self

@property
def header(self) -> Tuple[Literal["Set-Cookie"], str]:
return "Set-Cookie", self
40 changes: 40 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,46 @@ def test_respond():
route.respond(content=Exception()) # type: ignore[arg-type]


def test_respond_with_cookies():
with respx.mock:
route = respx.get("https://foo.bar/").respond(
json={}, headers={"X-Foo": "bar"}, cookies={"foo": "bar", "ham": "spam"}
)
response = httpx.get("https://foo.bar/")
assert len(response.headers) == 5
assert response.headers["X-Foo"] == "bar", "mocked header is missing"
assert len(response.cookies) == 2
assert response.cookies["foo"] == "bar"
assert response.cookies["ham"] == "spam"

route.respond(cookies=[("egg", "yolk")])
response = httpx.get("https://foo.bar/")
assert len(response.cookies) == 1
assert response.cookies["egg"] == "yolk"

route.respond(
cookies=[respx.SetCookie("foo", "bar", path="/", same_site="Lax")]
)
response = httpx.get("https://foo.bar/")
assert len(response.cookies) == 1
assert response.cookies["foo"] == "bar"


def test_mock_response_with_cookies():
request = httpx.Request("GET", "https://example.com/")
response = httpx.Response(
200,
headers=[
respx.SetCookie("foo", value="bar").header,
respx.SetCookie("ham", value="spam").header,
],
request=request,
)
assert len(response.cookies) == 2
assert response.cookies["foo"] == "bar"
assert response.cookies["ham"] == "spam"


@pytest.mark.parametrize(
"kwargs",
[
Expand Down
32 changes: 32 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from datetime import datetime, timezone

from respx.utils import SetCookie


def test_set_cookie_header():
expires = datetime.fromtimestamp(0, tz=timezone.utc)
cookie = SetCookie(
"foo",
value="bar",
path="/",
domain=".example.com",
expires=expires,
max_age=44,
http_only=True,
same_site="None",
partitioned=True,
)
assert cookie.header == (
"Set-Cookie",
(
"foo=bar; "
"Path=/; "
"Domain=.example.com; "
"Expires=Thu, 01 Jan 1970 00:00:00 GMT; "
"Max-Age=44; "
"HttpOnly; "
"SameSite=None; "
"Secure; "
"Partitioned"
),
)

0 comments on commit 0980bf2

Please sign in to comment.