Skip to content

Commit 2d77ba3

Browse files
tdsteinschloerke
andauthored
feat: support request body forwarding on redirects (#390)
All requests sent over HTTP to Connect are responded to with a redirect to the HTTPS location. By default, `Requests` follows the default HTTP specification and removes the POST body when redirecting. We want to override this behavior to ensure the POST body is carried through the redirect. Resolves #385 --------- Co-authored-by: Barret Schloerke <[email protected]>
1 parent 4a9f04b commit 2d77ba3

File tree

3 files changed

+245
-4
lines changed

3 files changed

+245
-4
lines changed

src/posit/connect/client.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
from requests import Response, Session
65
from typing_extensions import TYPE_CHECKING, overload
76

87
from . import hooks, me
@@ -14,13 +13,16 @@
1413
from .metrics.metrics import Metrics
1514
from .oauth.oauth import API_KEY_TOKEN_TYPE, OAuth
1615
from .resources import _PaginatedResourceSequence, _ResourceSequence
16+
from .sessions import Session
1717
from .system import System
1818
from .tags import Tags
1919
from .tasks import Tasks
2020
from .users import User, Users
2121
from .vanities import Vanities
2222

2323
if TYPE_CHECKING:
24+
from requests import Response
25+
2426
from .environments import Environments
2527
from .packages import Packages
2628

@@ -208,6 +210,7 @@ def with_user_session_token(self, token: str) -> Client:
208210
--------
209211
```python
210212
from posit.connect import Client
213+
211214
client = Client().with_user_session_token("my-user-session-token")
212215
```
213216
@@ -218,13 +221,14 @@ def with_user_session_token(self, token: str) -> Client:
218221
219222
client = Client()
220223
224+
221225
@reactive.calc
222226
def visitor_client():
223227
## read the user session token and generate a new client
224-
user_session_token = session.http_conn.headers.get(
225-
"Posit-Connect-User-Session-Token"
226-
)
228+
user_session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token")
227229
return client.with_user_session_token(user_session_token)
230+
231+
228232
@render.text
229233
def user_profile():
230234
# fetch the viewer's profile information

src/posit/connect/sessions.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from urllib.parse import urljoin
2+
3+
import requests
4+
5+
6+
class Session(requests.Session):
7+
"""Custom session that implements CURLOPT_POSTREDIR.
8+
9+
This class mimics the functionality of CURLOPT_POSTREDIR from libcurl by
10+
providing a custom implementation of the POST method. It allows the caller
11+
to control whether the original POST data is preserved on redirects or if the
12+
request should be converted to a GET when a redirect occurs. This is achieved
13+
by disabling automatic redirect handling and manually following the redirect
14+
chain with the desired behavior.
15+
16+
Notes
17+
-----
18+
The custom `post` method in this class:
19+
20+
- Disables automatic redirect handling by setting ``allow_redirects=False``.
21+
- Manually follows redirects up to a specified ``max_redirects``.
22+
- Determines the HTTP method for subsequent requests based on the response
23+
status code and the ``preserve_post`` flag:
24+
25+
- For HTTP status codes 307 and 308, the method and request body are
26+
always preserved as POST.
27+
- For other redirects (e.g., 301, 302, 303), the behavior is determined
28+
by ``preserve_post``:
29+
- If ``preserve_post=True``, the POST method is maintained.
30+
- If ``preserve_post=False``, the method is converted to GET and the
31+
request body is discarded.
32+
33+
Examples
34+
--------
35+
Create a session and send a POST request while preserving POST data on redirects:
36+
37+
>>> session = Session()
38+
>>> response = session.post(
39+
... "https://example.com/api", data={"key": "value"}, preserve_post=True
40+
... )
41+
>>> print(response.status_code)
42+
43+
See Also
44+
--------
45+
requests.Session : The base session class from the requests library.
46+
"""
47+
48+
def post(self, url, data=None, json=None, preserve_post=True, max_redirects=5, **kwargs):
49+
"""
50+
Send a POST request and handle redirects manually.
51+
52+
Parameters
53+
----------
54+
url : str
55+
The URL to send the POST request to.
56+
data : dict, bytes, or file-like object, optional
57+
The form data to send.
58+
json : any, optional
59+
The JSON data to send.
60+
preserve_post : bool, optional
61+
If True, re-send POST data on redirects (mimicking CURLOPT_POSTREDIR);
62+
if False, converts to GET on 301/302/303 responses.
63+
max_redirects : int, optional
64+
Maximum number of redirects to follow.
65+
**kwargs
66+
Additional keyword arguments passed to the request.
67+
68+
Returns
69+
-------
70+
requests.Response
71+
The final response after following redirects.
72+
"""
73+
# Force manual redirect handling by disabling auto redirects.
74+
kwargs["allow_redirects"] = False
75+
76+
# Initial POST request
77+
response = super().post(url, data=data, json=json, **kwargs)
78+
redirect_count = 0
79+
80+
# Manually follow redirects, if any
81+
while response.is_redirect and redirect_count < max_redirects:
82+
redirect_url = response.headers.get("location")
83+
if not redirect_url:
84+
break # No redirect URL; exit loop
85+
86+
redirect_url = urljoin(response.url, redirect_url)
87+
88+
# For 307 and 308 the HTTP spec mandates preserving the method and body.
89+
if response.status_code in (307, 308):
90+
method = "POST"
91+
else:
92+
if preserve_post:
93+
method = "POST"
94+
else:
95+
method = "GET"
96+
data = None
97+
json = None
98+
99+
# Perform the next request in the redirect chain.
100+
response = self.request(method, redirect_url, data=data, json=json, **kwargs)
101+
redirect_count += 1
102+
103+
return response
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import pytest
2+
import responses
3+
4+
from posit.connect.sessions import Session
5+
6+
7+
@responses.activate
8+
def test_post_no_redirect():
9+
url = "https://connect.example.com/api"
10+
responses.add(responses.POST, url, json={"result": "ok"}, status=200)
11+
12+
session = Session()
13+
response = session.post(url, data={"key": "value"})
14+
15+
assert response.status_code == 200
16+
assert len(responses.calls) == 1
17+
# Confirm that the request method was POST.
18+
assert responses.calls[0].request.method == "POST"
19+
20+
21+
@responses.activate
22+
def test_post_with_redirect_preserve():
23+
initial_url = "http://connect.example.com/api"
24+
redirect_url = "http://connect.example.com/redirect"
25+
26+
responses.add(responses.POST, initial_url, status=302, headers={"location": "/redirect"})
27+
responses.add(responses.POST, redirect_url, json={"result": "redirected"}, status=200)
28+
29+
session = Session()
30+
response = session.post(initial_url, data={"key": "value"}, preserve_post=True)
31+
32+
assert response.status_code == 200
33+
assert len(responses.calls) == 2
34+
35+
# Both calls should use the POST method.
36+
assert responses.calls[0].request.method == "POST"
37+
assert responses.calls[1].request.method == "POST"
38+
39+
40+
@responses.activate
41+
def test_post_with_redirect_no_preserve():
42+
initial_url = "http://connect.example.com/api"
43+
redirect_url = "http://connect.example.com/redirect"
44+
45+
responses.add(responses.POST, initial_url, status=302, headers={"location": "/redirect"})
46+
responses.add(responses.GET, redirect_url, json={"result": "redirected"}, status=200)
47+
48+
session = Session()
49+
response = session.post(initial_url, data={"key": "value"}, preserve_post=False)
50+
51+
assert response.status_code == 200
52+
assert len(responses.calls) == 2
53+
# The initial call is a POST, but the follow-up should be a GET since preserve_post is False
54+
assert responses.calls[0].request.method == "POST"
55+
assert responses.calls[1].request.method == "GET"
56+
57+
58+
@pytest.mark.parametrize("status_code", [307, 308])
59+
@responses.activate
60+
def test_post_redirect_307_308(status_code):
61+
initial_url = "http://connect.example.com/api"
62+
redirect_url = "http://connect.example.com/redirect"
63+
64+
# For 307 and 308 redirects, the HTTP spec mandates preserving the method.
65+
responses.add(
66+
responses.POST, initial_url, status=status_code, headers={"location": "/redirect"}
67+
)
68+
responses.add(responses.POST, redirect_url, json={"result": "redirected"}, status=200)
69+
70+
session = Session()
71+
# Even with preserve_post=False, a 307 or 308 redirect should use POST.
72+
response = session.post(initial_url, data={"key": "value"}, preserve_post=False)
73+
74+
assert response.status_code == 200
75+
assert len(responses.calls) == 2
76+
# Confirm that the method for the redirect is still POST.
77+
assert responses.calls[1].request.method == "POST"
78+
79+
80+
@responses.activate
81+
def test_post_redirect_max_redirects():
82+
initial_url = "http://connect.example.com/api"
83+
redirect1_url = "http://connect.example.com/redirect1"
84+
redirect2_url = "http://connect.example.com/redirect2"
85+
86+
# Build a chain of 3 redirects.
87+
responses.add(responses.POST, initial_url, status=302, headers={"location": "/redirect1"})
88+
responses.add(responses.POST, redirect1_url, status=302, headers={"location": "/redirect2"})
89+
responses.add(responses.POST, redirect2_url, status=302, headers={"location": "/redirect3"})
90+
91+
session = Session()
92+
# Limit to 2 redirects; thus, the third redirect response should not be followed.
93+
response = session.post(
94+
initial_url, data={"key": "value"}, max_redirects=2, preserve_post=True
95+
)
96+
97+
# The calls should include: initial, first redirect, and second redirect.
98+
assert len(responses.calls) == 3
99+
# The final response is the one from the second redirect.
100+
assert response.status_code == 302
101+
# The Location header should point to the third URL.
102+
assert response.headers.get("location") == "/redirect3"
103+
104+
105+
@responses.activate
106+
def test_post_redirect_no_location():
107+
url = "http://connect.example.com/api"
108+
# Simulate a redirect response that lacks a Location header.
109+
responses.add(responses.POST, url, status=302, headers={})
110+
111+
session = Session()
112+
response = session.post(url, data={"key": "value"})
113+
114+
# The loop should break immediately since there is no location to follow.
115+
assert len(responses.calls) == 1
116+
assert response.status_code == 302
117+
118+
119+
@responses.activate
120+
def test_post_redirect_location_none_explicit():
121+
url = "http://connect.example.com/api"
122+
123+
# Use a callback to explicitly return a None for the "location" header.
124+
def request_callback(request):
125+
return (302, {"location": ""}, "Redirect without location")
126+
127+
responses.add_callback(responses.POST, url, callback=request_callback)
128+
129+
session = Session()
130+
response = session.post(url, data={"key": "value"})
131+
132+
# The redirect loop should break since location is None.
133+
assert len(responses.calls) == 1
134+
assert response.status_code == 302

0 commit comments

Comments
 (0)