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