1pub mod transport;
25
26use std::time::Duration;
27
28use anyhow::{anyhow, Context, Result};
29use bytes::Bytes;
30use hyper::header::{HeaderMap, CONTENT_TYPE};
31use hyper::{Method, StatusCode};
32use serde::de::DeserializeOwned;
33use serde::Serialize;
34
35#[derive(Clone)]
38pub struct FetchClient {
39 backend: Backend,
40}
41
42#[derive(Clone)]
43enum Backend {
44 #[cfg(not(target_arch = "wasm32"))]
45 Reqwest(reqwest::Client, usize),
46 Transport {
47 timeout: Duration,
48 max_body: usize,
49 user_agent: Option<String>,
50 wire: transport::Wire,
51 },
52}
53
54impl FetchClient {
55 #[cfg(not(target_arch = "wasm32"))]
57 pub fn from_reqwest(client: reqwest::Client) -> Self {
58 Self {
59 backend: Backend::Reqwest(client, transport::MAX_RESPONSE_BODY_BYTES),
60 }
61 }
62
63 pub fn transport(timeout: Duration) -> Self {
66 Self {
67 backend: Backend::Transport {
68 timeout,
69 max_body: transport::MAX_RESPONSE_BODY_BYTES,
70 user_agent: None,
71 wire: transport::Wire::Http1,
72 },
73 }
74 }
75
76 pub fn with_max_body(mut self, max_body: usize) -> Self {
79 match &mut self.backend {
80 #[cfg(not(target_arch = "wasm32"))]
81 Backend::Reqwest(_, bound) => *bound = max_body,
82 Backend::Transport {
83 max_body: bound, ..
84 } => *bound = max_body,
85 }
86 self
87 }
88
89 #[allow(irrefutable_let_patterns)]
93 pub fn with_user_agent(mut self, user_agent: &str) -> Self {
94 if let Backend::Transport { user_agent: ua, .. } = &mut self.backend {
95 *ua = Some(user_agent.to_string());
96 }
97 self
98 }
99
100 #[allow(irrefutable_let_patterns)]
104 pub fn http2_prior_knowledge(mut self) -> Self {
105 if let Backend::Transport { wire, .. } = &mut self.backend {
106 *wire = transport::Wire::Http2PriorKnowledge;
107 }
108 self
109 }
110
111 pub fn request(&self, method: Method, url: &str) -> FetchRequest {
112 match &self.backend {
113 #[cfg(not(target_arch = "wasm32"))]
114 Backend::Reqwest(client, max_body) => FetchRequest {
115 inner: RequestInner::Reqwest(client.request(method, url), *max_body),
116 },
117 Backend::Transport {
118 timeout,
119 max_body,
120 user_agent,
121 wire,
122 } => FetchRequest {
123 inner: RequestInner::Transport(TransportRequest {
124 method,
125 url: url.to_string(),
126 headers: Vec::new(),
127 user_agent: user_agent.clone(),
128 wire: *wire,
129 body: None,
130 timeout: *timeout,
131 max_body: *max_body,
132 error: None,
133 }),
134 },
135 }
136 }
137
138 pub fn get(&self, url: &str) -> FetchRequest {
139 self.request(Method::GET, url)
140 }
141
142 pub fn post(&self, url: &str) -> FetchRequest {
143 self.request(Method::POST, url)
144 }
145
146 pub fn put(&self, url: &str) -> FetchRequest {
147 self.request(Method::PUT, url)
148 }
149
150 pub fn delete(&self, url: &str) -> FetchRequest {
151 self.request(Method::DELETE, url)
152 }
153
154 pub fn head(&self, url: &str) -> FetchRequest {
155 self.request(Method::HEAD, url)
156 }
157
158 pub fn patch(&self, url: &str) -> FetchRequest {
159 self.request(Method::PATCH, url)
160 }
161}
162
163pub fn check_url(url: &str) -> Result<()> {
167 #[cfg(target_arch = "wasm32")]
168 transport::parse_http_url(url)?;
169 #[cfg(not(target_arch = "wasm32"))]
170 let _ = url;
171 Ok(())
172}
173
174pub fn origin_of(url: &str) -> Result<String> {
177 let parsed = url::Url::parse(url).with_context(|| format!("{url:?} is not a URL"))?;
178 anyhow::ensure!(
179 matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
180 "{url:?} is not an http(s) URL with a host"
181 );
182 Ok(parsed.origin().ascii_serialization())
183}
184
185pub fn resolve_same_origin(base_url: &str, path: &str) -> Result<String> {
206 let base = if base_url.contains("://") {
207 base_url.to_string()
208 } else {
209 format!("http://{base_url}")
210 };
211 let bound = origin_of(&base)?;
212 let base_parsed = url::Url::parse(&base).with_context(|| format!("{base:?} is not a URL"))?;
213
214 let has_scheme = |scheme: &str| {
215 path.as_bytes()
216 .get(..scheme.len())
217 .is_some_and(|head| head.eq_ignore_ascii_case(scheme.as_bytes()))
218 };
219 let is_absolute = has_scheme("http://") || has_scheme("https://");
220 let candidate = if is_absolute {
221 path.to_string()
222 } else if path.is_empty() || path.starts_with(['/', '?', '#']) || base.ends_with('/') {
223 format!("{base}{path}")
224 } else {
225 format!("{base}/{path}")
226 };
227
228 let resolved = url::Url::parse(&candidate)
229 .with_context(|| format!("path {path:?} does not form a URL"))?;
230 let asked = origin_of(resolved.as_str())?;
231 anyhow::ensure!(
232 bound == asked,
233 "path {path:?} is on {asked}, but this client is bound to {bound}; use a path \
234 relative to that origin, or open a client for {asked}"
235 );
236 anyhow::ensure!(
237 resolved.username() == base_parsed.username()
238 && resolved.password() == base_parsed.password(),
239 "path {path:?} carries credentials in the URL, which this client bound to {bound} \
240 refuses; set an Authorization header instead"
241 );
242 Ok(resolved.into())
243}
244
245#[cfg(not(target_arch = "wasm32"))]
252pub fn same_origin_redirects() -> reqwest::redirect::Policy {
253 reqwest::redirect::Policy::custom(|attempt| {
254 let first = attempt
255 .previous()
256 .first()
257 .map(|u| u.origin().ascii_serialization());
258 let next = attempt.url().origin().ascii_serialization();
259 if attempt.previous().len() >= 5 || first.as_deref() != Some(next.as_str()) {
260 attempt.stop()
261 } else {
262 attempt.follow()
263 }
264 })
265}
266
267pub struct FetchRequest {
270 inner: RequestInner,
271}
272
273enum RequestInner {
274 #[cfg(not(target_arch = "wasm32"))]
275 Reqwest(reqwest::RequestBuilder, usize),
276 Transport(TransportRequest),
277}
278
279struct TransportRequest {
280 method: Method,
281 url: String,
282 headers: Vec<(String, String)>,
283 body: Option<Bytes>,
284 user_agent: Option<String>,
285 wire: transport::Wire,
286 timeout: Duration,
287 max_body: usize,
288 error: Option<anyhow::Error>,
289}
290
291impl TransportRequest {
292 fn has_header(&self, name: &str) -> bool {
293 self.headers
294 .iter()
295 .any(|(n, _)| n.eq_ignore_ascii_case(name))
296 }
297
298 fn fail(&mut self, error: anyhow::Error) {
299 if self.error.is_none() {
300 self.error = Some(error);
301 }
302 }
303}
304
305impl FetchRequest {
306 pub fn header(self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
308 match self.inner {
309 #[cfg(not(target_arch = "wasm32"))]
310 RequestInner::Reqwest(builder, max_body) => Self {
311 inner: RequestInner::Reqwest(
312 builder.header(name.as_ref(), value.as_ref()),
313 max_body,
314 ),
315 },
316 RequestInner::Transport(mut req) => {
317 req.headers
318 .push((name.as_ref().to_string(), value.as_ref().to_string()));
319 Self {
320 inner: RequestInner::Transport(req),
321 }
322 }
323 }
324 }
325
326 pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Self {
329 match self.inner {
330 #[cfg(not(target_arch = "wasm32"))]
331 RequestInner::Reqwest(builder, max_body) => Self {
332 inner: RequestInner::Reqwest(builder.json(value), max_body),
333 },
334 RequestInner::Transport(mut req) => {
335 match serde_json::to_vec(value) {
336 Ok(body) => {
337 if !req.has_header(CONTENT_TYPE.as_str()) {
338 req.headers.push((
339 CONTENT_TYPE.as_str().to_string(),
340 "application/json".to_string(),
341 ));
342 }
343 req.body = Some(Bytes::from(body));
344 }
345 Err(e) => req.fail(anyhow!("serialize the JSON request body: {e}")),
346 }
347 Self {
348 inner: RequestInner::Transport(req),
349 }
350 }
351 }
352 }
353
354 pub fn query<T: Serialize + ?Sized>(self, query: &T) -> Self {
357 match self.inner {
358 #[cfg(not(target_arch = "wasm32"))]
359 RequestInner::Reqwest(builder, max_body) => Self {
360 inner: RequestInner::Reqwest(builder.query(query), max_body),
361 },
362 RequestInner::Transport(mut req) => {
363 match serde_urlencoded::to_string(query) {
364 Ok(encoded) if encoded.is_empty() => {}
365 Ok(encoded) => {
366 let separator = if req.url.contains('?') { '&' } else { '?' };
367 req.url = format!("{}{separator}{encoded}", req.url);
368 }
369 Err(e) => req.fail(anyhow!("serialize the query string: {e}")),
370 }
371 Self {
372 inner: RequestInner::Transport(req),
373 }
374 }
375 }
376 }
377
378 pub fn form<T: Serialize + ?Sized>(self, value: &T) -> Self {
380 match self.inner {
381 #[cfg(not(target_arch = "wasm32"))]
382 RequestInner::Reqwest(builder, max_body) => Self {
383 inner: RequestInner::Reqwest(builder.form(value), max_body),
384 },
385 RequestInner::Transport(mut req) => {
386 match serde_urlencoded::to_string(value) {
387 Ok(body) => {
388 if !req.has_header(CONTENT_TYPE.as_str()) {
389 req.headers.push((
390 CONTENT_TYPE.as_str().to_string(),
391 "application/x-www-form-urlencoded".to_string(),
392 ));
393 }
394 req.body = Some(Bytes::from(body));
395 }
396 Err(e) => req.fail(anyhow!("serialize the form request body: {e}")),
397 }
398 Self {
399 inner: RequestInner::Transport(req),
400 }
401 }
402 }
403 }
404
405 pub fn body(self, body: impl Into<Bytes>) -> Self {
407 let body = body.into();
408 match self.inner {
409 #[cfg(not(target_arch = "wasm32"))]
410 RequestInner::Reqwest(builder, max_body) => Self {
411 inner: RequestInner::Reqwest(builder.body(body), max_body),
412 },
413 RequestInner::Transport(mut req) => {
414 req.body = Some(body);
415 Self {
416 inner: RequestInner::Transport(req),
417 }
418 }
419 }
420 }
421
422 pub fn basic_auth(
425 self,
426 user: impl std::fmt::Display,
427 password: Option<impl std::fmt::Display>,
428 ) -> Self {
429 match self.inner {
430 #[cfg(not(target_arch = "wasm32"))]
431 RequestInner::Reqwest(builder, max_body) => Self {
432 inner: RequestInner::Reqwest(builder.basic_auth(user, password), max_body),
433 },
434 RequestInner::Transport(mut req) => {
435 use base64::Engine;
436 let credentials = match password {
437 Some(password) => format!("{user}:{password}"),
438 None => format!("{user}:"),
439 };
440 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
441 req.headers.push((
442 hyper::header::AUTHORIZATION.as_str().to_string(),
443 format!("Basic {encoded}"),
444 ));
445 Self {
446 inner: RequestInner::Transport(req),
447 }
448 }
449 }
450 }
451
452 pub fn timeout(self, timeout: Duration) -> Self {
454 match self.inner {
455 #[cfg(not(target_arch = "wasm32"))]
456 RequestInner::Reqwest(builder, max_body) => Self {
457 inner: RequestInner::Reqwest(builder.timeout(timeout), max_body),
458 },
459 RequestInner::Transport(mut req) => {
460 req.timeout = timeout;
461 Self {
462 inner: RequestInner::Transport(req),
463 }
464 }
465 }
466 }
467
468 pub async fn send(self) -> Result<FetchResponse> {
471 match self.inner {
472 #[cfg(not(target_arch = "wasm32"))]
473 RequestInner::Reqwest(builder, max_body) => Ok(FetchResponse {
474 inner: ResponseInner::Reqwest(builder.send().await?, max_body),
475 }),
476 RequestInner::Transport(mut req) => {
477 if let Some(error) = req.error.take() {
478 return Err(error);
479 }
480 if let Some(user_agent) = req.user_agent.take() {
481 if !req.has_header(hyper::header::USER_AGENT.as_str()) {
482 req.headers
483 .push((hyper::header::USER_AGENT.as_str().to_string(), user_agent));
484 }
485 }
486 let response = transport::fetch_response_on(
487 req.wire,
488 req.method.as_str(),
489 &req.url,
490 &req.headers,
491 req.body,
492 req.timeout,
493 req.max_body,
494 )
495 .await?;
496 let (parts, body) = response.into_parts();
497 Ok(FetchResponse {
498 inner: ResponseInner::Transport {
499 status: parts.status,
500 version: parts.version,
501 headers: parts.headers,
502 body: Some(body),
503 },
504 })
505 }
506 }
507 }
508}
509
510pub struct FetchResponse {
513 inner: ResponseInner,
514}
515
516enum ResponseInner {
517 #[cfg(not(target_arch = "wasm32"))]
518 Reqwest(reqwest::Response, usize),
519 Transport {
520 status: StatusCode,
521 version: hyper::Version,
522 headers: HeaderMap,
523 body: Option<Bytes>,
524 },
525}
526
527impl FetchResponse {
528 pub fn status(&self) -> StatusCode {
529 match &self.inner {
530 #[cfg(not(target_arch = "wasm32"))]
531 ResponseInner::Reqwest(response, _) => response.status(),
532 ResponseInner::Transport { status, .. } => *status,
533 }
534 }
535
536 pub fn version(&self) -> hyper::Version {
538 match &self.inner {
539 #[cfg(not(target_arch = "wasm32"))]
540 ResponseInner::Reqwest(response, _) => response.version(),
541 ResponseInner::Transport { version, .. } => *version,
542 }
543 }
544
545 pub fn headers(&self) -> &HeaderMap {
546 match &self.inner {
547 #[cfg(not(target_arch = "wasm32"))]
548 ResponseInner::Reqwest(response, _) => response.headers(),
549 ResponseInner::Transport { headers, .. } => headers,
550 }
551 }
552
553 pub fn content_length(&self) -> Option<u64> {
555 match &self.inner {
556 #[cfg(not(target_arch = "wasm32"))]
557 ResponseInner::Reqwest(response, _) => response.content_length(),
558 ResponseInner::Transport { body, headers, .. } => {
559 body.as_ref().map(|b| b.len() as u64).or_else(|| {
560 headers
561 .get(hyper::header::CONTENT_LENGTH)
562 .and_then(|v| v.to_str().ok())
563 .and_then(|v| v.parse().ok())
564 })
565 }
566 }
567 }
568
569 pub async fn chunk(&mut self) -> Result<Option<Bytes>> {
571 match &mut self.inner {
572 #[cfg(not(target_arch = "wasm32"))]
573 ResponseInner::Reqwest(response, _) => Ok(response.chunk().await?),
574 ResponseInner::Transport { body, .. } => Ok(body.take().filter(|b| !b.is_empty())),
575 }
576 }
577
578 pub async fn bytes(self) -> Result<Bytes> {
580 match self.inner {
581 #[cfg(not(target_arch = "wasm32"))]
582 ResponseInner::Reqwest(response, limit) => read_response_bytes(response, limit).await,
583 ResponseInner::Transport { body, .. } => Ok(body.unwrap_or_default()),
584 }
585 }
586
587 pub async fn text(self) -> Result<String> {
590 match self.inner {
591 #[cfg(not(target_arch = "wasm32"))]
592 ResponseInner::Reqwest(response, limit) => read_response_text(response, limit).await,
593 ResponseInner::Transport { body, .. } => {
594 Ok(String::from_utf8_lossy(&body.unwrap_or_default()).into_owned())
595 }
596 }
597 }
598
599 pub async fn json<T: DeserializeOwned>(self) -> Result<T> {
601 match self.inner {
602 #[cfg(not(target_arch = "wasm32"))]
603 ResponseInner::Reqwest(response, limit) => read_response_json(response, limit).await,
604 ResponseInner::Transport { body, .. } => {
605 serde_json::from_slice(&body.unwrap_or_default())
606 .context("decode the response body as JSON")
607 }
608 }
609 }
610}
611
612#[derive(Debug)]
615pub struct FetchError(pub anyhow::Error);
616
617impl std::fmt::Display for FetchError {
618 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619 write!(f, "{:#}", self.0)
620 }
621}
622
623impl std::error::Error for FetchError {}
624
625pub async fn round_trip_parts(
630 client: &FetchClient,
631 method: &str,
632 url: &str,
633 headers: Vec<(String, Vec<u8>)>,
634 body: Vec<u8>,
635) -> std::result::Result<(u16, Vec<(String, Vec<u8>)>, Bytes), FetchError> {
636 let method: Method = method
637 .parse()
638 .map_err(|e| FetchError(anyhow!("invalid HTTP method {method:?}: {e}")))?;
639 let mut request = client.request(method, url);
640 for (name, value) in &headers {
641 request = request.header(name, String::from_utf8_lossy(value));
642 }
643 if !body.is_empty() {
644 request = request.body(body);
645 }
646 let response = request.send().await.map_err(FetchError)?;
647 let status = response.status().as_u16();
648 let headers = response
649 .headers()
650 .iter()
651 .map(|(name, value)| (name.as_str().to_string(), value.as_bytes().to_vec()))
652 .collect();
653 let body = response.bytes().await.map_err(FetchError)?;
654 Ok((status, headers, body))
655}
656
657#[cfg(not(target_arch = "wasm32"))]
660pub async fn read_response_bytes(mut response: reqwest::Response, limit: usize) -> Result<Bytes> {
661 if response
662 .content_length()
663 .is_some_and(|length| length > limit as u64)
664 {
665 anyhow::bail!("HTTP response body exceeds the {limit}-byte cap");
666 }
667 tokio::time::timeout(transport::REQUEST_TIMEOUT, async move {
668 let mut body = bytes::BytesMut::new();
669 while let Some(chunk) = response.chunk().await? {
670 if chunk.len() > limit.saturating_sub(body.len()) {
671 anyhow::bail!("HTTP response body exceeds the {limit}-byte cap");
672 }
673 body.extend_from_slice(&chunk);
674 }
675 Ok(body.freeze())
676 })
677 .await
678 .context("HTTP response body deadline exceeded")?
679}
680
681#[cfg(not(target_arch = "wasm32"))]
683pub async fn read_response_text(response: reqwest::Response, limit: usize) -> Result<String> {
684 let headers = response.headers().clone();
685 let status = response.status();
686 let body = read_response_bytes(response, limit).await?;
687 let mut buffered = hyper::Response::new(body);
688 *buffered.headers_mut() = headers;
689 *buffered.status_mut() = status;
690 Ok(reqwest::Response::from(buffered).text().await?)
691}
692
693#[cfg(not(target_arch = "wasm32"))]
695pub async fn read_response_json<T: DeserializeOwned>(
696 response: reqwest::Response,
697 limit: usize,
698) -> Result<T> {
699 serde_json::from_slice(&read_response_bytes(response, limit).await?)
700 .context("decode the response body as JSON")
701}