1use cranpose_core::{compositionLocalOfWithPolicy, CompositionLocal};
2#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
3use futures_util::{stream, StreamExt};
4use std::future::Future;
5#[cfg(not(target_arch = "wasm32"))]
6use std::path::Path;
7use std::pin::Pin;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11#[derive(thiserror::Error, Debug, Clone)]
12pub enum HttpError {
13 #[error("Failed to build HTTP client: {0}")]
14 ClientInit(String),
15 #[error("Request failed for {url}: {message}")]
16 RequestFailed { url: String, message: String },
17 #[error("Request failed with status {status} for {url}")]
18 HttpStatus { url: String, status: u16 },
19 #[error("Failed to read response body for {url}: {message}")]
20 BodyReadFailed { url: String, message: String },
21 #[error("Invalid response for {url}: {message}")]
22 InvalidResponse { url: String, message: String },
23 #[error("No window object available")]
24 NoWindow,
25 #[error("{operation} worker thread panicked")]
26 WorkerPanicked { operation: &'static str },
27 #[error("{operation} requires cranpose-services feature `{feature}`")]
28 UnsupportedFeature {
29 operation: &'static str,
30 feature: &'static str,
31 },
32 #[error("Download was cancelled")]
33 Cancelled,
34 #[error("Failed to write downloaded file {path}: {message}")]
35 FileWrite { path: String, message: String },
36}
37
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub struct HttpProgress {
41 pub transferred: u64,
43 pub total: Option<u64>,
46}
47
48impl HttpProgress {
49 pub fn fraction(&self) -> Option<f32> {
52 let total = self.total?;
53 if total == 0 {
54 return None;
55 }
56 Some((self.transferred as f32 / total as f32).clamp(0.0, 1.0))
57 }
58
59 pub fn is_complete(&self) -> bool {
61 self.total.is_some_and(|total| self.transferred >= total)
62 }
63}
64
65pub type ProgressHandler = Arc<dyn Fn(HttpProgress) + Send + Sync>;
67
68#[derive(Clone, Default)]
74pub struct HttpControl {
75 cancelled: Arc<AtomicBool>,
76 progress: Option<ProgressHandler>,
77}
78
79impl HttpControl {
80 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn with_progress(
91 mut self,
92 progress: impl Fn(HttpProgress) + Send + Sync + 'static,
93 ) -> Self {
94 self.progress = Some(Arc::new(progress));
95 self
96 }
97
98 pub fn cancel(&self) {
104 self.cancelled.store(true, Ordering::Release);
105 }
106
107 pub fn is_cancelled(&self) -> bool {
109 self.cancelled.load(Ordering::Acquire)
110 }
111
112 pub fn report(&self, progress: HttpProgress) {
120 if let Some(handler) = &self.progress {
121 handler(progress);
122 }
123 }
124}
125
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
128pub enum HttpMethod {
129 #[default]
130 Get,
131 Head,
132 Post,
133 Put,
134 Delete,
135}
136
137impl HttpMethod {
138 pub fn name(self) -> &'static str {
140 match self {
141 HttpMethod::Get => "GET",
142 HttpMethod::Head => "HEAD",
143 HttpMethod::Post => "POST",
144 HttpMethod::Put => "PUT",
145 HttpMethod::Delete => "DELETE",
146 }
147 }
148}
149
150#[derive(Clone, Debug, Default, PartialEq, Eq)]
152pub struct HttpRequest {
153 pub url: String,
154 pub method: HttpMethod,
155 pub headers: Vec<(String, String)>,
156 pub body: Option<Vec<u8>>,
157 pub resume_from: Option<u64>,
164}
165
166impl HttpRequest {
167 pub fn get(url: impl Into<String>) -> Self {
169 Self {
170 url: url.into(),
171 ..Self::default()
172 }
173 }
174
175 pub fn method(mut self, method: HttpMethod) -> Self {
176 self.method = method;
177 self
178 }
179
180 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
181 self.headers.push((name.into(), value.into()));
182 self
183 }
184
185 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
186 self.body = Some(body.into());
187 self
188 }
189
190 pub fn resume_from(mut self, offset: u64) -> Self {
192 self.resume_from = (offset > 0).then_some(offset);
193 self
194 }
195}
196
197#[cfg(not(target_arch = "wasm32"))]
198pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + Send + 'a>>;
199
200#[cfg(target_arch = "wasm32")]
201pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + 'a>>;
202
203pub trait HttpBody {
210 fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>>;
212}
213
214#[cfg(not(target_arch = "wasm32"))]
221pub type HttpBodyRef = Arc<dyn HttpBody + Send + Sync>;
222
223#[cfg(target_arch = "wasm32")]
229pub type HttpBodyRef = std::rc::Rc<dyn HttpBody>;
230
231#[cfg(not(target_arch = "wasm32"))]
238pub fn http_body_ref<B: HttpBody + Send + Sync + 'static>(body: B) -> HttpBodyRef {
239 Arc::new(body)
240}
241
242#[cfg(target_arch = "wasm32")]
244pub fn http_body_ref<B: HttpBody + 'static>(body: B) -> HttpBodyRef {
245 std::rc::Rc::new(body)
246}
247
248struct EmptyBody;
250
251impl HttpBody for EmptyBody {
252 fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
253 Box::pin(async { Ok(None) })
254 }
255}
256
257pub struct BytesBody {
263 bytes: Vec<u8>,
264 offset: std::sync::Mutex<usize>,
265 chunk: usize,
266}
267
268impl BytesBody {
269 pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
271 Self::chunked(bytes, usize::MAX)
272 }
273
274 pub fn chunked(bytes: impl Into<Vec<u8>>, chunk: usize) -> Self {
276 Self {
277 bytes: bytes.into(),
278 offset: std::sync::Mutex::new(0),
279 chunk: chunk.max(1),
280 }
281 }
282
283 pub fn len(&self) -> u64 {
285 self.bytes.len() as u64
286 }
287
288 pub fn is_empty(&self) -> bool {
290 self.bytes.is_empty()
291 }
292}
293
294impl HttpBody for BytesBody {
295 fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
296 Box::pin(async move {
297 let mut offset = self
298 .offset
299 .lock()
300 .unwrap_or_else(|error| error.into_inner());
301 if *offset >= self.bytes.len() {
302 return Ok(None);
303 }
304 let end = offset.saturating_add(self.chunk).min(self.bytes.len());
305 let chunk = self.bytes[*offset..end].to_vec();
306 *offset = end;
307 Ok(Some(chunk))
308 })
309 }
310}
311
312pub struct HttpResponse {
314 pub status: u16,
316 pub headers: Vec<(String, String)>,
318 pub content_length: Option<u64>,
320 pub resumed: bool,
325 pub url: String,
327 body: HttpBodyRef,
328}
329
330impl HttpResponse {
331 pub fn new(url: impl Into<String>, status: u16, body: HttpBodyRef) -> Self {
333 Self {
334 status,
335 headers: Vec::new(),
336 content_length: None,
337 resumed: false,
338 url: url.into(),
339 body,
340 }
341 }
342
343 pub fn empty(url: impl Into<String>, status: u16) -> Self {
345 Self::new(url, status, http_body_ref(EmptyBody))
346 }
347
348 pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
349 self.headers = headers;
350 self
351 }
352
353 pub fn with_content_length(mut self, content_length: Option<u64>) -> Self {
354 self.content_length = content_length;
355 self
356 }
357
358 pub fn with_resumed(mut self, resumed: bool) -> Self {
359 self.resumed = resumed;
360 self
361 }
362
363 pub fn is_success(&self) -> bool {
365 (200..300).contains(&self.status)
366 }
367
368 pub fn header(&self, name: &str) -> Option<&str> {
370 let name = name.to_ascii_lowercase();
371 self.headers
372 .iter()
373 .find(|(key, _)| key.eq_ignore_ascii_case(&name))
374 .map(|(_, value)| value.as_str())
375 }
376
377 pub fn error_for_status(self) -> Result<Self, HttpError> {
379 if self.is_success() {
380 Ok(self)
381 } else {
382 Err(HttpError::HttpStatus {
383 url: self.url.clone(),
384 status: self.status,
385 })
386 }
387 }
388
389 pub async fn read_chunk(&self) -> Result<Option<Vec<u8>>, HttpError> {
391 self.body.read_chunk().await
392 }
393
394 pub async fn read_all(&self) -> Result<Vec<u8>, HttpError> {
399 let mut out = Vec::with_capacity(self.content_length.unwrap_or(0).min(1 << 20) as usize);
400 while let Some(chunk) = self.read_chunk().await? {
401 out.extend_from_slice(&chunk);
402 }
403 Ok(out)
404 }
405
406 pub async fn read_text(&self) -> Result<String, HttpError> {
408 String::from_utf8(self.read_all().await?).map_err(|error| HttpError::InvalidResponse {
409 url: self.url.clone(),
410 message: error.to_string(),
411 })
412 }
413}
414
415pub trait HttpClient: Send + Sync {
422 fn send<'a>(
425 &'a self,
426 request: &'a HttpRequest,
427 control: HttpControl,
428 ) -> HttpFuture<'a, HttpResponse>;
429
430 fn get_text<'a>(&'a self, url: &'a str) -> HttpFuture<'a, String> {
432 Box::pin(async move {
433 self.send(&HttpRequest::get(url), HttpControl::new())
434 .await?
435 .error_for_status()?
436 .read_text()
437 .await
438 })
439 }
440
441 fn get_bytes<'a>(&'a self, url: &'a str) -> HttpFuture<'a, Vec<u8>> {
443 Box::pin(async move {
444 self.send(&HttpRequest::get(url), HttpControl::new())
445 .await?
446 .error_for_status()?
447 .read_all()
448 .await
449 })
450 }
451
452 #[cfg(not(target_arch = "wasm32"))]
460 fn download_to<'a>(
461 &'a self,
462 url: &'a str,
463 target: &'a Path,
464 control: HttpControl,
465 ) -> HttpFuture<'a, u64> {
466 Box::pin(async move { download_through(self, url, target, control).await })
467 }
468}
469
470#[cfg(not(target_arch = "wasm32"))]
477async fn download_through<C: HttpClient + ?Sized>(
478 client: &C,
479 url: &str,
480 target: &Path,
481 control: HttpControl,
482) -> Result<u64, HttpError> {
483 use std::io::Write;
484
485 if control.is_cancelled() {
486 return Err(HttpError::Cancelled);
487 }
488 let existing = std::fs::metadata(target)
489 .map(|metadata| metadata.len())
490 .unwrap_or(0);
491 let mut request = HttpRequest::get(url);
492 if existing > 0 {
493 request = request.resume_from(existing);
494 }
495 let response = client
496 .send(&request, control.clone())
497 .await?
498 .error_for_status()?;
499
500 let base = if response.resumed { existing } else { 0 };
501 let total = response.content_length.map(|remaining| base + remaining);
502 let mut output = if response.resumed {
503 std::fs::OpenOptions::new().append(true).open(target)
504 } else {
505 std::fs::File::create(target)
506 }
507 .map_err(|error| HttpError::FileWrite {
508 path: target.display().to_string(),
509 message: error.to_string(),
510 })?;
511
512 let mut transferred = base;
513 control.report(HttpProgress { transferred, total });
514 while let Some(chunk) = response.read_chunk().await? {
515 if control.is_cancelled() {
516 return Err(HttpError::Cancelled);
517 }
518 output
519 .write_all(&chunk)
520 .map_err(|error| HttpError::FileWrite {
521 path: target.display().to_string(),
522 message: error.to_string(),
523 })?;
524 transferred += chunk.len() as u64;
525 control.report(HttpProgress { transferred, total });
526 }
527 output.flush().map_err(|error| HttpError::FileWrite {
528 path: target.display().to_string(),
529 message: error.to_string(),
530 })?;
531 Ok(transferred)
532}
533
534pub type HttpClientRef = Arc<dyn HttpClient>;
535
536#[cfg(not(target_arch = "wasm32"))]
537pub async fn map_ordered_concurrent<I, T, F, Fut>(
538 items: &[I],
539 concurrency: usize,
540 task: F,
541) -> Result<Vec<T>, HttpError>
542where
543 I: Clone + Send,
544 T: Send,
545 F: Fn(I) -> Fut + Send + Sync + 'static,
546 Fut: Future<Output = T> + Send,
547{
548 let task = Arc::new(task);
549 let mut results = Vec::with_capacity(items.len());
550
551 for chunk in items.chunks(concurrency.max(1)) {
552 let chunk_results = std::thread::scope(|scope| {
553 let mut handles = Vec::with_capacity(chunk.len());
554 for item in chunk.iter().cloned() {
555 let task = Arc::clone(&task);
556 handles.push(scope.spawn(move || pollster::block_on(task(item))));
557 }
558
559 let mut chunk_results = Vec::with_capacity(handles.len());
560 for handle in handles {
561 let value = handle.join().map_err(|_| HttpError::WorkerPanicked {
562 operation: "ordered concurrent task",
563 })?;
564 chunk_results.push(value);
565 }
566 Ok::<Vec<T>, HttpError>(chunk_results)
567 })?;
568 results.extend(chunk_results);
569 }
570
571 Ok(results)
572}
573
574#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
575pub async fn map_ordered_concurrent<I, T, F, Fut>(
576 items: &[I],
577 concurrency: usize,
578 task: F,
579) -> Result<Vec<T>, HttpError>
580where
581 I: Clone,
582 F: Fn(I) -> Fut + Clone,
583 Fut: Future<Output = T>,
584{
585 let mut results = stream::iter(items.iter().cloned().enumerate().map(|(index, item)| {
586 let task = task.clone();
587 async move { (index, task(item).await) }
588 }))
589 .buffer_unordered(concurrency.max(1))
590 .collect::<Vec<_>>()
591 .await;
592
593 results.sort_by_key(|(index, _)| *index);
594 Ok(results.into_iter().map(|(_, value)| value).collect())
595}
596
597#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
598pub async fn map_ordered_concurrent<I, T, F, Fut>(
599 items: &[I],
600 _concurrency: usize,
601 task: F,
602) -> Result<Vec<T>, HttpError>
603where
604 I: Clone,
605 F: Fn(I) -> Fut,
606 Fut: Future<Output = T>,
607{
608 Ok(map_ordered_sequential(items, task).await)
609}
610
611#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
612async fn map_ordered_sequential<I, T, F, Fut>(items: &[I], task: F) -> Vec<T>
613where
614 I: Clone,
615 F: Fn(I) -> Fut,
616 Fut: Future<Output = T>,
617{
618 let mut results = Vec::with_capacity(items.len());
619 for item in items.iter().cloned() {
620 results.push(task(item).await);
621 }
622 results
623}
624
625pub type StubAnswer = dyn Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync;
627
628pub struct StubHttpClient {
642 answer: Box<StubAnswer>,
643}
644
645impl StubHttpClient {
646 pub fn new(
648 answer: impl Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync + 'static,
649 ) -> Self {
650 Self {
651 answer: Box::new(answer),
652 }
653 }
654
655 pub fn with_body(body: impl Into<Vec<u8>>) -> Self {
657 let body = body.into();
658 Self::new(move |request| {
659 Ok(HttpResponse::new(
660 request.url.clone(),
661 200,
662 http_body_ref(BytesBody::new(body.clone())),
663 ))
664 })
665 }
666
667 pub fn from_text(
669 answer: impl Fn(&str) -> Result<String, HttpError> + Send + Sync + 'static,
670 ) -> Self {
671 Self::new(move |request| {
672 let body = answer(&request.url)?;
673 Ok(HttpResponse::new(
674 request.url.clone(),
675 200,
676 http_body_ref(BytesBody::new(body)),
677 ))
678 })
679 }
680}
681
682impl HttpClient for StubHttpClient {
683 fn send<'a>(
684 &'a self,
685 request: &'a HttpRequest,
686 _control: HttpControl,
687 ) -> HttpFuture<'a, HttpResponse> {
688 Box::pin(async move { (self.answer)(request) })
689 }
690}
691
692#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
698const CHUNK_LEN: usize = 64 * 1024;
699
700struct DefaultHttpClient {
701 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
702 native_client: Result<reqwest::blocking::Client, HttpError>,
703}
704
705impl DefaultHttpClient {
706 fn new() -> Self {
707 Self {
708 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
709 native_client: build_native_client(),
710 }
711 }
712}
713
714#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
717struct ResponseHead {
718 status: u16,
719 headers: Vec<(String, String)>,
720 content_length: Option<u64>,
721 resumed: bool,
722}
723
724#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
726struct ChannelBody {
727 chunks: crate::async_io::ChunkStream<HttpError>,
728}
729
730#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
731impl HttpBody for ChannelBody {
732 fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
733 Box::pin(self.chunks.next())
734 }
735}
736
737#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
746async fn send_native(
747 client: reqwest::blocking::Client,
748 request: HttpRequest,
749 control: HttpControl,
750) -> Result<HttpResponse, HttpError> {
751 use crate::async_io::{ChunkChannel, Signal};
752
753 let head_signal: Signal<Result<ResponseHead, HttpError>> = Signal::new();
754 let (chunks, stream) = ChunkChannel::<HttpError>::new();
755 let worker_head = head_signal.clone();
756 let url = request.url.clone();
757 let worker_url = url.clone();
758
759 std::thread::Builder::new()
760 .name("cranpose-http".to_string())
761 .spawn(move || {
762 let outcome = read_native_response(&client, &request, &control, &chunks, &worker_head);
763 match outcome {
764 Ok(()) => chunks.finish(),
765 Err(error) => {
766 worker_head.set(Err(error.clone()));
770 chunks.fail(error);
771 }
772 }
773 let _ = worker_url;
774 })
775 .map_err(|error| HttpError::RequestFailed {
776 url: url.clone(),
777 message: format!("could not start the transfer: {error}"),
778 })?;
779
780 let head = head_signal
784 .wait()
785 .await
786 .ok_or_else(|| HttpError::RequestFailed {
787 url: url.clone(),
788 message: "the transfer ended before a response arrived".to_string(),
789 })??;
790
791 Ok(HttpResponse::new(
792 url,
793 head.status,
794 http_body_ref(ChannelBody { chunks: stream }),
795 )
796 .with_headers(head.headers)
797 .with_content_length(head.content_length)
798 .with_resumed(head.resumed))
799}
800
801#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
804fn read_native_response(
805 client: &reqwest::blocking::Client,
806 request: &HttpRequest,
807 control: &HttpControl,
808 chunks: &crate::async_io::ChunkChannel<HttpError>,
809 head_signal: &crate::async_io::Signal<Result<ResponseHead, HttpError>>,
810) -> Result<(), HttpError> {
811 use std::io::Read;
812
813 if control.is_cancelled() {
814 return Err(HttpError::Cancelled);
815 }
816 let method = match request.method {
817 HttpMethod::Get => reqwest::Method::GET,
818 HttpMethod::Head => reqwest::Method::HEAD,
819 HttpMethod::Post => reqwest::Method::POST,
820 HttpMethod::Put => reqwest::Method::PUT,
821 HttpMethod::Delete => reqwest::Method::DELETE,
822 };
823 let mut builder = client.request(method, &request.url);
824 for (name, value) in &request.headers {
825 builder = builder.header(name, value);
826 }
827 if let Some(offset) = request.resume_from {
828 builder = builder.header(reqwest::header::RANGE, format!("bytes={offset}-"));
829 }
830 if let Some(body) = &request.body {
831 builder = builder.body(body.clone());
832 }
833
834 let mut response = builder.send().map_err(|error| HttpError::RequestFailed {
835 url: request.url.clone(),
836 message: error.to_string(),
837 })?;
838
839 let status = response.status();
840 let resumed = request.resume_from.is_some() && status == reqwest::StatusCode::PARTIAL_CONTENT;
841 let content_length = response.content_length();
842 let headers = response
843 .headers()
844 .iter()
845 .map(|(name, value)| {
846 (
847 name.as_str().to_ascii_lowercase(),
848 value.to_str().unwrap_or_default().to_string(),
849 )
850 })
851 .collect();
852 head_signal.set(Ok(ResponseHead {
853 status: status.as_u16(),
854 headers,
855 content_length,
856 resumed,
857 }));
858
859 let mut buffer = vec![0u8; CHUNK_LEN];
860 let mut transferred = 0u64;
861 loop {
862 if control.is_cancelled() {
863 return Err(HttpError::Cancelled);
864 }
865 let count = response
866 .read(&mut buffer)
867 .map_err(|error| HttpError::BodyReadFailed {
868 url: request.url.clone(),
869 message: error.to_string(),
870 })?;
871 if count == 0 {
872 break;
873 }
874 transferred += count as u64;
875 control.report(HttpProgress {
876 transferred,
877 total: content_length,
878 });
879 if !chunks.push(buffer[..count].to_vec()) {
881 break;
882 }
883 }
884 Ok(())
885}
886
887#[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
888async fn send_native(
889 _request: HttpRequest,
890 _control: HttpControl,
891) -> Result<HttpResponse, HttpError> {
892 Err(HttpError::UnsupportedFeature {
893 operation: "native HTTP requests",
894 feature: "http-native",
895 })
896}
897
898impl HttpClient for DefaultHttpClient {
899 fn send<'a>(
900 &'a self,
901 request: &'a HttpRequest,
902 control: HttpControl,
903 ) -> HttpFuture<'a, HttpResponse> {
904 Box::pin(async move {
905 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
906 {
907 let client = self.native_client.as_ref().map_err(Clone::clone)?.clone();
908 send_native(client, request.clone(), control).await
909 }
910 #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
911 {
912 send_native(request.clone(), control).await
913 }
914 #[cfg(target_arch = "wasm32")]
915 {
916 send_web(request, control).await
917 }
918 })
919 }
920}
921
922#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
923fn build_native_client() -> Result<reqwest::blocking::Client, HttpError> {
924 use std::time::Duration;
925
926 configure_native_client_builder(
927 reqwest::blocking::Client::builder()
928 .connect_timeout(Duration::from_secs(30))
929 .timeout(None)
930 .user_agent(concat!("cranpose/", env!("CARGO_PKG_VERSION"))),
935 )?
936 .build()
937 .map_err(|err| HttpError::ClientInit(err.to_string()))
938}
939
940#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
941fn configure_native_client_builder(
942 builder: reqwest::blocking::ClientBuilder,
943) -> Result<reqwest::blocking::ClientBuilder, HttpError> {
944 #[cfg(target_os = "android")]
945 {
946 return Ok(builder.tls_certs_only(android_root_certificates()?));
947 }
948
949 #[cfg(not(target_os = "android"))]
950 {
951 Ok(builder)
952 }
953}
954
955#[cfg(all(target_os = "android", feature = "http-native"))]
956fn android_root_certificates() -> Result<Vec<reqwest::Certificate>, HttpError> {
957 certificates_from_der_chain(
958 webpki_root_certs::TLS_SERVER_ROOT_CERTS
959 .iter()
960 .map(|certificate| certificate.as_ref()),
961 )
962}
963
964#[cfg(any(
965 all(test, not(target_arch = "wasm32"), feature = "http-native"),
966 all(target_os = "android", feature = "http-native")
967))]
968fn certificates_from_der_chain<'a, I>(
969 certificates: I,
970) -> Result<Vec<reqwest::Certificate>, HttpError>
971where
972 I: IntoIterator<Item = &'a [u8]>,
973{
974 certificates
975 .into_iter()
976 .enumerate()
977 .map(|(index, der)| {
978 reqwest::Certificate::from_der(der).map_err(|err| {
979 HttpError::ClientInit(format!(
980 "Failed to load TLS root certificate {index}: {err}"
981 ))
982 })
983 })
984 .collect()
985}
986
987#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
993struct FetchBody {
994 url: String,
995 reader: web_sys::ReadableStreamDefaultReader,
996 control: HttpControl,
997 transferred: std::cell::Cell<u64>,
998 total: Option<u64>,
999}
1000
1001#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1002impl HttpBody for FetchBody {
1003 fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
1004 use wasm_bindgen_futures::JsFuture;
1005
1006 Box::pin(async move {
1007 if self.control.is_cancelled() {
1008 let _ = self.reader.cancel();
1009 return Err(HttpError::Cancelled);
1010 }
1011 let result = JsFuture::from(self.reader.read()).await.map_err(|error| {
1012 HttpError::BodyReadFailed {
1013 url: self.url.clone(),
1014 message: format!("{error:?}"),
1015 }
1016 })?;
1017 let done = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("done"))
1018 .ok()
1019 .and_then(|value| value.as_bool())
1020 .unwrap_or(true);
1021 if done {
1022 return Ok(None);
1023 }
1024 let value = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("value"))
1025 .map_err(|error| HttpError::BodyReadFailed {
1026 url: self.url.clone(),
1027 message: format!("{error:?}"),
1028 })?;
1029 let chunk = js_sys::Uint8Array::new(&value).to_vec();
1030 self.transferred
1031 .set(self.transferred.get() + chunk.len() as u64);
1032 self.control.report(HttpProgress {
1033 transferred: self.transferred.get(),
1034 total: self.total,
1035 });
1036 Ok(Some(chunk))
1037 })
1038 }
1039}
1040
1041#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1042async fn send_web(request: &HttpRequest, control: HttpControl) -> Result<HttpResponse, HttpError> {
1043 use wasm_bindgen::JsCast;
1044 use wasm_bindgen_futures::JsFuture;
1045 use web_sys::{Request, RequestInit, RequestMode, Response};
1046
1047 if control.is_cancelled() {
1048 return Err(HttpError::Cancelled);
1049 }
1050 let options = RequestInit::new();
1051 options.set_method(request.method.name());
1052 options.set_mode(RequestMode::Cors);
1053 if let Some(body) = &request.body {
1054 options.set_body(&js_sys::Uint8Array::from(body.as_slice()).into());
1055 }
1056
1057 let fetch_request =
1058 Request::new_with_str_and_init(&request.url, &options).map_err(|error| {
1059 HttpError::RequestFailed {
1060 url: request.url.clone(),
1061 message: format!("{error:?}"),
1062 }
1063 })?;
1064 let headers = fetch_request.headers();
1065 for (name, value) in &request.headers {
1066 headers
1067 .set(name, value)
1068 .map_err(|error| HttpError::RequestFailed {
1069 url: request.url.clone(),
1070 message: format!("{error:?}"),
1071 })?;
1072 }
1073 if let Some(offset) = request.resume_from {
1074 headers
1075 .set("Range", &format!("bytes={offset}-"))
1076 .map_err(|error| HttpError::RequestFailed {
1077 url: request.url.clone(),
1078 message: format!("{error:?}"),
1079 })?;
1080 }
1081
1082 let window = web_sys::window().ok_or(HttpError::NoWindow)?;
1083 let value = JsFuture::from(window.fetch_with_request(&fetch_request))
1084 .await
1085 .map_err(|error| HttpError::RequestFailed {
1086 url: request.url.clone(),
1087 message: format!("{error:?}"),
1088 })?;
1089 let response: Response = value.dyn_into().map_err(|_| HttpError::InvalidResponse {
1090 url: request.url.clone(),
1091 message: "the browser answered with something that is not a Response".to_string(),
1092 })?;
1093
1094 let status = response.status();
1095 let mut header_pairs = Vec::new();
1096 let entries = js_sys::try_iter(&response.headers()).ok().flatten();
1097 if let Some(entries) = entries {
1098 for entry in entries.flatten() {
1099 let pair = js_sys::Array::from(&entry);
1100 if pair.length() >= 2 {
1101 let name = pair.get(0).as_string().unwrap_or_default();
1102 let value = pair.get(1).as_string().unwrap_or_default();
1103 header_pairs.push((name.to_ascii_lowercase(), value));
1104 }
1105 }
1106 }
1107 let total = header_pairs
1108 .iter()
1109 .find(|(name, _)| name == "content-length")
1110 .and_then(|(_, value)| value.parse::<u64>().ok());
1111 let resumed = request.resume_from.is_some() && status == 206;
1112
1113 let body = response.body().ok_or_else(|| HttpError::InvalidResponse {
1114 url: request.url.clone(),
1115 message: "the response carries no body".to_string(),
1116 })?;
1117 let reader: web_sys::ReadableStreamDefaultReader =
1118 body.get_reader()
1119 .dyn_into()
1120 .map_err(|_| HttpError::InvalidResponse {
1121 url: request.url.clone(),
1122 message: "the response body cannot be read in chunks".to_string(),
1123 })?;
1124
1125 Ok(HttpResponse::new(
1126 request.url.clone(),
1127 status,
1128 http_body_ref(FetchBody {
1129 url: request.url.clone(),
1130 reader,
1131 control,
1132 transferred: std::cell::Cell::new(0),
1133 total,
1134 }),
1135 )
1136 .with_headers(header_pairs)
1137 .with_content_length(total)
1138 .with_resumed(resumed))
1139}
1140
1141#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
1142async fn send_web(request: &HttpRequest, _control: HttpControl) -> Result<HttpResponse, HttpError> {
1143 let _ = request;
1144 Err(HttpError::UnsupportedFeature {
1145 operation: "web HTTP requests",
1146 feature: "web-http",
1147 })
1148}
1149
1150pub fn default_http_client() -> HttpClientRef {
1151 Arc::new(DefaultHttpClient::new())
1152}
1153
1154pub fn local_http_client() -> CompositionLocal<HttpClientRef> {
1155 thread_local! {
1156 static LOCAL_HTTP_CLIENT: std::cell::RefCell<Option<CompositionLocal<HttpClientRef>>> = const { std::cell::RefCell::new(None) };
1157 }
1158
1159 LOCAL_HTTP_CLIENT.with(|cell| {
1160 let mut local = cell.borrow_mut();
1161 local
1162 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_http_client, Arc::ptr_eq))
1163 .clone()
1164 })
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use super::*;
1170 use crate::run_test_composition;
1171 use cranpose_core::CompositionLocalProvider;
1172 use std::cell::RefCell;
1173 use std::rc::Rc;
1174 #[cfg(not(target_arch = "wasm32"))]
1175 use std::sync::atomic::{AtomicU64, Ordering};
1176
1177 #[test]
1178 fn a_response_carries_what_a_resumed_download_needs_to_know() {
1179 let response = HttpResponse::empty("https://host/big.bin", 206)
1183 .with_content_length(Some(4_096))
1184 .with_resumed(true);
1185 assert_eq!(response.content_length, Some(4_096));
1186 assert!(response.resumed);
1187 assert!(response.is_success());
1190
1191 let restarted = HttpResponse::empty("https://host/big.bin", 200)
1194 .with_content_length(None)
1195 .with_resumed(false);
1196 assert_eq!(restarted.content_length, None);
1197 assert!(!restarted.resumed);
1198 assert!(restarted.is_success());
1199 }
1200
1201 #[test]
1202 fn the_stub_client_answers_every_url_with_the_same_body() {
1203 let client = StubHttpClient::with_body(b"pong".to_vec());
1206 for url in ["https://host/a", "https://host/b?query=1"] {
1207 let request = HttpRequest::get(url);
1208 let response = pollster::block_on(client.send(&request, HttpControl::new()))
1209 .expect("the stub answers every request");
1210 assert_eq!(response.status, 200);
1211 assert!(response.is_success());
1212 assert_eq!(response.url, url);
1213 let body = pollster::block_on(response.read_all()).expect("body");
1214 assert_eq!(body.as_slice(), b"pong");
1215 }
1216 }
1217
1218 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1219 use std::thread;
1220
1221 struct TestHttpClient;
1222
1223 impl HttpClient for TestHttpClient {
1224 fn send<'a>(
1225 &'a self,
1226 request: &'a HttpRequest,
1227 _control: HttpControl,
1228 ) -> HttpFuture<'a, HttpResponse> {
1229 Box::pin(async move {
1230 Ok(HttpResponse::new(
1231 request.url.clone(),
1232 200,
1233 Arc::new(BytesBody::new("ok")),
1234 ))
1235 })
1236 }
1237 }
1238
1239 #[test]
1240 fn a_control_shares_cancellation_and_reports_progress() {
1241 let reported = Arc::new(AtomicU64::new(0));
1242 let recorder = Arc::clone(&reported);
1243 let control = HttpControl::new().with_progress(move |progress| {
1244 assert_eq!(progress.total, Some(20));
1245 recorder.store(progress.transferred, Ordering::Release);
1246 });
1247 let clone = control.clone();
1248 control.report(HttpProgress {
1249 transferred: 12,
1250 total: Some(20),
1251 });
1252 assert_eq!(reported.load(Ordering::Acquire), 12);
1253 assert!(!clone.is_cancelled());
1254 control.cancel();
1255 assert!(
1256 clone.is_cancelled(),
1257 "a control handed to a transfer must see the cancellation the caller made"
1258 );
1259 }
1260
1261 #[test]
1262 fn progress_reports_a_fraction_only_when_the_server_said_how_large_it_is() {
1263 assert_eq!(
1264 HttpProgress {
1265 transferred: 5,
1266 total: Some(20)
1267 }
1268 .fraction(),
1269 Some(0.25)
1270 );
1271 assert_eq!(
1272 HttpProgress {
1273 transferred: 5,
1274 total: None
1275 }
1276 .fraction(),
1277 None,
1278 "a chunked response has no total, and inventing one is lying about it"
1279 );
1280 assert_eq!(
1281 HttpProgress {
1282 transferred: 5,
1283 total: Some(0)
1284 }
1285 .fraction(),
1286 None
1287 );
1288 assert!(HttpProgress {
1289 transferred: 20,
1290 total: Some(20)
1291 }
1292 .is_complete());
1293 }
1294
1295 #[test]
1296 fn a_request_carries_what_it_asks_for() {
1297 let request = HttpRequest::get("https://example.test/thing")
1298 .method(HttpMethod::Post)
1299 .header("Accept", "application/json")
1300 .body(b"payload".to_vec())
1301 .resume_from(4096);
1302 assert_eq!(request.method.name(), "POST");
1303 assert_eq!(
1304 request.headers,
1305 vec![("Accept".to_string(), "application/json".to_string())]
1306 );
1307 assert_eq!(request.body.as_deref(), Some(b"payload".as_slice()));
1308 assert_eq!(request.resume_from, Some(4096));
1309 assert_eq!(
1310 HttpRequest::get("https://example.test/thing")
1311 .resume_from(0)
1312 .resume_from,
1313 None,
1314 "resuming from the beginning is not resuming"
1315 );
1316 }
1317
1318 #[test]
1319 fn a_response_reads_its_headers_without_regard_to_case() {
1320 let response = HttpResponse::empty("https://example.test", 200).with_headers(vec![
1321 ("content-type".to_string(), "text/plain".to_string()),
1322 ("etag".to_string(), "\"abc\"".to_string()),
1323 ]);
1324 assert_eq!(response.header("Content-Type"), Some("text/plain"));
1325 assert_eq!(response.header("ETAG"), Some("\"abc\""));
1326 assert_eq!(response.header("missing"), None);
1327 assert!(response.is_success());
1328 }
1329
1330 #[test]
1331 fn a_failing_status_is_an_error_the_caller_can_stop_on() {
1332 let response = HttpResponse::empty("https://example.test", 404);
1333 assert!(!response.is_success());
1334 let error = response
1335 .error_for_status()
1336 .err()
1337 .expect("404 is not success");
1338 assert!(matches!(error, HttpError::HttpStatus { status: 404, .. }));
1339 }
1340
1341 #[test]
1344 fn a_body_read_in_chunks_reassembles_to_what_was_sent() {
1345 let response = HttpResponse::new(
1346 "https://example.test",
1347 200,
1348 Arc::new(BytesBody::chunked("cranpose streams bodies", 4)),
1349 );
1350 let mut chunks = Vec::new();
1351 while let Some(chunk) = pollster::block_on(response.read_chunk()).expect("a chunk") {
1352 assert!(chunk.len() <= 4, "a chunked body honours its chunk size");
1353 chunks.push(chunk);
1354 }
1355 assert!(chunks.len() > 1, "the body arrived in pieces");
1356 let joined = chunks.concat();
1357 assert_eq!(
1358 String::from_utf8(joined).expect("text"),
1359 "cranpose streams bodies"
1360 );
1361 }
1362
1363 #[test]
1364 fn reading_a_body_as_text_rejects_bytes_that_are_not_text() {
1365 let response = HttpResponse::new(
1366 "https://example.test",
1367 200,
1368 Arc::new(BytesBody::new(vec![0xff, 0xfe])),
1369 );
1370 assert!(matches!(
1371 pollster::block_on(response.read_text()),
1372 Err(HttpError::InvalidResponse { .. })
1373 ));
1374 }
1375
1376 #[test]
1377 fn an_empty_body_ends_immediately() {
1378 let response = HttpResponse::empty("https://example.test", 204);
1379 assert_eq!(
1380 pollster::block_on(response.read_all()).expect("an empty body reads"),
1381 Vec::<u8>::new()
1382 );
1383 }
1384
1385 #[test]
1386 fn default_http_client_is_available() {
1387 let client = default_http_client();
1388 let cloned = client.clone();
1389 assert_eq!(Arc::strong_count(&client), 2);
1390 drop(cloned);
1391 assert_eq!(Arc::strong_count(&client), 1);
1392 }
1393
1394 #[test]
1399 fn the_native_transfer_awaits_its_response_rather_than_blocking_on_it() {
1400 let source = include_str!("http.rs");
1401 let send = source
1402 .split("async fn send_native(")
1403 .nth(1)
1404 .expect("the native send");
1405 let body = send.split("\nasync fn ").next().unwrap_or(send);
1406 assert!(
1407 body.contains("head_signal\n .wait()\n .await"),
1408 "the native send must await the response head"
1409 );
1410 let blocking = ["pollster", "::", "block_on"].concat();
1411 assert!(
1412 !body.contains(&blocking),
1413 "the native send must not block the thread that polled it"
1414 );
1415 }
1416
1417 #[test]
1418 fn default_http_client_has_no_process_global_native_client_cache() {
1419 let source = include_str!("http.rs");
1420 let once_lock = ["Once", "Lock"].concat();
1421 let static_client = ["static ", "CLIENT"].concat();
1422 let native_client_fn = ["fn ", "native_client()"].concat();
1423
1424 assert!(
1425 !source.contains(&static_client)
1426 && !source.contains(&native_client_fn)
1427 && !source.contains(&once_lock),
1428 "native HTTP client state must be owned by DefaultHttpClient instead of a process-global cache"
1429 );
1430 }
1431
1432 #[test]
1436 fn every_convenience_reads_the_body_the_backend_produced() {
1437 let client = TestHttpClient;
1438 assert_eq!(
1439 pollster::block_on(client.get_bytes("https://example.com")).expect("bytes"),
1440 b"ok".to_vec()
1441 );
1442 assert_eq!(
1443 pollster::block_on(client.get_text("https://example.com")).expect("text"),
1444 "ok"
1445 );
1446 }
1447
1448 #[test]
1449 fn map_ordered_concurrent_preserves_input_order() {
1450 let inputs = [3usize, 1, 4, 1, 5];
1451 let outputs = pollster::block_on(map_ordered_concurrent(&inputs, 2, |value| async move {
1452 value * 10
1453 }))
1454 .expect("ordered concurrent mapping");
1455
1456 assert_eq!(outputs, vec![30, 10, 40, 10, 50]);
1457 }
1458
1459 #[test]
1460 fn native_ordered_concurrency_is_not_tied_to_native_http_client_feature() {
1461 let source = include_str!("http.rs");
1462 assert!(
1463 source.contains(
1464 "#[cfg(not(target_arch = \"wasm32\"))]\npub async fn map_ordered_concurrent"
1465 ),
1466 "native ordered concurrency must stay available without the HTTP client feature"
1467 );
1468 assert!(
1469 !source.contains(
1470 "all(not(target_arch = \"wasm32\"), feature = \"http-native\")]\npub async fn map_ordered_concurrent"
1471 ) && !source.contains(
1472 "all(not(target_arch = \"wasm32\"), not(feature = \"http-native\"))]\npub async fn map_ordered_concurrent"
1473 ),
1474 "native ordered concurrency must not split into HTTP-enabled and HTTP-disabled behavior"
1475 );
1476 }
1477
1478 #[cfg(not(target_arch = "wasm32"))]
1479 #[test]
1480 fn map_ordered_concurrent_reports_worker_panic() {
1481 let inputs = [1usize];
1482 let should_panic = Arc::new(std::sync::atomic::AtomicBool::new(true));
1483 let should_panic_for_task = Arc::clone(&should_panic);
1484
1485 let error = pollster::block_on(map_ordered_concurrent(&inputs, 1, move |_| {
1486 let should_panic = Arc::clone(&should_panic_for_task);
1487 async move {
1488 if should_panic.load(std::sync::atomic::Ordering::SeqCst) {
1489 panic!("test worker panic");
1490 }
1491 1usize
1492 }
1493 }))
1494 .expect_err("worker panic should be reported");
1495
1496 assert!(matches!(error, HttpError::WorkerPanicked { .. }));
1497 }
1498
1499 #[test]
1500 fn local_http_client_can_be_overridden() {
1501 let local = local_http_client();
1502 let default_client = default_http_client();
1503 let custom_client: HttpClientRef = Arc::new(TestHttpClient);
1504 let captured = Rc::new(RefCell::new(None));
1505
1506 {
1507 let captured_for_closure = Rc::clone(&captured);
1508 let custom_client = custom_client.clone();
1509 let local_for_provider = local.clone();
1510 let local_for_read = local.clone();
1511 run_test_composition(move || {
1512 let captured = Rc::clone(&captured_for_closure);
1513 let local_for_read = local_for_read.clone();
1514 CompositionLocalProvider(
1515 vec![local_for_provider.provides(custom_client.clone())],
1516 move || {
1517 let current = local_for_read.current();
1518 *captured.borrow_mut() = Some(current);
1519 },
1520 );
1521 });
1522 }
1523
1524 let current = captured.borrow().as_ref().expect("client captured").clone();
1525 assert!(Arc::ptr_eq(¤t, &custom_client));
1526 assert!(!Arc::ptr_eq(¤t, &default_client));
1527 }
1528
1529 #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
1530 #[test]
1531 fn default_http_client_reports_disabled_native_http_feature() {
1532 let error = pollster::block_on(default_http_client().get_text("https://example.com"))
1533 .expect_err("native HTTP should be feature-gated");
1534
1535 assert!(matches!(
1536 error,
1537 HttpError::UnsupportedFeature {
1538 feature: "http-native",
1539 ..
1540 }
1541 ));
1542 }
1543
1544 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1545 #[test]
1546 fn native_http_client_builds() {
1547 build_native_client().expect("native HTTP client should initialize");
1548 }
1549
1550 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1551 #[test]
1552 fn certificates_from_der_chain_accepts_valid_roots() {
1553 let certificates = certificates_from_der_chain(
1554 webpki_root_certs::TLS_SERVER_ROOT_CERTS
1555 .iter()
1556 .take(3)
1557 .map(|certificate| certificate.as_ref()),
1558 )
1559 .expect("root certificates should parse");
1560
1561 assert_eq!(certificates.len(), 3);
1562 }
1563
1564 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1571 fn local_server(
1572 body: Vec<u8>,
1573 chunk: usize,
1574 delay: std::time::Duration,
1575 supports_range: bool,
1576 ) -> Option<(String, thread::JoinHandle<()>)> {
1577 use std::io::{Read, Write};
1578 use std::net::TcpListener;
1579
1580 let listener = match TcpListener::bind("127.0.0.1:0") {
1581 Ok(listener) => listener,
1582 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1583 eprintln!("skipping local HTTP server bind in restricted environment: {error}");
1584 return None;
1585 }
1586 Err(error) => panic!("bind local test server: {error}"),
1587 };
1588 let address = listener.local_addr().expect("local test server address");
1589 let handle = thread::spawn(move || {
1590 let (mut stream, _) = listener.accept().expect("accept local test request");
1591 let mut request = [0u8; 2048];
1592 let read = stream.read(&mut request).expect("read local test request");
1593 let request = String::from_utf8_lossy(&request[..read]).to_string();
1594
1595 let range_from = supports_range
1596 .then(|| {
1597 request
1598 .lines()
1599 .find(|line| line.to_ascii_lowercase().starts_with("range:"))
1600 .and_then(|line| line.split("bytes=").nth(1))
1601 .and_then(|value| value.trim_end_matches('-').trim().parse::<u64>().ok())
1602 })
1603 .flatten();
1604
1605 let payload = match range_from {
1606 Some(offset) if (offset as usize) < body.len() => &body[offset as usize..],
1607 Some(_) => &body[body.len()..],
1608 None => &body[..],
1609 };
1610 let status = if range_from.is_some() {
1611 "206 Partial Content"
1612 } else {
1613 "200 OK"
1614 };
1615 write!(
1616 stream,
1617 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1618 payload.len()
1619 )
1620 .expect("write local test response head");
1621 for piece in payload.chunks(chunk.max(1)) {
1622 if stream.write_all(piece).is_err() {
1623 return;
1626 }
1627 let _ = stream.flush();
1628 if !delay.is_zero() {
1629 thread::sleep(delay);
1630 }
1631 }
1632 });
1633 Some((format!("http://{address}"), handle))
1634 }
1635
1636 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1639 #[test]
1640 fn a_native_body_arrives_in_pieces() {
1641 let body = vec![b'x'; 200 * 1024];
1642 let Some((url, server)) = local_server(
1643 body.clone(),
1644 16 * 1024,
1645 std::time::Duration::from_millis(5),
1646 false,
1647 ) else {
1648 return;
1649 };
1650
1651 let client = default_http_client();
1652 let response = pollster::block_on(client.send(&HttpRequest::get(&url), HttpControl::new()))
1653 .expect("a response");
1654 assert!(response.is_success());
1655 assert_eq!(response.content_length, Some(body.len() as u64));
1656
1657 let mut received = Vec::new();
1658 let mut chunks = 0usize;
1659 while let Some(chunk) = pollster::block_on(response.read_chunk()).expect("a chunk") {
1660 chunks += 1;
1661 received.extend_from_slice(&chunk);
1662 }
1663 server.join().expect("the local server finishes");
1664 assert_eq!(received.len(), body.len());
1665 assert!(
1666 chunks > 1,
1667 "a 200 KiB body must arrive in more than one piece, saw {chunks}"
1668 );
1669 }
1670
1671 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1674 #[test]
1675 fn a_native_transfer_reports_progress_as_it_runs() {
1676 let body = vec![b'y'; 200 * 1024];
1677 let Some((url, server)) = local_server(
1678 body.clone(),
1679 16 * 1024,
1680 std::time::Duration::from_millis(2),
1681 false,
1682 ) else {
1683 return;
1684 };
1685
1686 let reports = Arc::new(std::sync::Mutex::new(Vec::new()));
1687 let recorder = Arc::clone(&reports);
1688 let control = HttpControl::new().with_progress(move |progress| {
1689 recorder
1690 .lock()
1691 .unwrap_or_else(|error| error.into_inner())
1692 .push(progress);
1693 });
1694
1695 let client = default_http_client();
1696 let response =
1697 pollster::block_on(client.send(&HttpRequest::get(&url), control)).expect("a response");
1698 let received = pollster::block_on(response.read_all()).expect("the body");
1699 server.join().expect("the local server finishes");
1700
1701 assert_eq!(received.len(), body.len());
1702 let reports = reports
1703 .lock()
1704 .unwrap_or_else(|error| error.into_inner())
1705 .clone();
1706 assert!(
1707 reports.len() > 1,
1708 "progress must move more than once over a 200 KiB transfer, saw {}",
1709 reports.len()
1710 );
1711 assert!(
1712 reports
1713 .windows(2)
1714 .all(|pair| pair[1].transferred >= pair[0].transferred),
1715 "progress must not go backwards: {reports:?}"
1716 );
1717 assert_eq!(
1718 reports.last().map(|progress| progress.transferred),
1719 Some(body.len() as u64)
1720 );
1721 }
1722
1723 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1726 #[test]
1727 fn a_cancelled_native_transfer_stops() {
1728 let body = vec![b'z'; 512 * 1024];
1729 let Some((url, server)) =
1730 local_server(body, 8 * 1024, std::time::Duration::from_millis(5), false)
1731 else {
1732 return;
1733 };
1734
1735 let control = HttpControl::new();
1736 let client = default_http_client();
1737 let response = pollster::block_on(client.send(&HttpRequest::get(&url), control.clone()))
1738 .expect("a response");
1739
1740 let first = pollster::block_on(response.read_chunk()).expect("a chunk");
1742 assert!(first.is_some());
1743 control.cancel();
1744
1745 let mut ended = false;
1746 for _ in 0..64 {
1747 match pollster::block_on(response.read_chunk()) {
1748 Ok(Some(_)) => continue,
1749 Ok(None) => {
1750 ended = true;
1751 break;
1752 }
1753 Err(HttpError::Cancelled) => {
1754 ended = true;
1755 break;
1756 }
1757 Err(other) => panic!("unexpected error after cancelling: {other}"),
1758 }
1759 }
1760 drop(response);
1761 let _ = server.join();
1762 assert!(ended, "a cancelled transfer must end rather than run on");
1763 }
1764
1765 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1768 #[test]
1769 fn a_download_resumes_from_what_is_already_on_disk() {
1770 let body: Vec<u8> = (0..4096u32).map(|value| value as u8).collect();
1771 let directory = crate::test_scratch_dir("http-resume");
1772 let target = directory.join("payload.bin");
1773 let _ = std::fs::remove_file(&target);
1774 std::fs::write(&target, &body[..1024]).expect("a partial file");
1775
1776 let Some((url, server)) = local_server(body.clone(), 1024, std::time::Duration::ZERO, true)
1777 else {
1778 return;
1779 };
1780 let client = default_http_client();
1781 let written = pollster::block_on(client.download_to(&url, &target, HttpControl::new()))
1782 .expect("the download finishes");
1783 server.join().expect("the local server finishes");
1784
1785 assert_eq!(written, body.len() as u64);
1786 assert_eq!(std::fs::read(&target).expect("the file"), body);
1787 let _ = std::fs::remove_file(&target);
1788 }
1789
1790 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1794 #[test]
1795 fn a_server_that_ignores_a_range_restarts_the_file_rather_than_appending() {
1796 let body: Vec<u8> = (0..4096u32).map(|value| value as u8).collect();
1797 let directory = crate::test_scratch_dir("http-restart");
1798 let target = directory.join("payload.bin");
1799 let _ = std::fs::remove_file(&target);
1800 std::fs::write(&target, &body[..1024]).expect("a partial file");
1801
1802 let Some((url, server)) =
1803 local_server(body.clone(), 1024, std::time::Duration::ZERO, false)
1804 else {
1805 return;
1806 };
1807 let client = default_http_client();
1808 let written = pollster::block_on(client.download_to(&url, &target, HttpControl::new()))
1809 .expect("the download finishes");
1810 server.join().expect("the local server finishes");
1811
1812 assert_eq!(written, body.len() as u64);
1813 assert_eq!(
1814 std::fs::read(&target).expect("the file"),
1815 body,
1816 "the partial file must be replaced, not appended to"
1817 );
1818 let _ = std::fs::remove_file(&target);
1819 }
1820
1821 #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1822 #[test]
1823 fn default_http_client_fetches_text_from_local_server() {
1824 use std::io::{Read, Write};
1825 use std::net::TcpListener;
1826
1827 let listener = match TcpListener::bind("127.0.0.1:0") {
1828 Ok(listener) => listener,
1829 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
1830 eprintln!("skipping local HTTP server bind in restricted test environment: {err}");
1831 return;
1832 }
1833 Err(err) => panic!("bind local test server: {err}"),
1834 };
1835 let address = listener
1836 .local_addr()
1837 .expect("read local test server address");
1838 let server = thread::spawn(move || {
1839 let (mut stream, _) = listener.accept().expect("accept local test request");
1840 let mut request = [0_u8; 1024];
1841 let _ = stream.read(&mut request).expect("read local test request");
1842 let body = "cranpose-http-test";
1843 write!(
1844 stream,
1845 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1846 body.len(),
1847 body
1848 )
1849 .expect("write local test response");
1850 });
1851
1852 let url = format!("http://{address}");
1853 let text = pollster::block_on(default_http_client().get_text(&url))
1854 .expect("fetch text from local test server");
1855 server.join().expect("join local test server");
1856
1857 assert_eq!(text, "cranpose-http-test");
1858 }
1859}