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(¶ms);
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}