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