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