Skip to content

Commit 5079fb9

Browse files
feat(client): add follow_redirects request option
1 parent 2ce93ae commit 5079fb9

File tree

4 files changed

+65
-1
lines changed

4 files changed

+65
-1
lines changed

src/knock_mapi/_base_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,9 @@ def request(
960960
if self.custom_auth is not None:
961961
kwargs["auth"] = self.custom_auth
962962

963+
if options.follow_redirects is not None:
964+
kwargs["follow_redirects"] = options.follow_redirects
965+
963966
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
964967

965968
response = None
@@ -1460,6 +1463,9 @@ async def request(
14601463
if self.custom_auth is not None:
14611464
kwargs["auth"] = self.custom_auth
14621465

1466+
if options.follow_redirects is not None:
1467+
kwargs["follow_redirects"] = options.follow_redirects
1468+
14631469
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
14641470

14651471
response = None

src/knock_mapi/_models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
737737
idempotency_key: str
738738
json_data: Body
739739
extra_json: AnyMapping
740+
follow_redirects: bool
740741

741742

742743
@final
@@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel):
750751
files: Union[HttpxRequestFiles, None] = None
751752
idempotency_key: Union[str, None] = None
752753
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
754+
follow_redirects: Union[bool, None] = None
753755

754756
# It should be noted that we cannot use `json` here as that would override
755757
# a BaseModel method in an incompatible fashion.

src/knock_mapi/_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False):
100100
params: Query
101101
extra_json: AnyMapping
102102
idempotency_key: str
103+
follow_redirects: bool
103104

104105

105106
# Sentinel class used until PEP 0661 is accepted
@@ -215,3 +216,4 @@ class _GenericAlias(Protocol):
215216

216217
class HttpxSendArgs(TypedDict, total=False):
217218
auth: httpx.Auth
219+
follow_redirects: bool

tests/test_client.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from knock_mapi import KnockMgmt, AsyncKnockMgmt, APIResponseValidationError
2525
from knock_mapi._types import Omit
2626
from knock_mapi._models import BaseModel, FinalRequestOptions
27-
from knock_mapi._exceptions import KnockMgmtError, APIResponseValidationError
27+
from knock_mapi._exceptions import APIStatusError, KnockMgmtError, APIResponseValidationError
2828
from knock_mapi._base_client import (
2929
DEFAULT_TIMEOUT,
3030
HTTPX_DEFAULT_TIMEOUT,
@@ -821,6 +821,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
821821

822822
assert response.http_request.headers.get("x-stainless-retry-count") == "42"
823823

824+
@pytest.mark.respx(base_url=base_url)
825+
def test_follow_redirects(self, respx_mock: MockRouter) -> None:
826+
# Test that the default follow_redirects=True allows following redirects
827+
respx_mock.post("/redirect").mock(
828+
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
829+
)
830+
respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
831+
832+
response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
833+
assert response.status_code == 200
834+
assert response.json() == {"status": "ok"}
835+
836+
@pytest.mark.respx(base_url=base_url)
837+
def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
838+
# Test that follow_redirects=False prevents following redirects
839+
respx_mock.post("/redirect").mock(
840+
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
841+
)
842+
843+
with pytest.raises(APIStatusError) as exc_info:
844+
self.client.post(
845+
"/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
846+
)
847+
848+
assert exc_info.value.response.status_code == 302
849+
assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
850+
824851

825852
class TestAsyncKnockMgmt:
826853
client = AsyncKnockMgmt(base_url=base_url, service_token=service_token, _strict_response_validation=True)
@@ -1648,3 +1675,30 @@ async def test_main() -> None:
16481675
raise AssertionError("calling get_platform using asyncify resulted in a hung process")
16491676

16501677
time.sleep(0.1)
1678+
1679+
@pytest.mark.respx(base_url=base_url)
1680+
async def test_follow_redirects(self, respx_mock: MockRouter) -> None:
1681+
# Test that the default follow_redirects=True allows following redirects
1682+
respx_mock.post("/redirect").mock(
1683+
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
1684+
)
1685+
respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
1686+
1687+
response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
1688+
assert response.status_code == 200
1689+
assert response.json() == {"status": "ok"}
1690+
1691+
@pytest.mark.respx(base_url=base_url)
1692+
async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
1693+
# Test that follow_redirects=False prevents following redirects
1694+
respx_mock.post("/redirect").mock(
1695+
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
1696+
)
1697+
1698+
with pytest.raises(APIStatusError) as exc_info:
1699+
await self.client.post(
1700+
"/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
1701+
)
1702+
1703+
assert exc_info.value.response.status_code == 302
1704+
assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"

0 commit comments

Comments
 (0)