From 0980bf2b56ed393567213fdaa3809839f64015bd Mon Sep 17 00:00:00 2001 From: Jonas Lundberg Date: Mon, 18 Mar 2024 16:41:35 +0100 Subject: [PATCH] Add `SetCookie` utility --- docs/api.md | 30 ++++++++++++++++++++-- respx/__init__.py | 3 +++ respx/models.py | 21 ++++++++++++++++ respx/utils.py | 61 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_api.py | 40 +++++++++++++++++++++++++++++ tests/test_utils.py | 32 ++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 tests/test_utils.py diff --git a/docs/api.md b/docs/api.md index f43c7a8..0fd4883 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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). -> 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 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* @@ -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,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. + +> respx.SetCookie(*name, value, path=None, domain=None, expires=None, max_age=None, http_only=False, same_site=None, secure=False, partitioned=False*) + +### .header + +Returns a `("Set-Cookie", )` name/value header pair. + +``` python +import respx +respx.post("https://example.org/").mock( + return_value=httpx.Response(200, headers=[SetCookie("foo", "bar").header]) +) +``` + --- ## 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..5e6ffd9 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,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__( @@ -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, @@ -268,6 +288,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..e470635 100644 --- a/respx/utils.py +++ b/respx/utils.py @@ -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 @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index ef1dddd..d9d1ada 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_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", [ diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7453e98 --- /dev/null +++ b/tests/test_utils.py @@ -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" + ), + )