diff --git a/impit-node/src/lib.rs b/impit-node/src/lib.rs index c3d91e5e..cb152ca1 100644 --- a/impit-node/src/lib.rs +++ b/impit-node/src/lib.rs @@ -140,6 +140,7 @@ impl ImpitWrapper { Either::B(_) => None, }), http3_prior_knowledge: force_http3.unwrap_or_default(), + ..Default::default() }); let method = method.unwrap_or_default(); diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index 36d77ae7..4305cbf8 100644 --- a/impit-python/python/impit/impit.pyi +++ b/impit-python/python/impit/impit.pyi @@ -523,6 +523,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a GET request. @@ -533,6 +535,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def post( @@ -543,6 +547,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a POST request. @@ -553,6 +559,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ @@ -564,6 +572,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a PUT request. @@ -574,6 +584,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def patch( @@ -584,6 +596,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a PATCH request. @@ -594,6 +608,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def delete( @@ -604,6 +620,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a DELETE request. @@ -614,6 +632,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def head( @@ -624,6 +644,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a HEAD request. @@ -634,6 +656,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def options( @@ -644,6 +668,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an OPTIONS request. @@ -654,6 +680,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def trace( @@ -664,6 +692,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make a TRACE request. @@ -674,6 +704,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def request( @@ -686,6 +718,8 @@ class Client: timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, stream: bool = False, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an HTTP request with the specified method. @@ -698,6 +732,8 @@ class Client: timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol stream: Whether to return a streaming response (default: False) + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def stream( @@ -709,6 +745,8 @@ class Client: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> AbstractContextManager[Response]: """Make a streaming request with the specified method. @@ -731,6 +769,8 @@ class Client: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ @@ -864,6 +904,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous GET request. @@ -874,6 +916,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def post( @@ -884,6 +928,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous POST request. @@ -894,6 +940,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ @@ -905,6 +953,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous PUT request. @@ -915,6 +965,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def patch( @@ -925,6 +977,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous PATCH request. @@ -935,6 +989,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def delete( @@ -945,6 +1001,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous DELETE request. @@ -955,6 +1013,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def head( @@ -965,6 +1025,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous HEAD request. @@ -975,6 +1037,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def options( @@ -985,6 +1049,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous OPTIONS request. @@ -995,6 +1061,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def trace( @@ -1005,6 +1073,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous TRACE request. @@ -1015,6 +1085,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ async def request( @@ -1027,6 +1099,8 @@ class AsyncClient: timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, stream: bool = False, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> Response: """Make an asynchronous HTTP request with the specified method. @@ -1039,6 +1113,8 @@ class AsyncClient: timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol stream: Whether to return a streaming response (default: False) + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ def stream( @@ -1050,6 +1126,8 @@ class AsyncClient: headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, + follow_redirects: bool | None = None, + max_redirects: int | None = None, ) -> AbstractAsyncContextManager[Response]: """Make an asynchronous streaming request with the specified method. @@ -1072,6 +1150,8 @@ class AsyncClient: headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol + follow_redirects: Whether to follow redirects. Defaults to the client's ``follow_redirects``. + max_redirects: Maximum number of redirects to follow. Defaults to the client's ``max_redirects``. """ diff --git a/impit-python/src/async_client.rs b/impit-python/src/async_client.rs index 990936b2..96fdfa60 100644 --- a/impit-python/src/async_client.rs +++ b/impit-python/src/async_client.rs @@ -11,7 +11,9 @@ use pyo3::{ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{parse_timeout, to_body, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, + request::{ + parse_timeout, redirect_behavior, to_body, RequestBody, USE_CLIENT_DEFAULT_SENTINEL, + }, response::ImpitPyResponse, }; @@ -19,6 +21,8 @@ use crate::{ pub(crate) struct AsyncClient { impit: Arc>, default_encoding: Option, + follow_redirects: bool, + max_redirects: u16, } #[pymethods] @@ -87,12 +91,9 @@ impl AsyncClient { _ => builder, }; - let builder = match follow_redirects { - Some(true) => builder.with_redirect(impit::impit::RedirectBehavior::FollowRedirect( - max_redirects.unwrap_or(20).into(), - )), - _ => builder.with_redirect(impit::impit::RedirectBehavior::ManualRedirect), - }; + let follow_redirects = follow_redirects.unwrap_or(false); + let max_redirects = max_redirects.unwrap_or(20); + let builder = builder.with_redirect(redirect_behavior(follow_redirects, max_redirects)); let builder = match (cookie_jar, cookies) { (Some(_), Some(_)) => { @@ -127,10 +128,12 @@ impl AsyncClient { Ok(Self { impit: Arc::new(impit), default_encoding, + follow_redirects, + max_redirects, }) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn get<'python>( &self, py: Python<'python>, @@ -140,6 +143,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -151,10 +156,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn head<'python>( &self, py: Python<'python>, @@ -164,6 +171,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -175,10 +184,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn post<'python>( &self, py: Python<'python>, @@ -188,6 +199,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -199,10 +212,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn patch<'python>( &self, py: Python<'python>, @@ -212,6 +227,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -223,10 +240,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn put<'python>( &self, py: Python<'python>, @@ -236,6 +255,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -247,10 +268,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn delete<'python>( &self, py: Python<'python>, @@ -260,6 +283,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -271,10 +296,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn options<'python>( &self, py: Python<'python>, @@ -284,6 +311,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -295,10 +324,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn trace<'python>( &self, py: Python<'python>, @@ -308,6 +339,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { self.request( py, @@ -319,10 +352,12 @@ impl AsyncClient { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn stream<'python>( &self, py: Python<'python>, @@ -333,6 +368,8 @@ impl AsyncClient { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { let response = self.request( py, @@ -344,6 +381,8 @@ impl AsyncClient { timeout, force_http3, Some(true), + follow_redirects, + max_redirects, )?; let fun: Py = PyModule::from_code( @@ -368,7 +407,7 @@ impl AsyncClient { Ok(wrapped_response.into_bound(py)) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false, follow_redirects=None, max_redirects=None))] pub fn request<'python>( &self, py: Python<'python>, @@ -380,6 +419,8 @@ impl AsyncClient { timeout: Option>, force_http3: Option, stream: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { if let Some(content) = content { data = Some(content); @@ -393,6 +434,10 @@ impl AsyncClient { headers: headers.unwrap_or_default().into_iter().collect(), timeout, http3_prior_knowledge: force_http3.unwrap_or(false), + redirect: Some(redirect_behavior( + follow_redirects.unwrap_or(self.follow_redirects), + max_redirects.unwrap_or(self.max_redirects), + )), }; let method_str = method.to_string(); diff --git a/impit-python/src/client.rs b/impit-python/src/client.rs index 708589b9..364564c9 100644 --- a/impit-python/src/client.rs +++ b/impit-python/src/client.rs @@ -11,7 +11,9 @@ use pyo3::{ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{parse_timeout, to_body, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, + request::{ + parse_timeout, redirect_behavior, to_body, RequestBody, USE_CLIENT_DEFAULT_SENTINEL, + }, response::{self, ImpitPyResponse}, }; @@ -19,6 +21,8 @@ use crate::{ pub(crate) struct Client { impit: Impit, default_encoding: Option, + follow_redirects: bool, + max_redirects: u16, } #[pymethods] @@ -84,12 +88,9 @@ impl Client { _ => builder, }; - let builder = match follow_redirects { - Some(true) => builder.with_redirect(impit::impit::RedirectBehavior::FollowRedirect( - max_redirects.unwrap_or(20).into(), - )), - _ => builder.with_redirect(impit::impit::RedirectBehavior::ManualRedirect), - }; + let follow_redirects = follow_redirects.unwrap_or(false); + let max_redirects = max_redirects.unwrap_or(20); + let builder = builder.with_redirect(redirect_behavior(follow_redirects, max_redirects)); let builder = match headers { Some(headers) => builder.with_headers(headers.into_iter().collect::>()), @@ -123,11 +124,13 @@ impl Client { Ok(Self { impit, default_encoding, + follow_redirects, + max_redirects, }) }) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn get( &self, py: Python<'_>, @@ -137,6 +140,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -148,10 +153,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn head( &self, py: Python<'_>, @@ -161,6 +168,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -172,10 +181,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn post( &self, py: Python<'_>, @@ -185,6 +196,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -196,10 +209,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn patch( &self, py: Python<'_>, @@ -209,6 +224,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -220,10 +237,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn put( &self, py: Python<'_>, @@ -233,6 +252,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -244,10 +265,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn delete( &self, py: Python<'_>, @@ -257,6 +280,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -268,10 +293,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn options( &self, py: Python<'_>, @@ -281,6 +308,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -292,10 +321,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn trace( &self, py: Python<'_>, @@ -305,6 +336,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { self.request( py, @@ -316,10 +349,12 @@ impl Client { timeout, force_http3, Some(false), + follow_redirects, + max_redirects, ) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, follow_redirects=None, max_redirects=None))] pub fn stream<'python>( &self, py: Python<'python>, @@ -330,6 +365,8 @@ impl Client { headers: Option>, timeout: Option>, force_http3: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result, PyErr> { let response = self.request( py, @@ -341,6 +378,8 @@ impl Client { timeout, force_http3, Some(true), + follow_redirects, + max_redirects, )?; let fun: Py = PyModule::from_code( @@ -365,7 +404,7 @@ impl Client { Ok(wrapped_response.into_bound(py)) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false, follow_redirects=None, max_redirects=None))] pub fn request( &self, py: Python<'_>, @@ -377,6 +416,8 @@ impl Client { timeout: Option>, force_http3: Option, stream: Option, + follow_redirects: Option, + max_redirects: Option, ) -> Result { if let Some(content) = content { data = Some(content); @@ -392,6 +433,10 @@ impl Client { headers: headers.unwrap_or_default().into_iter().collect(), timeout, http3_prior_knowledge: force_http3.unwrap_or(false), + redirect: Some(redirect_behavior( + follow_redirects.unwrap_or(self.follow_redirects), + max_redirects.unwrap_or(self.max_redirects), + )), }; py.detach(|| { diff --git a/impit-python/src/lib.rs b/impit-python/src/lib.rs index 2617c6ba..4a2ecf3e 100644 --- a/impit-python/src/lib.rs +++ b/impit-python/src/lib.rs @@ -109,7 +109,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { ) -> PyResult { let client = Client::new(_py, None, None, proxy, Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), None, None, follow_redirects, max_redirects, cookie_jar, cookies, None, None); - Ok(client?.$name(_py, url, content, data, headers, timeout, force_http3)?) + Ok(client?.$name(_py, url, content, data, headers, timeout, force_http3, None, None)?) } m.add_function(wrap_pyfunction!($name, m)?)?; @@ -161,6 +161,8 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { headers, timeout, force_http3, + None, + None, ) } diff --git a/impit-python/src/request.rs b/impit-python/src/request.rs index 1e919f3b..dfbea315 100644 --- a/impit-python/src/request.rs +++ b/impit-python/src/request.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, io, time::Duration}; use bytes::Bytes; use either::{Either, Left, Right}; use futures::{stream, Stream}; -use impit::request::ImpitBody; +use impit::{impit::RedirectBehavior, request::ImpitBody}; use pyo3::{ exceptions::{PyStopAsyncIteration, PyStopIteration, PyTypeError}, types::{PyAnyMethods, PyMapping}, @@ -41,6 +41,13 @@ pub(crate) fn parse_timeout( } } +pub(crate) fn redirect_behavior(follow_redirects: bool, max_redirects: u16) -> RedirectBehavior { + match follow_redirects { + true => RedirectBehavior::FollowRedirect(max_redirects.into()), + false => RedirectBehavior::ManualRedirect, + } +} + use pyo3::FromPyObject; #[derive(FromPyObject)] diff --git a/impit-python/test/async_client_test.py b/impit-python/test/async_client_test.py index f1432985..e4ea4e50 100644 --- a/impit-python/test/async_client_test.py +++ b/impit-python/test/async_client_test.py @@ -465,6 +465,21 @@ async def test_limit_redirects(self, browser: Browser) -> None: with pytest.raises(TooManyRedirects): await impit.get(redirect_url) + @pytest.mark.asyncio + async def test_per_request_redirect_options(self, browser: Browser) -> None: + redirect_url = get_httpbin_url('/absolute-redirect/3') + + impit = AsyncClient(browser=browser) + assert (await impit.get(redirect_url)).status_code == 302 + assert (await impit.get(redirect_url, follow_redirects=True)).status_code == 200 + async with impit.stream('GET', redirect_url, follow_redirects=True) as response: + assert response.status_code == 200 + with pytest.raises(TooManyRedirects): + await impit.request('GET', redirect_url, follow_redirects=True, max_redirects=2) + + impit = AsyncClient(browser=browser, follow_redirects=True) + assert (await impit.get(redirect_url, follow_redirects=False)).status_code == 302 + @pytest.mark.asyncio async def test_async_socket_server(self, browser: Browser) -> None: port_holder = [0] diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index 7707ba26..c94832ec 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -408,6 +408,20 @@ def test_limit_redirects(self, browser: Browser) -> None: with pytest.raises(TooManyRedirects): impit.get(redirect_url) + def test_per_request_redirect_options(self, browser: Browser) -> None: + redirect_url = get_httpbin_url('/absolute-redirect/3') + + impit = Client(browser=browser) + assert impit.get(redirect_url).status_code == 302 + assert impit.get(redirect_url, follow_redirects=True).status_code == 200 + with impit.stream('GET', redirect_url, follow_redirects=True) as response: + assert response.status_code == 200 + with pytest.raises(TooManyRedirects): + impit.request('GET', redirect_url, follow_redirects=True, max_redirects=2) + + impit = Client(browser=browser, follow_redirects=True) + assert impit.get(redirect_url, follow_redirects=False).status_code == 302 + def test_thread_server(self, browser: Browser) -> None: port_holder = [0] thread = threading.Thread(target=thread_server, args=(port_holder,)) diff --git a/impit/src/errors.rs b/impit/src/errors.rs index 97d01570..72cdddb3 100644 --- a/impit/src/errors.rs +++ b/impit/src/errors.rs @@ -6,7 +6,6 @@ use thiserror::Error; #[derive(Default)] pub struct ErrorContext { pub timeout: Option, - pub max_redirects: Option, pub method: Option, pub protocol: Option, pub url: Option, @@ -118,10 +117,6 @@ impl ImpitError { return ImpitError::TimeoutException(context.timeout.map(|x| x.as_millis())); } - if error.is_redirect() { - return ImpitError::TooManyRedirects(context.max_redirects); - } - if (error.is_body() || error.is_decode()) && (format!("{:?}", error).to_lowercase()).contains("unexpectedeof") { diff --git a/impit/src/impit.rs b/impit/src/impit.rs index 9a8164cc..47c71154 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -2,8 +2,20 @@ use tokio::sync::RwLock; use log::{debug, warn}; -use reqwest::{cookie::CookieStore, header::HeaderMap, Method, Response, Version}; -use std::{fmt::Debug, net::IpAddr, sync::Arc, time::Duration}; +use reqwest::{ + cookie::CookieStore, + header::{ + HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, + COOKIE, LOCATION, PROXY_AUTHORIZATION, REFERER, TRANSFER_ENCODING, WWW_AUTHENTICATE, + }, + Method, Response, StatusCode, Version, +}; +use std::{ + fmt::Debug, + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; use url::Url; #[cfg(feature = "http3")] @@ -320,14 +332,8 @@ impl Impit { client = client.local_address(ip_addr); } - match config.redirect { - RedirectBehavior::FollowRedirect(max) => { - client = client.redirect(reqwest::redirect::Policy::limited(max)); - } - RedirectBehavior::ManualRedirect => { - client = client.redirect(reqwest::redirect::Policy::none()); - } - } + // Redirects are followed by `Impit::send`, so they can be configured per request. + client = client.redirect(reqwest::redirect::Policy::none()); client .build() @@ -483,43 +489,32 @@ impl Impit { req.send().await } - async fn send( + /// Sends a single request of the redirect chain. `remaining` is the time left from `timeout`. + async fn send_once( &self, - request: ImpitRequest, - timeout: Option, - http3_prior_knowledge: Option, + prepared: &mut PreparedRequest, + timeout: Duration, + remaining: Duration, + http3_prior_knowledge: bool, ) -> Result { - let http3_prior_knowledge = http3_prior_knowledge.unwrap_or(false); if http3_prior_knowledge && self.config.max_http_version < Version::HTTP_3 { return Err(ImpitError::Http3Disabled); } let h3 = http3_prior_knowledge || self - .should_use_h3(request.url.host_str().unwrap_or_default()) + .should_use_h3(prepared.url.host_str().unwrap_or_default()) .await; let client = if h3 { - debug!("Using QUIC for request to {}", request.url); + debug!("Using QUIC for request to {}", prepared.url); self.h3_client.as_ref().unwrap_or(&self.base_client) } else { - debug!("{} doesn't seem to have HTTP3 support", request.url); + debug!("{} doesn't seem to have HTTP3 support", prepared.url); &self.base_client }; - let max_redirects = match self.config.redirect { - RedirectBehavior::FollowRedirect(max) => max, - RedirectBehavior::ManualRedirect => 0, - }; - - let mut prepared = PreparedRequest { - method: request.method, - url: request.url, - headers: request.headers, - body: request.body, - }; - let primary_result = self - .execute_request(client, &mut prepared, timeout, h3) + .execute_request(client, prepared, Some(remaining), h3) .await; let response = match primary_result { @@ -528,8 +523,7 @@ impl Impit { let primary_error = ImpitError::from( err, Some(ErrorContext { - timeout: Some(timeout.unwrap_or(self.config.request_timeout)), - max_redirects: Some(max_redirects), + timeout: Some(timeout), method: Some(prepared.method.to_string()), protocol: Some(prepared.url.scheme().to_string()), url: Some(prepared.url.to_string()), @@ -549,7 +543,7 @@ impl Impit { prepared.url ); match self - .execute_request(vanilla_client, &mut prepared, timeout, false) + .execute_request(vanilla_client, prepared, Some(remaining), false) .await { Ok(resp) => resp, @@ -581,6 +575,44 @@ impl Impit { Ok(response) } + async fn send( + &self, + request: ImpitRequest, + timeout: Option, + http3_prior_knowledge: bool, + redirect: &RedirectBehavior, + ) -> Result { + let timeout = timeout.unwrap_or(self.config.request_timeout); + let start = Instant::now(); + let mut redirects = 0; + + let mut prepared = PreparedRequest { + method: request.method, + url: request.url, + headers: request.headers, + body: request.body, + }; + + loop { + // The timeout bounds the whole redirect chain, not each request. + let remaining = timeout.saturating_sub(start.elapsed()); + let response = self + .send_once(&mut prepared, timeout, remaining, http3_prior_knowledge) + .await?; + + let RedirectBehavior::FollowRedirect(max_redirects) = *redirect else { + return Ok(response); + }; + if !follow_redirect(&mut prepared, &response)? { + return Ok(response); + } + if redirects == max_redirects { + return Err(ImpitError::TooManyRedirects(Some(max_redirects))); + } + redirects += 1; + } + } + async fn make_request( &self, method: Method, @@ -600,9 +632,16 @@ impl Impit { Some(None) => Some(Duration::MAX), Some(Some(d)) => Some(d), }; - let http3_prior_knowledge = request_options.http3_prior_knowledge; - self.send(request, timeout, Some(http3_prior_knowledge)) - .await + self.send( + request, + timeout, + request_options.http3_prior_knowledge, + request_options + .redirect + .as_ref() + .unwrap_or(&self.config.redirect), + ) + .await } /// Makes a `GET` request to the specified URL. @@ -733,12 +772,151 @@ impl Impit { } } +/// Turns `request` into the request following the redirect in `response`, the same way `reqwest` does. +/// +/// Returns `false` and leaves `request` as is if `response` isn't a redirect that can be followed. +fn follow_redirect(request: &mut PreparedRequest, response: &Response) -> Result { + let drop_body = match response.status() { + StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => request.method == Method::POST, + StatusCode::SEE_OTHER => true, + StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT => false, + _ => return Ok(false), + }; + + if !drop_body && !request.body.is_sendable() { + return Ok(false); + } + + let Some(url) = response + .headers() + .get(LOCATION) + .and_then(|location| std::str::from_utf8(location.as_bytes()).ok()) + .and_then(|location| request.url.join(location).ok()) + else { + return Ok(false); + }; + + if !matches!(url.scheme(), "http" | "https") { + return Err(ImpitError::UrlProtocolError(url.scheme().to_string())); + } + + if drop_body { + if request.method != Method::HEAD { + request.method = Method::GET; + } + request.body = ImpitBody::Empty; + for header in [ + CONTENT_TYPE, + CONTENT_LENGTH, + CONTENT_ENCODING, + TRANSFER_ENCODING, + ] { + request.headers.remove(header); + } + } + + let previous = &request.url; + if url.host_str() != previous.host_str() + || url.port_or_known_default() != previous.port_or_known_default() + || url.scheme() != previous.scheme() + { + for header in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION, WWW_AUTHENTICATE] { + request.headers.remove(header); + } + request.headers.remove("cookie2"); + } + + if !(url.scheme() == "http" && previous.scheme() == "https") { + let mut referer = previous.clone(); + let _ = referer.set_username(""); + let _ = referer.set_password(None); + referer.set_fragment(None); + if let Ok(referer) = HeaderValue::from_str(referer.as_str()) { + request.headers.insert(REFERER, referer); + } + } + + request.url = url; + Ok(true) +} + #[cfg(test)] mod tests { - use reqwest::cookie::Jar; + use reqwest::{cookie::Jar, header::HeaderMap, Method, Response}; + + use super::{follow_redirect, Impit, PreparedRequest}; + use crate::{ + errors::ImpitError, + request::{ImpitBody, RequestOptions}, + }; + + fn request(method: Method, url: &str, headers: &[(&str, &str)]) -> PreparedRequest { + let headers: HeaderMap = headers + .iter() + .map(|(name, value)| (name.parse().unwrap(), value.parse().unwrap())) + .collect(); + PreparedRequest { + url: url.parse().unwrap(), + body: ImpitBody::from("body"), + headers, + method, + } + } - use super::Impit; - use crate::{errors::ImpitError, request::RequestOptions}; + fn redirect(status: u16, location: &str) -> Response { + hyper::Response::builder() + .status(status) + .header("location", location) + .body("") + .unwrap() + .into() + } + + #[test] + fn found_redirects_post_as_get() { + let mut request = request( + Method::POST, + "https://example.com/form", + &[("content-type", "text/plain")], + ); + + assert!(follow_redirect(&mut request, &redirect(302, "/done")).unwrap()); + assert_eq!(request.method, Method::GET); + assert_eq!(request.url.as_str(), "https://example.com/done"); + assert!(request.body.take().is_none()); + assert!(request.headers.get("content-type").is_none()); + assert_eq!(request.headers["referer"], "https://example.com/form"); + } + + #[test] + fn cross_origin_redirects_drop_credentials() { + let mut request = request( + Method::PUT, + "https://example.com/", + &[ + ("authorization", "secret"), + ("cookie", "a=b"), + ("x-custom", "1"), + ], + ); + + assert!(follow_redirect(&mut request, &redirect(307, "https://example.org/")).unwrap()); + assert_eq!(request.method, Method::PUT); + assert!(request.body.take().is_some()); + assert!(request.headers.get("authorization").is_none()); + assert!(request.headers.get("cookie").is_none()); + assert_eq!(request.headers["x-custom"], "1"); + } + + #[test] + fn sent_streamed_bodies_are_not_replayed() { + let mut request = request(Method::POST, "https://example.com/", &[]); + request.body = ImpitBody::Consumed; + + assert!(!follow_redirect(&mut request, &redirect(307, "/next")).unwrap()); + assert_eq!(request.url.as_str(), "https://example.com/"); + assert!(follow_redirect(&mut request, &redirect(303, "/next")).unwrap()); + } async fn get_with_http3_prior_knowledge(impit: &Impit) -> Result<(), ImpitError> { let options = RequestOptions { diff --git a/impit/src/request.rs b/impit/src/request.rs index 12e878aa..9799c725 100644 --- a/impit/src/request.rs +++ b/impit/src/request.rs @@ -5,6 +5,8 @@ use futures_core::TryStream; use reqwest::{header::HeaderMap, Method}; use url::Url; +use crate::impit::RedirectBehavior; + /// A struct that holds the request options. /// /// Unlike the [`ImpitBuilder`](crate::impit::ImpitBuilder) struct, these options are specific to a single request. @@ -25,6 +27,8 @@ pub struct RequestOptions { /// /// If [`ImpitBuilder::with_http3`](crate::impit::ImpitBuilder::with_http3) wasn't called, this option will cause [`ErrorType::Http3Disabled`](crate::impit::ErrorType::Http3Disabled) errors. pub http3_prior_knowledge: bool, + /// Overrides the redirect behavior set with [`ImpitBuilder::with_redirect`](crate::impit::ImpitBuilder::with_redirect) for this request. + pub redirect: Option, } /// The body of a request.