Skip to main content

cranpose_services/
http.rs

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