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