diff --git a/docs/api.md b/docs/api.md
index f43c7a8..170d368 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -133,15 +133,17 @@ Setter for the [side effect](guide.md#mock-with-a-side-effect) to trigger.
Shortcut for creating and mocking a `HTTPX` [Response](#response).
-> route.respond(*status_code=200, headers=None, content=None, text=None, html=None, json=None, stream=None*)
+> route.respond(*status_code=200, headers=None, cookies=None, content=None, text=None, html=None, json=None, stream=None, content_type=None*)
>
> **Parameters:**
>
> * **status_code** - *(optional) int - default: `200`*
> Response status code to mock.
-> * **headers** - *(optional) dict*
+> * **headers** - *(optional) dict | Sequence[tuple[str, str]]*
> Response headers to mock.
-> * **content** - *(optional) bytes | str | iterable bytes*
+> * **cookies** - *(optional) dict | Sequence[tuple[str, str]] | Sequence[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*
> Response *text* content to mock, with automatic content-type header added.
@@ -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`
@@ -191,6 +195,24 @@ 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", )` tuple. See route [respond](#respond) shortcut for alternative use.
+
+> respx.SetCookie(*name, value, path=None, domain=None, expires=None, max_age=None, http_only=False, same_site=None, secure=False, partitioned=False*)
+
+``` python
+import respx
+respx.post("https://example.org/").mock(
+ return_value=httpx.Response(200, headers=[SetCookie("foo", "bar")])
+)
+```
+
---
## Patterns
diff --git a/respx/__init__.py b/respx/__init__.py
index 89083a4..13694fd 100644
--- a/respx/__init__.py
+++ b/respx/__init__.py
@@ -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,
@@ -24,6 +25,7 @@
options,
)
+
__all__ = [
"__version__",
"MockResponse",
@@ -32,6 +34,7 @@
"WSGIHandler",
"Router",
"Route",
+ "SetCookie",
"mock",
"routes",
"calls",
diff --git a/respx/models.py b/respx/models.py
index 28fd609..b53974f 100644
--- a/respx/models.py
+++ b/respx/models.py
@@ -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,
@@ -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 (
@@ -110,6 +114,19 @@ 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 if isinstance(cookie, SetCookie) else SetCookie(*cookie)
+ for cookie in cookies
+ ),
+ )
+ )
+
class Route:
def __init__(
@@ -256,6 +273,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,
@@ -268,6 +286,7 @@ def respond(
response = MockResponse(
status_code,
headers=headers,
+ cookies=cookies,
content=content,
text=text,
html=html,
diff --git a/respx/utils.py b/respx/utils.py
index 434c30d..5a6ce3a 100644
--- a/respx/utils.py
+++ b/respx/utils.py
@@ -1,8 +1,14 @@
import email
+from datetime import datetime
from email.message import Message
-from typing import List, Tuple, cast
+from typing import Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union, cast
from urllib.parse import parse_qsl
+try:
+ from typing import Literal # type: ignore[attr-defined]
+except ImportError: # pragma: no cover
+ from typing_extensions import Literal
+
import httpx
@@ -71,3 +77,62 @@ def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]:
files = MultiItems()
return data, files
+
+
+Self = TypeVar("Self", bound="SetCookie")
+
+
+class SetCookie(
+ NamedTuple(
+ "SetCookie",
+ [
+ ("header_name", Literal["Set-Cookie"]),
+ ("header_value", str),
+ ],
+ )
+):
+ 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, "Set-Cookie", string)
+ return self
diff --git a/tests/test_api.py b/tests/test_api.py
index ef1dddd..c4e0ff7 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -564,6 +564,46 @@ def test_respond():
route.respond(content=Exception()) # type: ignore[arg-type]
+def test_can_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_can_mock_response_with_set_cookie_headers():
+ request = httpx.Request("GET", "https://example.com/")
+ response = httpx.Response(
+ 200,
+ headers=[
+ respx.SetCookie("foo", value="bar"),
+ respx.SetCookie("ham", value="spam"),
+ ],
+ request=request,
+ )
+ assert len(response.cookies) == 2
+ assert response.cookies["foo"] == "bar"
+ assert response.cookies["ham"] == "spam"
+
+
@pytest.mark.parametrize(
"kwargs",
[
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..ea9c365
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,33 @@
+from datetime import datetime, timezone
+
+from respx.utils import SetCookie
+
+
+class TestSetCookie:
+ def test_can_render_all_attributes(self) -> None:
+ 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 == (
+ "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"
+ ),
+ )