Skip to main content

cranpose_services/
http.rs

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