Skip to content

Commit e97b677

Browse files
authored
Add SetCookie utility (#254)
1 parent 24ee4a9 commit e97b677

File tree

6 files changed

+186
-4
lines changed

6 files changed

+186
-4
lines changed

docs/api.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,15 +133,17 @@ Setter for the [side effect](guide.md#mock-with-a-side-effect) to trigger.
133133

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

136-
> <code>route.<strong>respond</strong>(*status_code=200, headers=None, content=None, text=None, html=None, json=None, stream=None*)</strong></code>
136+
> <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>
137137
>
138138
> **Parameters:**
139139
>
140140
> * **status_code** - *(optional) int - default: `200`*
141141
> Response status code to mock.
142-
> * **headers** - *(optional) dict*
142+
> * **headers** - *(optional) dict | Sequence[tuple[str, str]]*
143143
> Response headers to mock.
144-
> * **content** - *(optional) bytes | str | iterable bytes*
144+
> * **cookies** - *(optional) dict | Sequence[tuple[str, str]] | Sequence[SetCookie]*
145+
> Response cookies to mock as `Set-Cookie` headers. See [SetCookie](#setcookie).
146+
> * **content** - *(optional) bytes | str | Iterable[bytes]*
145147
> Response raw content to mock.
146148
> * **text** - *(optional) str*
147149
> Response *text* content to mock, with automatic content-type header added.
@@ -151,6 +153,8 @@ Shortcut for creating and mocking a `HTTPX` [Response](#response).
151153
> Response *JSON* content to mock, with automatic content-type header added.
152154
> * **stream** - *(optional) Iterable[bytes]*
153155
> Response *stream* to mock.
156+
> * **content_type** - *(optional) str*
157+
> Response `Content-Type` header to mock.
154158
>
155159
> **Returns:** `Route`
156160
@@ -191,6 +195,24 @@ Shortcut for creating and mocking a `HTTPX` [Response](#response).
191195
> * **stream** - *(optional) Iterable[bytes]*
192196
> Content *stream*.
193197
198+
!!! tip "Cookies"
199+
Use [respx.SetCookie(...)](#setcookie) to produce `Set-Cookie` headers.
200+
201+
---
202+
203+
## SetCookie
204+
205+
A utility to render a `("Set-Cookie", <cookie header value>)` tuple. See route [respond](#respond) shortcut for alternative use.
206+
207+
> <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>
208+
209+
``` python
210+
import respx
211+
respx.post("https://example.org/").mock(
212+
return_value=httpx.Response(200, headers=[SetCookie("foo", "bar")])
213+
)
214+
```
215+
194216
---
195217

196218
## Patterns

respx/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .handlers import ASGIHandler, WSGIHandler
33
from .models import MockResponse, Route
44
from .router import MockRouter, Router
5+
from .utils import SetCookie
56

67
from .api import ( # isort:skip
78
mock,
@@ -24,6 +25,7 @@
2425
options,
2526
)
2627

28+
2729
__all__ = [
2830
"__version__",
2931
"MockResponse",
@@ -32,6 +34,7 @@
3234
"WSGIHandler",
3335
"Router",
3436
"Route",
37+
"SetCookie",
3538
"mock",
3639
"routes",
3740
"calls",

respx/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616

1717
import httpx
1818

19+
from respx.utils import SetCookie
20+
1921
from .patterns import M, Pattern
2022
from .types import (
2123
CallableSideEffect,
2224
Content,
25+
CookieTypes,
2326
HeaderTypes,
2427
ResolvedResponseTypes,
2528
RouteResultTypes,
@@ -90,6 +93,7 @@ def __init__(
9093
content: Optional[Content] = None,
9194
content_type: Optional[str] = None,
9295
http_version: Optional[str] = None,
96+
cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None,
9397
**kwargs: Any,
9498
) -> None:
9599
if not isinstance(content, (str, bytes)) and (
@@ -110,6 +114,19 @@ def __init__(
110114
if content_type:
111115
self.headers["Content-Type"] = content_type
112116

117+
if cookies:
118+
if isinstance(cookies, dict):
119+
cookies = tuple(cookies.items())
120+
self.headers = httpx.Headers(
121+
(
122+
*self.headers.multi_items(),
123+
*(
124+
cookie if isinstance(cookie, SetCookie) else SetCookie(*cookie)
125+
for cookie in cookies
126+
),
127+
)
128+
)
129+
113130

114131
class Route:
115132
def __init__(
@@ -256,6 +273,7 @@ def respond(
256273
status_code: int = 200,
257274
*,
258275
headers: Optional[HeaderTypes] = None,
276+
cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None,
259277
content: Optional[Content] = None,
260278
text: Optional[str] = None,
261279
html: Optional[str] = None,
@@ -268,6 +286,7 @@ def respond(
268286
response = MockResponse(
269287
status_code,
270288
headers=headers,
289+
cookies=cookies,
271290
content=content,
272291
text=text,
273292
html=html,

respx/utils.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import email
2+
from datetime import datetime
23
from email.message import Message
3-
from typing import List, Tuple, cast
4+
from typing import Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union, cast
45
from urllib.parse import parse_qsl
56

7+
try:
8+
from typing import Literal # type: ignore[attr-defined]
9+
except ImportError: # pragma: no cover
10+
from typing_extensions import Literal
11+
612
import httpx
713

814

@@ -71,3 +77,62 @@ def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]:
7177
files = MultiItems()
7278

7379
return data, files
80+
81+
82+
Self = TypeVar("Self", bound="SetCookie")
83+
84+
85+
class SetCookie(
86+
NamedTuple(
87+
"SetCookie",
88+
[
89+
("header_name", Literal["Set-Cookie"]),
90+
("header_value", str),
91+
],
92+
)
93+
):
94+
def __new__(
95+
cls: Type[Self],
96+
name: str,
97+
value: str,
98+
*,
99+
path: Optional[str] = None,
100+
domain: Optional[str] = None,
101+
expires: Optional[Union[str, datetime]] = None,
102+
max_age: Optional[int] = None,
103+
http_only: bool = False,
104+
same_site: Optional[Literal["Strict", "Lax", "None"]] = None,
105+
secure: bool = False,
106+
partitioned: bool = False,
107+
) -> Self:
108+
"""
109+
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#syntax
110+
"""
111+
attrs: Dict[str, Union[str, bool]] = {name: value}
112+
if path is not None:
113+
attrs["Path"] = path
114+
if domain is not None:
115+
attrs["Domain"] = domain
116+
if expires is not None:
117+
if isinstance(expires, datetime): # pragma: no branch
118+
expires = expires.strftime("%a, %d %b %Y %H:%M:%S GMT")
119+
attrs["Expires"] = expires
120+
if max_age is not None:
121+
attrs["Max-Age"] = str(max_age)
122+
if http_only:
123+
attrs["HttpOnly"] = True
124+
if same_site is not None:
125+
attrs["SameSite"] = same_site
126+
if same_site == "None": # pragma: no branch
127+
secure = True
128+
if secure:
129+
attrs["Secure"] = True
130+
if partitioned:
131+
attrs["Partitioned"] = True
132+
133+
string = "; ".join(
134+
_name if _value is True else f"{_name}={_value}"
135+
for _name, _value in attrs.items()
136+
)
137+
self = super().__new__(cls, "Set-Cookie", string)
138+
return self

tests/test_api.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,46 @@ def test_respond():
564564
route.respond(content=Exception()) # type: ignore[arg-type]
565565

566566

567+
def test_can_respond_with_cookies():
568+
with respx.mock:
569+
route = respx.get("https://foo.bar/").respond(
570+
json={}, headers={"X-Foo": "bar"}, cookies={"foo": "bar", "ham": "spam"}
571+
)
572+
response = httpx.get("https://foo.bar/")
573+
assert len(response.headers) == 5
574+
assert response.headers["X-Foo"] == "bar", "mocked header is missing"
575+
assert len(response.cookies) == 2
576+
assert response.cookies["foo"] == "bar"
577+
assert response.cookies["ham"] == "spam"
578+
579+
route.respond(cookies=[("egg", "yolk")])
580+
response = httpx.get("https://foo.bar/")
581+
assert len(response.cookies) == 1
582+
assert response.cookies["egg"] == "yolk"
583+
584+
route.respond(
585+
cookies=[respx.SetCookie("foo", "bar", path="/", same_site="Lax")]
586+
)
587+
response = httpx.get("https://foo.bar/")
588+
assert len(response.cookies) == 1
589+
assert response.cookies["foo"] == "bar"
590+
591+
592+
def test_can_mock_response_with_set_cookie_headers():
593+
request = httpx.Request("GET", "https://example.com/")
594+
response = httpx.Response(
595+
200,
596+
headers=[
597+
respx.SetCookie("foo", value="bar"),
598+
respx.SetCookie("ham", value="spam"),
599+
],
600+
request=request,
601+
)
602+
assert len(response.cookies) == 2
603+
assert response.cookies["foo"] == "bar"
604+
assert response.cookies["ham"] == "spam"
605+
606+
567607
@pytest.mark.parametrize(
568608
"kwargs",
569609
[

tests/test_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from datetime import datetime, timezone
2+
3+
from respx.utils import SetCookie
4+
5+
6+
class TestSetCookie:
7+
def test_can_render_all_attributes(self) -> None:
8+
expires = datetime.fromtimestamp(0, tz=timezone.utc)
9+
cookie = SetCookie(
10+
"foo",
11+
value="bar",
12+
path="/",
13+
domain=".example.com",
14+
expires=expires,
15+
max_age=44,
16+
http_only=True,
17+
same_site="None",
18+
partitioned=True,
19+
)
20+
assert cookie == (
21+
"Set-Cookie",
22+
(
23+
"foo=bar; "
24+
"Path=/; "
25+
"Domain=.example.com; "
26+
"Expires=Thu, 01 Jan 1970 00:00:00 GMT; "
27+
"Max-Age=44; "
28+
"HttpOnly; "
29+
"SameSite=None; "
30+
"Secure; "
31+
"Partitioned"
32+
),
33+
)

0 commit comments

Comments
 (0)