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/// A standalone HTTP client. Cheap to clone — it shares one underlying
21/// `reqwest::Client` and a set of default headers.
22///
23/// Mirrors <https://playwright.dev/docs/api/class-apirequestcontext>.
24#[derive(Clone)]
25pub struct APIRequestContext {
26    client: reqwest::Client,
27    default_headers: Headers,
28}
29
30impl APIRequestContext {
31    /// Create a new context with the given default headers (cloned into the
32    /// context). A fresh `reqwest::Client` is built once and reused.
33    pub fn new(default_headers: Headers) -> Self {
34        let client = reqwest::Client::builder()
35            .build()
36            .unwrap_or_else(|_| reqwest::Client::new());
37        Self {
38            client,
39            default_headers,
40        }
41    }
42
43    /// The default headers carried by this context.
44    pub fn default_headers(&self) -> &Headers {
45        &self.default_headers
46    }
47
48    /// `GET`.
49    pub async fn get(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
50        self.send(reqwest::Method::GET, url, options).await
51    }
52
53    /// `POST`.
54    pub async fn post(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
55        self.send(reqwest::Method::POST, url, options).await
56    }
57
58    /// `PUT`.
59    pub async fn put(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
60        self.send(reqwest::Method::PUT, url, options).await
61    }
62
63    /// `PATCH`.
64    pub async fn patch(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
65        self.send(reqwest::Method::PATCH, url, options).await
66    }
67
68    /// `DELETE`.
69    pub async fn delete(
70        &self,
71        url: &str,
72        options: Option<APIRequestOptions>,
73    ) -> Result<APIResponse> {
74        self.send(reqwest::Method::DELETE, url, options).await
75    }
76
77    /// `HEAD`.
78    pub async fn head(&self, url: &str, options: Option<APIRequestOptions>) -> Result<APIResponse> {
79        self.send(reqwest::Method::HEAD, url, options).await
80    }
81
82    /// Build + send a request for `method`, applying options, then capture the
83    /// full body so accessors are cheap.
84    async fn send(
85        &self,
86        method: reqwest::Method,
87        url: &str,
88        options: Option<APIRequestOptions>,
89    ) -> Result<APIResponse> {
90        let options = options.unwrap_or_default();
91        let mut builder = self.client.request(method, url);
92
93        // Default headers first, then per-request overrides.
94        for (k, v) in &self.default_headers {
95            builder = builder.header(k.as_str(), v.as_str());
96        }
97        if let Some(headers) = options.headers.as_ref() {
98            for (k, v) in headers {
99                builder = builder.header(k.as_str(), v.as_str());
100            }
101        }
102
103        // Query params.
104        if let Some(params) = options.params.as_ref() {
105            builder = builder.query(&params);
106        }
107
108        // Body: JSON `data` takes precedence over `form`.
109        if let Some(data) = options.data.as_ref() {
110            builder = builder.json(data);
111        } else if let Some(form) = options.form.as_ref() {
112            builder = builder.form(form);
113        }
114
115        // Per-request timeout.
116        if let Some(timeout_ms) = options.timeout {
117            builder = builder.timeout(Duration::from_millis(timeout_ms.max(0.0) as u64));
118        }
119
120        let resp = builder
121            .send()
122            .await
123            .map_err(|e| Error::Http(format!("request failed: {e}")))?;
124
125        let url = resp.url().to_string();
126        let status = resp.status().as_u16();
127        // Lowercased header keys, matching the network Response shape.
128        let mut headers: Headers = HashMap::with_capacity(resp.headers().len());
129        for (name, value) in resp.headers().iter() {
130            let key = name.as_str().to_ascii_lowercase();
131            let val = match value.to_str() {
132                Ok(s) => s.to_string(),
133                Err(_) => {
134                    // Fallback: lossy decode of raw bytes (rare for HTTP headers).
135                    String::from_utf8_lossy(value.as_bytes()).into_owned()
136                }
137            };
138            headers.insert(key, val);
139        }
140
141        let body = resp
142            .bytes()
143            .await
144            .map_err(|e| Error::Http(format!("failed to read body: {e}")))?;
145
146        Ok(APIResponse {
147            url,
148            status,
149            headers,
150            body: Arc::from(body.as_ref()),
151        })
152    }
153}
154
155/// A captured HTTP response. The full body is read once at construction and
156/// cached, so [`APIResponse::body`], [`APIResponse::text`], and
157/// [`APIResponse::json`] are cheap and re-entrant.
158///
159/// Mirrors <https://playwright.dev/docs/api/class-apiresponse>.
160#[derive(Debug, Clone)]
161pub struct APIResponse {
162    url: String,
163    status: u16,
164    headers: Headers,
165    body: Arc<[u8]>,
166}
167
168impl APIResponse {
169    /// The final URL after any redirects.
170    pub fn url(&self) -> &str {
171        &self.url
172    }
173
174    /// HTTP status code.
175    pub fn status(&self) -> u16 {
176        self.status
177    }
178
179    /// True when the status is in the `200..300` range.
180    pub fn ok(&self) -> bool {
181        (200..300).contains(&self.status)
182    }
183
184    /// Response headers, with lowercased keys.
185    pub fn headers(&self) -> &Headers {
186        &self.headers
187    }
188
189    /// The raw response body (cached).
190    pub async fn body(&self) -> Result<Vec<u8>> {
191        Ok(self.body.to_vec())
192    }
193
194    /// The response body decoded as UTF-8 (cached).
195    pub async fn text(&self) -> Result<String> {
196        Ok(String::from_utf8_lossy(&self.body).into_owned())
197    }
198
199    /// The response body parsed as JSON (re-parsed each call from the cached bytes).
200    pub async fn json(&self) -> Result<serde_json::Value> {
201        serde_json::from_slice(&self.body).map_err(Into::into)
202    }
203}