Skip to main content

playwright_cdp/
api_request.rs

1//! Standalone HTTP client mirroring Playwright's `APIRequestContext` /
2//! `APIResponse`.
3//!
4//! Unlike [`crate::Request`]/[`crate::Response`] (which represent network
5//! traffic captured *inside* the browser page), an [`APIRequestContext`] is a
6//! direct HTTP client — useful for exercising APIs alongside browser
7//! automation. It carries a single shared `reqwest::Client` and a set of
8//! default headers, and exposes the usual `get`/`post`/`put`/... verbs.
9//!
10//! Obtain one via [`crate::Page::request`] or [`crate::BrowserContext::request`].
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Duration;
15
16use crate::error::{Error, Result};
17use crate::options::APIRequestOptions;
18use crate::types::Headers;
19
20/// Configuration for building an [`APIRequestContext`].
21///
22/// Mirrors the relevant subset of Playwright's `newContext({ baseURL, ... })`
23/// options that apply to a standalone API request context. Use [`Default`] for
24/// sensible no-op defaults, then chain the builder setters:
25///
26/// ```no_run
27/// # use std::time::Duration;
28/// # use playwright_cdp::api_request::ApiRequestContextOptions;
29/// let opts = ApiRequestContextOptions::default()
30///     .base_url("https://api.example.com")
31///     .user_agent("my-bot/1.0")
32///     .timeout(Duration::from_secs(30))
33///     .ignore_https_errors(true);
34/// ```
35///
36/// See: <https://playwright.dev/docs/api/class-apirequestcontext>
37#[derive(Debug, Clone, Default)]
38#[non_exhaustive]
39pub struct ApiRequestContextOptions {
40    /// Base URL prepended to any relative request URL (one that does not start
41    /// with `http://` or `https://`).
42    pub base_url: Option<String>,
43    /// `User-Agent` header applied to every request. Per-request headers take
44    /// precedence.
45    pub user_agent: Option<String>,
46    /// Default per-request timeout. Overridden by a per-request `timeout` in
47    /// [`APIRequestOptions`].
48    pub timeout: Option<Duration>,
49    /// When true, the underlying `reqwest::Client` accepts invalid TLS
50    /// certificates (mirrors Playwright's `ignoreHTTPSErrors`).
51    pub ignore_https_errors: bool,
52    /// Extra headers merged into every request, below per-request headers.
53    pub extra_http_headers: Headers,
54}
55
56impl ApiRequestContextOptions {
57    /// Start from defaults.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Set the base URL prepended to relative request URLs.
63    pub fn base_url(mut self, url: impl Into<String>) -> Self {
64        self.base_url = Some(url.into());
65        self
66    }
67
68    /// Set the `User-Agent` header applied to every request.
69    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
70        self.user_agent = Some(ua.into());
71        self
72    }
73
74    /// Set the default per-request timeout.
75    pub fn timeout(mut self, timeout: Duration) -> Self {
76        self.timeout = Some(timeout);
77        self
78    }
79
80    /// When true, accept invalid TLS certificates.
81    pub fn ignore_https_errors(mut self, ignore: bool) -> Self {
82        self.ignore_https_errors = ignore;
83        self
84    }
85
86    /// Set the extra headers merged into every request (replaces any
87    /// previously set extras).
88    pub fn extra_http_headers(mut self, headers: Headers) -> Self {
89        self.extra_http_headers = headers;
90        self
91    }
92
93    /// Add a single extra header.
94    pub fn extra_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
95        self.extra_http_headers.insert(name.into(), value.into());
96        self
97    }
98}
99
100/// A standalone HTTP client. Cheap to clone — it shares one underlying
101/// `reqwest::Client` and a set of default headers.
102///
103/// Mirrors <https://playwright.dev/docs/api/class-apirequestcontext>.
104#[derive(Clone)]
105pub struct APIRequestContext {
106    client: reqwest::Client,
107    default_headers: Headers,
108    options: ApiRequestContextOptions,
109}
110
111impl APIRequestContext {
112    /// Create a new context with the given default headers (cloned into the
113    /// context). A fresh `reqwest::Client` is built once and reused.
114    ///
115    /// Equivalent to [`APIRequestContext::new_with_options`] with the headers
116    /// passed as `extra_http_headers` and all other options at their defaults.
117    pub fn new(default_headers: Headers) -> Self {
118        let mut options = ApiRequestContextOptions::default();
119        options.extra_http_headers = default_headers;
120        Self::new_with_options(options)
121    }
122
123    /// Create a new context from a full [`ApiRequestContextOptions`]. The
124    /// underlying `reqwest::Client` is built once (honoring
125    /// `ignore_https_errors` and the default `timeout`) and reused for every
126    /// request.
127    pub fn new_with_options(options: ApiRequestContextOptions) -> Self {
128        let mut builder = reqwest::Client::builder();
129        if options.ignore_https_errors {
130            builder = builder.danger_accept_invalid_certs(true);
131        }
132        if let Some(timeout) = options.timeout {
133            builder = builder.timeout(timeout);
134        }
135        if let Some(user_agent) = options.user_agent.as_deref() {
136            builder = builder.user_agent(user_agent);
137        }
138        let client = builder
139            .build()
140            .unwrap_or_else(|_| reqwest::Client::new());
141        // The default headers served to `default_headers()` are the
142        // `extra_http_headers` from the options.
143        let default_headers = options.extra_http_headers.clone();
144        Self {
145            client,
146            default_headers,
147            options,
148        }
149    }
150
151    /// The default headers carried by this context (seeded from
152    /// `extra_http_headers`).
153    pub fn default_headers(&self) -> &Headers {
154        &self.default_headers
155    }
156
157    /// The options this context was built with.
158    pub fn options(&self) -> &ApiRequestContextOptions {
159        &self.options
160    }
161
162    /// `GET`.
163    pub async fn get(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
164        self.send(reqwest::Method::GET, url, options).await
165    }
166
167    /// `POST`.
168    pub async fn post(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
169        self.send(reqwest::Method::POST, url, options).await
170    }
171
172    /// `PUT`.
173    pub async fn put(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
174        self.send(reqwest::Method::PUT, url, options).await
175    }
176
177    /// `PATCH`.
178    pub async fn patch(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
179        self.send(reqwest::Method::PATCH, url, options).await
180    }
181
182    /// `DELETE`.
183    pub async fn delete(
184        &self,
185        url: &str,
186        options: Option<APIRequestOptions>,
187    ) -> Result<APIResponse> {
188        self.send(reqwest::Method::DELETE, url, options).await
189    }
190
191    /// `HEAD`.
192    pub async fn head(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
193        self.send(reqwest::Method::HEAD, url, options).await
194    }
195
196    /// Resolve `url` against the configured base URL. Relative URLs (those not
197    /// starting with `http://` or `https://`) are joined onto `base_url`;
198    /// absolute URLs are returned verbatim. When no base URL is configured the
199    /// input is returned unchanged.
200    fn resolve_url(&self, url: &str) -> String {
201        let Some(base) = self.options.base_url.as_deref() else {
202            return url.to_string();
203        };
204        if url.starts_with("http://") || url.starts_with("https://") {
205            return url.to_string();
206        }
207        // Join base + relative, handling the leading '/' on either side so we
208        // avoid accidental double or missing slashes.
209        let base = base.trim_end_matches('/');
210        let path = if url.starts_with('/') {
211            url.to_string()
212        } else {
213            format!("/{url}")
214        };
215        format!("{base}{path}")
216    }
217
218    /// Build + send a request for `method`, applying options, then capture the
219    /// full body so accessors are cheap.
220    async fn send(
221        &self,
222        method: reqwest::Method,
223        url: &str,
224        options: Option<APIRequestOptions>,
225    ) -> Result<APIResponse> {
226        let options = options.unwrap_or_default();
227
228        // Resolve a relative URL against the configured base URL. Absolute URLs
229        // (and other schemes) are used verbatim.
230        let url = self.resolve_url(url);
231
232        let mut builder = self.client.request(method, &url);
233
234        // The context-wide user_agent is already baked into the shared client
235        // (set via ClientBuilder). Per-request headers below can still override
236        // it by setting `User-Agent` explicitly.
237
238        // Default headers first, then per-request overrides.
239        for (k, v) in &self.default_headers {
240            builder = builder.header(k.as_str(), v.as_str());
241        }
242        if let Some(headers) = options.headers.as_ref() {
243            for (k, v) in headers {
244                builder = builder.header(k.as_str(), v.as_str());
245            }
246        }
247
248        // Query params.
249        if let Some(params) = options.params.as_ref() {
250            builder = builder.query(&params);
251        }
252
253        // Body: JSON `data` takes precedence over `form`.
254        if let Some(data) = options.data.as_ref() {
255            builder = builder.json(data);
256        } else if let Some(form) = options.form.as_ref() {
257            builder = builder.form(form);
258        }
259
260        // Per-request timeout overrides the context default.
261        if let Some(timeout_ms) = options.timeout {
262            builder = builder.timeout(Duration::from_millis(timeout_ms.max(0.0) as u64));
263        }
264
265        let resp = builder
266            .send()
267            .await
268            .map_err(|e| Error::Http(format!("request failed: {e}")))?;
269
270        let url = resp.url().to_string();
271        let status = resp.status().as_u16();
272        // Lowercased header keys, matching the network Response shape.
273        let mut headers: Headers = HashMap::with_capacity(resp.headers().len());
274        for (name, value) in resp.headers().iter() {
275            let key = name.as_str().to_ascii_lowercase();
276            let val = match value.to_str() {
277                Ok(s) => s.to_string(),
278                Err(_) => {
279                    // Fallback: lossy decode of raw bytes (rare for HTTP headers).
280                    String::from_utf8_lossy(value.as_bytes()).into_owned()
281                }
282            };
283            headers.insert(key, val);
284        }
285
286        let body = resp
287            .bytes()
288            .await
289            .map_err(|e| Error::Http(format!("failed to read body: {e}")))?;
290
291        Ok(APIResponse {
292            url,
293            status,
294            headers,
295            body: Arc::from(body.as_ref()),
296        })
297    }
298}
299
300/// A captured HTTP response. The full body is read once at construction and
301/// cached, so [`APIResponse::body`], [`APIResponse::text`], and
302/// [`APIResponse::json`] are cheap and re-entrant.
303///
304/// Mirrors <https://playwright.dev/docs/api/class-apiresponse>.
305#[derive(Debug, Clone)]
306pub struct APIResponse {
307    url: String,
308    status: u16,
309    headers: Headers,
310    body: Arc<[u8]>,
311}
312
313impl APIResponse {
314    /// The final URL after any redirects.
315    pub fn url(&self) -> &str {
316        &self.url
317    }
318
319    /// HTTP status code.
320    pub fn status(&self) -> u16 {
321        self.status
322    }
323
324    /// True when the status is in the `200..300` range.
325    pub fn ok(&self) -> bool {
326        (200..300).contains(&self.status)
327    }
328
329    /// Response headers, with lowercased keys.
330    pub fn headers(&self) -> &Headers {
331        &self.headers
332    }
333
334    /// The raw response body (cached).
335    pub async fn body(&self) -> Result<Vec<u8>> {
336        Ok(self.body.to_vec())
337    }
338
339    /// The response body decoded as UTF-8 (cached).
340    pub async fn text(&self) -> Result<String> {
341        Ok(String::from_utf8_lossy(&self.body).into_owned())
342    }
343
344    /// The response body parsed as JSON (re-parsed each call from the cached bytes).
345    pub async fn json(&self) -> Result<serde_json::Value> {
346        serde_json::from_slice(&self.body).map_err(Into::into)
347    }
348}