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