Skip to main content

cranpose_services/
http.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::path::Path;
3use std::{
4    future::Future,
5    pin::Pin,
6    sync::{
7        Arc, Mutex, PoisonError, Weak,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use cranpose_core::{CompositionLocal, compositionLocalOfWithPolicy};
13#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
14use futures_util::{StreamExt, stream};
15
16/// Why an HTTP transfer failed.
17#[derive(thiserror::Error, Debug, Clone)]
18pub enum HttpError {
19    /// The platform could not create its HTTP client. The text describes the cause.
20    #[error("Failed to build HTTP client: {0}")]
21    ClientInit(String),
22    /// The request could not be sent or got no response.
23    #[error("Request failed for {url}: {message}")]
24    RequestFailed {
25        /// The URL that was requested.
26        url: String,
27        /// What the platform reported.
28        message: String,
29    },
30    /// The server answered with a status outside the 2xx range.
31    #[error("Request failed with status {status} for {url}")]
32    HttpStatus {
33        /// The URL that was requested.
34        url: String,
35        /// The status code of the response.
36        status: u16,
37    },
38    /// The response body ended early or could not be read.
39    #[error("Failed to read response body for {url}: {message}")]
40    BodyReadFailed {
41        /// The URL that was requested.
42        url: String,
43        /// What the platform reported.
44        message: String,
45    },
46    /// The response is not usable, such as a body that is not valid UTF-8 text.
47    #[error("Invalid response for {url}: {message}")]
48    InvalidResponse {
49        /// The URL that was requested.
50        url: String,
51        /// What is wrong with the response.
52        message: String,
53    },
54    /// The browser gave no window object to send the request through.
55    #[error("No window object available")]
56    NoWindow,
57    /// A worker thread that ran part of the operation panicked.
58    #[error("{operation} worker thread panicked")]
59    WorkerPanicked {
60        /// The operation that the worker thread ran.
61        operation: &'static str,
62    },
63    /// The operation needs a `cranpose-services` feature that is turned off.
64    #[error("{operation} requires cranpose-services feature `{feature}`")]
65    UnsupportedFeature {
66        /// The operation that the build cannot run.
67        operation: &'static str,
68        /// Name of the `cranpose-services` feature that the operation needs.
69        feature: &'static str,
70    },
71    /// The caller cancelled the transfer through its [`HttpControl`].
72    #[error("Download was cancelled")]
73    Cancelled,
74    /// A downloaded file could not be written to disk.
75    #[error("Failed to write downloaded file {path}: {message}")]
76    FileWrite {
77        /// The path of the file that failed to write.
78        path: String,
79        /// What the system reported, such as
80        /// [`std::io::ErrorKind::StorageFull`] for a full disk.
81        kind: std::io::ErrorKind,
82        /// The text of the system error.
83        message: String,
84    },
85}
86
87/// How much of a transfer has arrived.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
89pub struct HttpProgress {
90    /// Bytes transferred so far, counting anything a resumed transfer skipped.
91    pub transferred: u64,
92    /// The whole size, when the server said. A chunked response does not, and
93    /// a progress bar that invents a total for one is lying about it.
94    pub total: Option<u64>,
95}
96
97impl HttpProgress {
98    /// How far through the transfer is, in `0..=1`, or `None` when the server
99    /// never said how large it is.
100    pub fn fraction(&self) -> Option<f32> {
101        let total = self.total?;
102        if total == 0 {
103            return None;
104        }
105        Some((self.transferred as f32 / total as f32).clamp(0.0, 1.0))
106    }
107
108    /// Whether everything the server promised has arrived.
109    pub fn is_complete(&self) -> bool {
110        self.total.is_some_and(|total| self.transferred >= total)
111    }
112}
113
114/// What a progress report is handed to.
115pub type ProgressHandler = Arc<dyn Fn(HttpProgress) + Send + Sync>;
116
117/// Cancellation and progress shared with an in-flight request.
118///
119/// One control covers every request rather than only file downloads: a slow
120/// response is a slow response whether it is being written to disk or read into
121/// memory, and a screen that leaves is a screen that leaves.
122#[derive(Clone, Default)]
123pub struct HttpControl {
124    cancel: Arc<CancelState>,
125    progress: Option<ProgressHandler>,
126}
127
128#[derive(Default)]
129struct CancelState {
130    cancelled: AtomicBool,
131    waiting: Mutex<Vec<Weak<dyn CancelTransfer>>>,
132}
133
134/// A transfer that stops at once when its [`HttpControl`] is cancelled,
135/// registered with [`HttpControl::on_cancel`] by the backend running it.
136pub trait CancelTransfer: Send + Sync {
137    /// Stops the transfer; the caller sees [`HttpError::Cancelled`].
138    fn cancel(&self);
139}
140
141impl HttpControl {
142    /// A live control with no progress reporting.
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Reports transferred and, where the server says so, total bytes.
148    ///
149    /// Called from whichever thread is doing the transfer, so the handler must
150    /// be prepared for that — the framework's own event stream is the ordinary
151    /// way to get it back to composition.
152    pub fn with_progress(
153        mut self,
154        progress: impl Fn(HttpProgress) + Send + Sync + 'static,
155    ) -> Self {
156        self.progress = Some(Arc::new(progress));
157        self
158    }
159
160    /// Requests cancellation.
161    ///
162    /// A native transfer that waits for the response or the next chunk ends
163    /// with [`HttpError::Cancelled`] at once; other backends stop at the next
164    /// chunk boundary. A partial file left by a cancelled download stays on
165    /// disk, which is what makes the next attempt able to resume rather than
166    /// start again.
167    pub fn cancel(&self) {
168        self.cancel.cancelled.store(true, Ordering::Release);
169        let waiting = std::mem::take(
170            &mut *self
171                .cancel
172                .waiting
173                .lock()
174                .unwrap_or_else(PoisonError::into_inner),
175        );
176        for transfer in waiting.iter().filter_map(Weak::upgrade) {
177            transfer.cancel();
178        }
179    }
180
181    /// A control cancelled with this one that reports no progress.
182    #[cfg(not(target_arch = "wasm32"))]
183    fn cancel_only(&self) -> Self {
184        Self {
185            cancel: Arc::clone(&self.cancel),
186            progress: None,
187        }
188    }
189
190    /// Whether cancellation was requested.
191    pub fn is_cancelled(&self) -> bool {
192        self.cancel.cancelled.load(Ordering::Acquire)
193    }
194
195    /// Cancels `transfer` when this control is cancelled, or now if it already
196    /// is. The control holds it weakly, so a transfer that ended is dropped
197    /// at the next registration; a backend keeps it alive while it runs.
198    pub fn on_cancel(&self, transfer: Weak<dyn CancelTransfer>) {
199        let mut waiting = self
200            .cancel
201            .waiting
202            .lock()
203            .unwrap_or_else(PoisonError::into_inner);
204        if self.is_cancelled() {
205            drop(waiting);
206            if let Some(transfer) = transfer.upgrade() {
207                transfer.cancel();
208            }
209            return;
210        }
211        waiting.retain(|transfer| transfer.strong_count() > 0);
212        waiting.push(transfer);
213    }
214
215    /// Hands a progress reading to whatever [`with_progress`](Self::with_progress)
216    /// registered, and does nothing where nothing did.
217    ///
218    /// Called by the backend doing the transfer - including an
219    /// [`HttpClient`] an application provides through [`local_http_client`],
220    /// which is why this is part of the control's public surface rather than
221    /// private to the backends shipped here.
222    pub fn report(&self, progress: HttpProgress) {
223        if let Some(handler) = &self.progress {
224            handler(progress);
225        }
226    }
227}
228
229/// The verbs a request can use.
230#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
231pub enum HttpMethod {
232    /// Reads the resource.
233    #[default]
234    Get,
235    /// Asks for the headers only, without the body.
236    Head,
237    /// Sends the request body to the server for processing.
238    Post,
239    /// Replaces the resource with the request body.
240    Put,
241    /// Removes the resource.
242    Delete,
243    /// Lists WebDAV properties without downloading file contents.
244    Propfind,
245}
246
247impl HttpMethod {
248    /// The name that goes on the wire.
249    pub fn name(self) -> &'static str {
250        match self {
251            Self::Get => "GET",
252            Self::Head => "HEAD",
253            Self::Post => "POST",
254            Self::Put => "PUT",
255            Self::Delete => "DELETE",
256            Self::Propfind => "PROPFIND",
257        }
258    }
259}
260
261/// One request.
262#[derive(Clone, Debug, Default, PartialEq, Eq)]
263pub struct HttpRequest {
264    /// The absolute URL to request.
265    pub url: String,
266    /// The HTTP method of the request.
267    pub method: HttpMethod,
268    /// The request headers as name and value pairs, in the order they are sent.
269    pub headers: Vec<(String, String)>,
270    /// The request body, or `None` for a request without one.
271    pub body: Option<Vec<u8>>,
272    /// Resume an interrupted transfer from this byte offset.
273    ///
274    /// Sent as a `Range` header. A server free to ignore it answers `200` with
275    /// the whole body instead of `206` with the rest, which
276    /// [`HttpResponse::resumed`] reports — a caller that appends to a partial
277    /// file must check it, or it writes the start of the file over the middle.
278    pub resume_from: Option<u64>,
279}
280
281impl HttpRequest {
282    /// A `GET` for `url`.
283    pub fn get(url: impl Into<String>) -> Self {
284        Self {
285            url: url.into(),
286            ..Self::default()
287        }
288    }
289
290    /// Sets the HTTP method of the request.
291    pub fn method(mut self, method: HttpMethod) -> Self {
292        self.method = method;
293        self
294    }
295
296    /// Adds a header to the request. Calling it again with the same name adds a second header.
297    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
298        self.headers.push((name.into(), value.into()));
299        self
300    }
301
302    /// Sets the request body.
303    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
304        self.body = Some(body.into());
305        self
306    }
307
308    /// Resumes an interrupted transfer from `offset`.
309    pub fn resume_from(mut self, offset: u64) -> Self {
310        self.resume_from = (offset > 0).then_some(offset);
311        self
312    }
313}
314
315/// The future that an HTTP operation returns. It is `Send`, so it can run on any thread.
316#[cfg(not(target_arch = "wasm32"))]
317pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + Send + 'a>>;
318
319/// The future that an HTTP operation returns. It is not `Send` on wasm, because browser objects
320/// belong to the one thread of the page.
321#[cfg(target_arch = "wasm32")]
322pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + 'a>>;
323
324/// A response body, read a chunk at a time.
325///
326/// A body is not a `Vec<u8>`: a model pack, a video, an application package are
327/// all larger than the memory a phone will hand over, and the only way to read
328/// one is to not hold it. Everything that wants the whole thing anyway asks for
329/// it explicitly through [`HttpResponse::read_all`].
330pub trait HttpBody {
331    /// The next chunk, or `None` at the end of the body.
332    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>>;
333}
334
335/// Shared handle to a response body.
336///
337/// Bounded exactly as [`HttpFuture`] is: a native transfer runs on a thread of
338/// its own and its body crosses to whoever awaits it, while a browser's body is
339/// a `ReadableStream` reader that belongs to the one thread a page has and
340/// cannot claim otherwise.
341#[cfg(not(target_arch = "wasm32"))]
342pub type HttpBodyRef = Arc<dyn HttpBody + Send + Sync>;
343
344/// Shared handle to a response body.
345///
346/// A browser's body is a `ReadableStream` reader that belongs to the one
347/// thread a page has and cannot claim otherwise, so it is never `Send + Sync`
348/// there; `Rc` avoids paying for synchronisation the target cannot use.
349#[cfg(target_arch = "wasm32")]
350pub type HttpBodyRef = std::rc::Rc<dyn HttpBody>;
351
352/// Wraps `body` in an [`HttpBodyRef`].
353///
354/// A trait-object alias cannot expose its own `new`, so this is the one place
355/// that picks `Arc` on native and `Rc` on wasm; callers that need an
356/// [`HttpBodyRef`] from a concrete body go through this instead of repeating
357/// that choice.
358#[cfg(not(target_arch = "wasm32"))]
359pub fn http_body_ref<B: HttpBody + Send + Sync + 'static>(body: B) -> HttpBodyRef {
360    Arc::new(body)
361}
362
363/// See the native definition of [`http_body_ref`] for why this is `Rc` on wasm.
364#[cfg(target_arch = "wasm32")]
365pub fn http_body_ref<B: HttpBody + 'static>(body: B) -> HttpBodyRef {
366    std::rc::Rc::new(body)
367}
368
369struct EmptyBody;
370
371impl HttpBody for EmptyBody {
372    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
373        Box::pin(async { Ok(None) })
374    }
375}
376
377/// A body already in memory, handed out in `CHUNK_LEN`-sized pieces.
378///
379/// For a backend that has the bytes already — a cache, a bundled asset, a test
380/// double — so it satisfies the same chunked contract as one still on the wire
381/// and its callers cannot tell the difference.
382pub struct BytesBody {
383    bytes: Vec<u8>,
384    offset: Mutex<usize>,
385    chunk: usize,
386}
387
388impl BytesBody {
389    /// A body over `bytes`, delivered in one chunk per read.
390    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
391        Self::chunked(bytes, usize::MAX)
392    }
393
394    /// A body over `bytes`, delivered at most `chunk` bytes at a time.
395    pub fn chunked(bytes: impl Into<Vec<u8>>, chunk: usize) -> Self {
396        Self {
397            bytes: bytes.into(),
398            offset: Mutex::new(0),
399            chunk: chunk.max(1),
400        }
401    }
402
403    /// How long the body is.
404    pub fn len(&self) -> u64 {
405        self.bytes.len() as u64
406    }
407
408    /// Whether the body carries nothing.
409    pub fn is_empty(&self) -> bool {
410        self.bytes.is_empty()
411    }
412}
413
414impl HttpBody for BytesBody {
415    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
416        Box::pin(async move {
417            let mut offset = self.offset.lock().unwrap_or_else(PoisonError::into_inner);
418            if *offset >= self.bytes.len() {
419                return Ok(None);
420            }
421            let end = offset.saturating_add(self.chunk).min(self.bytes.len());
422            let chunk = self.bytes[*offset..end].to_vec();
423            *offset = end;
424            Ok(Some(chunk))
425        })
426    }
427}
428
429/// What a server answered, with its body still to be read.
430pub struct HttpResponse {
431    /// The status line's code.
432    pub status: u16,
433    /// The response headers, with their names lower-cased.
434    pub headers: Vec<(String, String)>,
435    /// How long the body is, when the server said.
436    pub content_length: Option<u64>,
437    /// Whether the server honoured [`HttpRequest::resume_from`].
438    ///
439    /// `false` from a resumed request means the body starts at the beginning
440    /// again, and a caller appending to a partial file must truncate it first.
441    pub resumed: bool,
442    /// The URL the request went to, for the errors reading the body reports.
443    pub url: String,
444    body: HttpBodyRef,
445}
446
447impl HttpResponse {
448    /// Builds a response around a body.
449    pub fn new(url: impl Into<String>, status: u16, body: HttpBodyRef) -> Self {
450        Self {
451            status,
452            headers: Vec::new(),
453            content_length: None,
454            resumed: false,
455            url: url.into(),
456            body,
457        }
458    }
459
460    /// A response with no body, which is what a `HEAD` answers with.
461    pub fn empty(url: impl Into<String>, status: u16) -> Self {
462        Self::new(url, status, http_body_ref(EmptyBody))
463    }
464
465    /// Sets the response headers as name and value pairs.
466    pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
467        self.headers = headers;
468        self
469    }
470
471    /// Sets the length of the body in bytes, when the server said.
472    pub fn with_content_length(mut self, content_length: Option<u64>) -> Self {
473        self.content_length = content_length;
474        self
475    }
476
477    /// Sets whether the server honoured [`HttpRequest::resume_from`].
478    pub fn with_resumed(mut self, resumed: bool) -> Self {
479        self.resumed = resumed;
480        self
481    }
482
483    /// Whether the status is in the 2xx range.
484    pub fn is_success(&self) -> bool {
485        (200..300).contains(&self.status)
486    }
487
488    /// One header, matched without regard to case.
489    pub fn header(&self, name: &str) -> Option<&str> {
490        let name = name.to_ascii_lowercase();
491        self.headers
492            .iter()
493            .find(|(key, _)| key.eq_ignore_ascii_case(&name))
494            .map(|(_, value)| value.as_str())
495    }
496
497    /// Fails with [`HttpError::HttpStatus`] unless the status is a success.
498    pub fn error_for_status(self) -> Result<Self, HttpError> {
499        if self.is_success() {
500            Ok(self)
501        } else {
502            Err(HttpError::HttpStatus {
503                url: self.url.clone(),
504                status: self.status,
505            })
506        }
507    }
508
509    /// The next chunk of the body, or `None` at its end.
510    pub async fn read_chunk(&self) -> Result<Option<Vec<u8>>, HttpError> {
511        self.body.read_chunk().await
512    }
513
514    /// The whole body.
515    ///
516    /// Named for what it does, so a caller reading something that might not fit
517    /// in memory can see that it is asking for exactly that.
518    pub async fn read_all(&self) -> Result<Vec<u8>, HttpError> {
519        let mut out = Vec::with_capacity(self.content_length.unwrap_or(0).min(1 << 20) as usize);
520        while let Some(chunk) = self.read_chunk().await? {
521            out.extend_from_slice(&chunk);
522        }
523        Ok(out)
524    }
525
526    /// The whole body as text.
527    pub async fn read_text(&self) -> Result<String, HttpError> {
528        String::from_utf8(self.read_all().await?).map_err(|error| HttpError::InvalidResponse {
529            url: self.url.clone(),
530            message: error.to_string(),
531        })
532    }
533}
534
535/// A platform's HTTP.
536///
537/// One method carries the whole contract: everything else — text, bytes, a file
538/// on disk — is that method plus what the caller does with the body, so a
539/// backend implements the transfer once and cannot make `get_text` behave
540/// differently from `download_to`.
541pub trait HttpClient: Send + Sync {
542    /// Sends `request`, resolving when the response's status line has arrived
543    /// and its body is ready to be read.
544    fn send<'a>(
545        &'a self,
546        request: &'a HttpRequest,
547        control: HttpControl,
548    ) -> HttpFuture<'a, HttpResponse>;
549
550    /// The body of a successful `GET`, as text.
551    fn get_text<'a>(&'a self, url: &'a str) -> HttpFuture<'a, String> {
552        Box::pin(async move {
553            self.send(&HttpRequest::get(url), HttpControl::new())
554                .await?
555                .error_for_status()?
556                .read_text()
557                .await
558        })
559    }
560
561    /// The body of a successful `GET`.
562    fn get_bytes<'a>(&'a self, url: &'a str) -> HttpFuture<'a, Vec<u8>> {
563        Box::pin(async move {
564            self.send(&HttpRequest::get(url), HttpControl::new())
565                .await?
566                .error_for_status()?
567                .read_all()
568                .await
569        })
570    }
571
572    /// Streams a response into `target`, resuming from its current length when
573    /// the server supports byte ranges, and returns the file's whole length.
574    ///
575    /// This is the appropriate call for model packs and other files that should
576    /// not be held in memory. A cancelled transfer leaves the partial file
577    /// where it is, which is what lets the next attempt continue rather than
578    /// start again.
579    #[cfg(not(target_arch = "wasm32"))]
580    fn download_to<'a>(
581        &'a self,
582        url: &'a str,
583        target: &'a Path,
584        control: HttpControl,
585    ) -> HttpFuture<'a, u64> {
586        Box::pin(async move { download_through(self, url, target, control).await })
587    }
588}
589
590/// A platform's own transfer for [`HttpClient::download_to`], one that goes
591/// on while the application is suspended or closed.
592///
593/// It keeps the contract of `download_to`: the file at `target` grows from its
594/// current length, a cancelled transfer keeps what arrived for the next call,
595/// and the result is the file's whole length. A transfer that finished while
596/// the application was closed is in `target` when the application starts
597/// again, so a call that finds the whole file returns at once.
598#[cfg(not(target_arch = "wasm32"))]
599pub trait PlatformDownloader: Send + Sync {
600    /// Starts or continues the download of `url` into `target`.
601    ///
602    /// Returns the whole length of the file in bytes.
603    fn download<'a>(
604        &'a self,
605        url: &'a str,
606        target: &'a Path,
607        control: HttpControl,
608    ) -> HttpFuture<'a, u64>;
609
610    /// The bytes of `target` this downloader keeps apart from the file, as
611    /// a paused transfer's, added to the file's own; `None` when it keeps
612    /// none.
613    fn downloaded_length(&self, target: &Path) -> Option<u64>;
614}
615
616/// How much of a download into `target` is on the device: the file's
617/// length, or more where the platform keeps a paused transfer's bytes apart
618/// from the file, as iOS does. An application shows a paused download's
619/// progress with it.
620#[cfg(not(target_arch = "wasm32"))]
621pub fn downloaded_length(target: &Path) -> u64 {
622    PLATFORM_DOWNLOADER
623        .get()
624        .and_then(|downloader| downloader.downloaded_length(target))
625        .unwrap_or_else(|| std::fs::metadata(target).map_or(0, |metadata| metadata.len()))
626}
627
628#[cfg(not(target_arch = "wasm32"))]
629static PLATFORM_DOWNLOADER: crate::registry::ServiceRegistry<dyn PlatformDownloader> =
630    crate::registry::ServiceRegistry::new();
631
632/// Installs the platform's downloader; [`default_http_client`] then runs
633/// `download_to` through it. iOS installs one on a background `URLSession`.
634// forwards on purpose: the registry is private to this crate, and this is the
635// one way a platform backend installs into it.
636#[cfg(not(target_arch = "wasm32"))]
637pub fn set_platform_downloader(downloader: Arc<dyn PlatformDownloader>) {
638    PLATFORM_DOWNLOADER.set(downloader);
639}
640
641#[cfg(not(target_arch = "wasm32"))]
642async fn download_through<C: HttpClient + ?Sized>(
643    client: &C,
644    url: &str,
645    target: &Path,
646    control: HttpControl,
647) -> Result<u64, HttpError> {
648    use std::io::Write;
649
650    if control.is_cancelled() {
651        return Err(HttpError::Cancelled);
652    }
653    let existing = std::fs::metadata(target).map_or(0, |metadata| metadata.len());
654    let mut request = HttpRequest::get(url);
655    if existing > 0 {
656        request = request.resume_from(existing);
657    }
658    // The request reports nothing itself: its count starts at zero, and this
659    // function reports the whole file's.
660    let response = client
661        .send(&request, control.cancel_only())
662        .await?
663        .error_for_status()?;
664
665    let base = if response.resumed { existing } else { 0 };
666    let total = response.content_length.map(|remaining| base + remaining);
667    let mut output = if response.resumed {
668        std::fs::OpenOptions::new().append(true).open(target)
669    } else {
670        std::fs::File::create(target)
671    }
672    .map_err(|error| file_write_error(target, &error))?;
673
674    let mut transferred = base;
675    control.report(HttpProgress { transferred, total });
676    while let Some(chunk) = response.read_chunk().await? {
677        if control.is_cancelled() {
678            return Err(HttpError::Cancelled);
679        }
680        output
681            .write_all(&chunk)
682            .map_err(|error| file_write_error(target, &error))?;
683        transferred += chunk.len() as u64;
684        control.report(HttpProgress { transferred, total });
685    }
686    output
687        .flush()
688        .map_err(|error| file_write_error(target, &error))?;
689    Ok(transferred)
690}
691
692#[cfg(not(target_arch = "wasm32"))]
693fn file_write_error(target: &Path, error: &std::io::Error) -> HttpError {
694    HttpError::FileWrite {
695        path: target.display().to_string(),
696        kind: error.kind(),
697        message: error.to_string(),
698    }
699}
700
701/// Shared handle to an [`HttpClient`].
702pub type HttpClientRef = Arc<dyn HttpClient>;
703
704/// Runs `task` for every item with at most `concurrency` tasks at a time, each on its own
705/// thread. Results keep the order of `items`. A `concurrency` of zero counts as one.
706/// Returns [`HttpError::WorkerPanicked`] when a task panics.
707#[cfg(not(target_arch = "wasm32"))]
708pub async fn map_ordered_concurrent<I, T, F, Fut>(
709    items: &[I],
710    concurrency: usize,
711    task: F,
712) -> Result<Vec<T>, HttpError>
713where
714    I: Clone + Send,
715    T: Send,
716    F: Fn(I) -> Fut + Send + Sync + 'static,
717    Fut: Future<Output = T> + Send,
718{
719    let task = Arc::new(task);
720    let mut results = Vec::with_capacity(items.len());
721
722    for chunk in items.chunks(concurrency.max(1)) {
723        let chunk_results = std::thread::scope(|scope| {
724            let mut handles = Vec::with_capacity(chunk.len());
725            for item in chunk.iter().cloned() {
726                let task = Arc::clone(&task);
727                handles.push(scope.spawn(move || pollster::block_on(task(item))));
728            }
729
730            let mut chunk_results = Vec::with_capacity(handles.len());
731            for handle in handles {
732                let value = handle.join().map_err(|_| HttpError::WorkerPanicked {
733                    operation: "ordered concurrent task",
734                })?;
735                chunk_results.push(value);
736            }
737            Ok::<Vec<T>, HttpError>(chunk_results)
738        })?;
739        results.extend(chunk_results);
740    }
741
742    Ok(results)
743}
744
745/// Runs `task` for every item with at most `concurrency` tasks in flight on the browser thread.
746/// Results keep the order of `items`. A `concurrency` of zero counts as one.
747#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
748pub async fn map_ordered_concurrent<I, T, F, Fut>(
749    items: &[I],
750    concurrency: usize,
751    task: F,
752) -> Result<Vec<T>, HttpError>
753where
754    I: Clone,
755    F: Fn(I) -> Fut + Clone,
756    Fut: Future<Output = T>,
757{
758    let mut results = stream::iter(items.iter().cloned().enumerate().map(|(index, item)| {
759        let task = task.clone();
760        async move { (index, task(item).await) }
761    }))
762    .buffer_unordered(concurrency.max(1))
763    .collect::<Vec<_>>()
764    .await;
765
766    results.sort_by_key(|(index, _)| *index);
767    Ok(results.into_iter().map(|(_, value)| value).collect())
768}
769
770/// Runs `task` for every item one after another, because this wasm build has no `web-http`
771/// feature. `concurrency` is ignored. Results keep the order of `items`.
772#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
773pub async fn map_ordered_concurrent<I, T, F, Fut>(
774    items: &[I],
775    _concurrency: usize,
776    task: F,
777) -> Result<Vec<T>, HttpError>
778where
779    I: Clone,
780    F: Fn(I) -> Fut,
781    Fut: Future<Output = T>,
782{
783    Ok(map_ordered_sequential(items, task).await)
784}
785
786#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
787async fn map_ordered_sequential<I, T, F, Fut>(items: &[I], task: F) -> Vec<T>
788where
789    I: Clone,
790    F: Fn(I) -> Fut,
791    Fut: Future<Output = T>,
792{
793    let mut results = Vec::with_capacity(items.len());
794    for item in items.iter().cloned() {
795        results.push(task(item).await);
796    }
797    results
798}
799
800/// What a [`StubHttpClient`] answers a request with.
801pub type StubAnswer = dyn Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync;
802
803/// An HTTP client that answers from a function rather than a network.
804///
805/// A screen driven from a recorded payload, a robot run that must not depend on
806/// a server, a test that wants a specific failure: all of them want one thing,
807/// which is to decide what a request answers. Written as a client each time,
808/// that is the same dozen lines of body plumbing repeated — and each copy is a
809/// chance to answer differently from the real client.
810///
811/// ```ignore
812/// let client: HttpClientRef = Arc::new(StubHttpClient::from_text(move |url| {
813///     Ok(fixture_for(url))
814/// }));
815/// ```
816pub struct StubHttpClient {
817    answer: Box<StubAnswer>,
818}
819
820impl StubHttpClient {
821    /// Answers from a function of the whole request.
822    pub fn new(
823        answer: impl Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync + 'static,
824    ) -> Self {
825        Self {
826            answer: Box::new(answer),
827        }
828    }
829
830    /// Answers every request with `body` and a `200`.
831    pub fn with_body(body: impl Into<Vec<u8>>) -> Self {
832        let body = body.into();
833        Self::new(move |request| {
834            Ok(HttpResponse::new(
835                request.url.clone(),
836                200,
837                http_body_ref(BytesBody::new(body.clone())),
838            ))
839        })
840    }
841
842    /// Answers with text, or an error, from a function of the URL.
843    pub fn from_text(
844        answer: impl Fn(&str) -> Result<String, HttpError> + Send + Sync + 'static,
845    ) -> Self {
846        Self::new(move |request| {
847            let body = answer(&request.url)?;
848            Ok(HttpResponse::new(
849                request.url.clone(),
850                200,
851                http_body_ref(BytesBody::new(body)),
852            ))
853        })
854    }
855}
856
857impl HttpClient for StubHttpClient {
858    fn send<'a>(
859        &'a self,
860        request: &'a HttpRequest,
861        _control: HttpControl,
862    ) -> HttpFuture<'a, HttpResponse> {
863        Box::pin(async move { (self.answer)(request) })
864    }
865}
866
867#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
868const CHUNK_LEN: usize = 64 * 1024;
869
870#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
871const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
872
873struct DefaultHttpClient {
874    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
875    native_client: Result<reqwest::blocking::Client, HttpError>,
876}
877
878impl DefaultHttpClient {
879    fn new() -> Self {
880        Self {
881            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
882            native_client: build_native_client(IDLE_TIMEOUT),
883        }
884    }
885
886    #[cfg(all(test, not(target_arch = "wasm32"), feature = "http-native"))]
887    fn with_idle_timeout(idle: std::time::Duration) -> Self {
888        Self {
889            native_client: build_native_client(idle),
890        }
891    }
892}
893
894#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
895struct ResponseHead {
896    status: u16,
897    headers: Vec<(String, String)>,
898    content_length: Option<u64>,
899    resumed: bool,
900}
901
902#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
903struct NativeTransfer {
904    head: crate::async_io::Signal<Result<ResponseHead, HttpError>>,
905    chunks: crate::async_io::ChunkStream<HttpError>,
906}
907
908#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
909impl CancelTransfer for NativeTransfer {
910    fn cancel(&self) {
911        self.head.set(Err(HttpError::Cancelled));
912        self.chunks.fail(HttpError::Cancelled);
913    }
914}
915
916#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
917struct ChannelBody {
918    transfer: Arc<NativeTransfer>,
919}
920
921#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
922impl HttpBody for ChannelBody {
923    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
924        Box::pin(self.transfer.chunks.next())
925    }
926}
927
928#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
929async fn send_native(
930    client: reqwest::blocking::Client,
931    request: HttpRequest,
932    control: HttpControl,
933) -> Result<HttpResponse, HttpError> {
934    use crate::async_io::{ChunkChannel, Signal};
935
936    let head = Signal::new();
937    let (chunks, stream) = ChunkChannel::<HttpError>::new();
938    let transfer = Arc::new(NativeTransfer {
939        head: head.clone(),
940        chunks: stream,
941    });
942    control.on_cancel(Arc::<NativeTransfer>::downgrade(&transfer));
943    let url = request.url.clone();
944
945    std::thread::Builder::new()
946        .name("cranpose-http".to_string())
947        .spawn(move || {
948            if let Err(error) = read_native_response(&client, request, &control, &chunks, &head) {
949                head.set(Err(error.clone()));
950                chunks.fail(error);
951            }
952        })
953        .map_err(|error| HttpError::RequestFailed {
954            url: url.clone(),
955            message: format!("could not start the transfer: {error}"),
956        })?;
957
958    let head = transfer
959        .head
960        .wait()
961        .await
962        .ok_or_else(|| HttpError::RequestFailed {
963            url: url.clone(),
964            message: "the transfer ended before a response arrived".to_string(),
965        })??;
966
967    Ok(
968        HttpResponse::new(url, head.status, http_body_ref(ChannelBody { transfer }))
969            .with_headers(head.headers)
970            .with_content_length(head.content_length)
971            .with_resumed(head.resumed),
972    )
973}
974
975#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
976fn read_native_response(
977    client: &reqwest::blocking::Client,
978    request: HttpRequest,
979    control: &HttpControl,
980    chunks: &crate::async_io::ChunkChannel<HttpError>,
981    head_signal: &crate::async_io::Signal<Result<ResponseHead, HttpError>>,
982) -> Result<(), HttpError> {
983    use std::io::Read;
984
985    if control.is_cancelled() {
986        return Err(HttpError::Cancelled);
987    }
988    let HttpRequest {
989        url,
990        method,
991        headers,
992        body,
993        resume_from,
994    } = request;
995    let method = reqwest::Method::from_bytes(method.name().as_bytes()).expect("valid HTTP method");
996    let mut builder = client.request(method, &url);
997    for (name, value) in headers {
998        builder = builder.header(name, value);
999    }
1000    if let Some(offset) = resume_from {
1001        builder = builder.header(reqwest::header::RANGE, format!("bytes={offset}-"));
1002    }
1003    if let Some(body) = body {
1004        builder = builder.body(body);
1005    }
1006
1007    let mut response = builder.send().map_err(|error| HttpError::RequestFailed {
1008        url: url.clone(),
1009        message: error.to_string(),
1010    })?;
1011
1012    let status = response.status();
1013    let resumed = resume_from.is_some() && status == reqwest::StatusCode::PARTIAL_CONTENT;
1014    let content_length = response.content_length();
1015    let headers = response
1016        .headers()
1017        .iter()
1018        .map(|(name, value)| {
1019            (
1020                name.as_str().to_ascii_lowercase(),
1021                value.to_str().unwrap_or_default().to_string(),
1022            )
1023        })
1024        .collect();
1025    head_signal.set(Ok(ResponseHead {
1026        status: status.as_u16(),
1027        headers,
1028        content_length,
1029        resumed,
1030    }));
1031
1032    let mut buffer = vec![0u8; CHUNK_LEN];
1033    let mut transferred = 0u64;
1034    loop {
1035        if control.is_cancelled() {
1036            return Err(HttpError::Cancelled);
1037        }
1038        let count = response
1039            .read(&mut buffer)
1040            .map_err(|error| HttpError::BodyReadFailed {
1041                url: url.clone(),
1042                message: error.to_string(),
1043            })?;
1044        if count == 0 {
1045            break;
1046        }
1047        transferred += count as u64;
1048        control.report(HttpProgress {
1049            transferred,
1050            total: content_length,
1051        });
1052        if !chunks.push(buffer[..count].to_vec()) {
1053            break;
1054        }
1055    }
1056    Ok(())
1057}
1058
1059#[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
1060async fn send_native(
1061    _request: HttpRequest,
1062    _control: HttpControl,
1063) -> Result<HttpResponse, HttpError> {
1064    Err(HttpError::UnsupportedFeature {
1065        operation: "native HTTP requests",
1066        feature: "http-native",
1067    })
1068}
1069
1070impl HttpClient for DefaultHttpClient {
1071    fn send<'a>(
1072        &'a self,
1073        request: &'a HttpRequest,
1074        control: HttpControl,
1075    ) -> HttpFuture<'a, HttpResponse> {
1076        Box::pin(async move {
1077            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1078            {
1079                let client = self.native_client.as_ref().map_err(Clone::clone)?.clone();
1080                send_native(client, request.clone(), control).await
1081            }
1082            #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
1083            {
1084                send_native(request.clone(), control).await
1085            }
1086            #[cfg(target_arch = "wasm32")]
1087            {
1088                send_web(request, control).await
1089            }
1090        })
1091    }
1092
1093    #[cfg(not(target_arch = "wasm32"))]
1094    fn download_to<'a>(
1095        &'a self,
1096        url: &'a str,
1097        target: &'a Path,
1098        control: HttpControl,
1099    ) -> HttpFuture<'a, u64> {
1100        Box::pin(async move {
1101            match PLATFORM_DOWNLOADER.get() {
1102                Some(downloader) => downloader.download(url, target, control).await,
1103                None => download_through(self, url, target, control).await,
1104            }
1105        })
1106    }
1107}
1108
1109#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1110fn build_native_client(idle: std::time::Duration) -> Result<reqwest::blocking::Client, HttpError> {
1111    configure_native_client_builder(
1112        reqwest::blocking::Client::builder()
1113            .connect_timeout(std::time::Duration::from_secs(30))
1114            .timeout(idle)
1115            .user_agent(concat!("cranpose/", env!("CARGO_PKG_VERSION"))),
1116    )?
1117    .build()
1118    .map_err(|err| HttpError::ClientInit(err.to_string()))
1119}
1120
1121#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1122fn configure_native_client_builder(
1123    builder: reqwest::blocking::ClientBuilder,
1124) -> Result<reqwest::blocking::ClientBuilder, HttpError> {
1125    #[cfg(target_os = "android")]
1126    {
1127        Ok(builder.tls_certs_only(android_root_certificates()?))
1128    }
1129
1130    #[cfg(not(target_os = "android"))]
1131    {
1132        Ok(builder)
1133    }
1134}
1135
1136#[cfg(all(target_os = "android", feature = "http-native"))]
1137fn android_root_certificates() -> Result<Vec<reqwest::Certificate>, HttpError> {
1138    certificates_from_der_chain(
1139        webpki_root_certs::TLS_SERVER_ROOT_CERTS
1140            .iter()
1141            .map(AsRef::as_ref),
1142    )
1143}
1144
1145#[cfg(any(
1146    all(test, not(target_arch = "wasm32"), feature = "http-native"),
1147    all(target_os = "android", feature = "http-native")
1148))]
1149fn certificates_from_der_chain<'a, I>(
1150    certificates: I,
1151) -> Result<Vec<reqwest::Certificate>, HttpError>
1152where
1153    I: IntoIterator<Item = &'a [u8]>,
1154{
1155    certificates
1156        .into_iter()
1157        .enumerate()
1158        .map(|(index, der)| {
1159            reqwest::Certificate::from_der(der).map_err(|err| {
1160                HttpError::ClientInit(format!(
1161                    "Failed to load TLS root certificate {index}: {err}"
1162                ))
1163            })
1164        })
1165        .collect()
1166}
1167
1168#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1169struct FetchBody {
1170    url: String,
1171    reader: web_sys::ReadableStreamDefaultReader,
1172    control: HttpControl,
1173    transferred: std::cell::Cell<u64>,
1174    total: Option<u64>,
1175}
1176
1177#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1178impl HttpBody for FetchBody {
1179    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
1180        use wasm_bindgen_futures::JsFuture;
1181
1182        Box::pin(async move {
1183            if self.control.is_cancelled() {
1184                let _ = self.reader.cancel();
1185                return Err(HttpError::Cancelled);
1186            }
1187            let result = JsFuture::from(self.reader.read()).await.map_err(|error| {
1188                HttpError::BodyReadFailed {
1189                    url: self.url.clone(),
1190                    message: format!("{error:?}"),
1191                }
1192            })?;
1193            let done = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("done"))
1194                .ok()
1195                .and_then(|value| value.as_bool())
1196                .unwrap_or(true);
1197            if done {
1198                return Ok(None);
1199            }
1200            let value = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("value"))
1201                .map_err(|error| HttpError::BodyReadFailed {
1202                    url: self.url.clone(),
1203                    message: format!("{error:?}"),
1204                })?;
1205            let chunk = js_sys::Uint8Array::new(&value).to_vec();
1206            self.transferred
1207                .set(self.transferred.get() + chunk.len() as u64);
1208            self.control.report(HttpProgress {
1209                transferred: self.transferred.get(),
1210                total: self.total,
1211            });
1212            Ok(Some(chunk))
1213        })
1214    }
1215}
1216
1217#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1218async fn send_web(request: &HttpRequest, control: HttpControl) -> Result<HttpResponse, HttpError> {
1219    use wasm_bindgen::JsCast;
1220    use wasm_bindgen_futures::JsFuture;
1221    use web_sys::{Request, RequestInit, RequestMode, Response};
1222
1223    if control.is_cancelled() {
1224        return Err(HttpError::Cancelled);
1225    }
1226    let options = RequestInit::new();
1227    options.set_method(request.method.name());
1228    options.set_mode(RequestMode::Cors);
1229    if let Some(body) = &request.body {
1230        options.set_body(&js_sys::Uint8Array::from(body.as_slice()).into());
1231    }
1232
1233    let fetch_request =
1234        Request::new_with_str_and_init(&request.url, &options).map_err(|error| {
1235            HttpError::RequestFailed {
1236                url: request.url.clone(),
1237                message: format!("{error:?}"),
1238            }
1239        })?;
1240    let headers = fetch_request.headers();
1241    for (name, value) in &request.headers {
1242        headers
1243            .set(name, value)
1244            .map_err(|error| HttpError::RequestFailed {
1245                url: request.url.clone(),
1246                message: format!("{error:?}"),
1247            })?;
1248    }
1249    if let Some(offset) = request.resume_from {
1250        headers
1251            .set("Range", &format!("bytes={offset}-"))
1252            .map_err(|error| HttpError::RequestFailed {
1253                url: request.url.clone(),
1254                message: format!("{error:?}"),
1255            })?;
1256    }
1257
1258    let window = web_sys::window().ok_or(HttpError::NoWindow)?;
1259    let value = JsFuture::from(window.fetch_with_request(&fetch_request))
1260        .await
1261        .map_err(|error| HttpError::RequestFailed {
1262            url: request.url.clone(),
1263            message: format!("{error:?}"),
1264        })?;
1265    let response: Response = value.dyn_into().map_err(|_| HttpError::InvalidResponse {
1266        url: request.url.clone(),
1267        message: "the browser answered with something that is not a Response".to_string(),
1268    })?;
1269
1270    let status = response.status();
1271    let mut header_pairs = Vec::new();
1272    let entries = js_sys::try_iter(&response.headers()).ok().flatten();
1273    if let Some(entries) = entries {
1274        for entry in entries.flatten() {
1275            let pair = js_sys::Array::from(&entry);
1276            if pair.length() >= 2 {
1277                let name = pair.get(0).as_string().unwrap_or_default();
1278                let value = pair.get(1).as_string().unwrap_or_default();
1279                header_pairs.push((name.to_ascii_lowercase(), value));
1280            }
1281        }
1282    }
1283    let total = header_pairs
1284        .iter()
1285        .find(|(name, _)| name == "content-length")
1286        .and_then(|(_, value)| value.parse::<u64>().ok());
1287    let resumed = request.resume_from.is_some() && status == 206;
1288
1289    let body = response.body().ok_or_else(|| HttpError::InvalidResponse {
1290        url: request.url.clone(),
1291        message: "the response carries no body".to_string(),
1292    })?;
1293    let reader: web_sys::ReadableStreamDefaultReader =
1294        body.get_reader()
1295            .dyn_into()
1296            .map_err(|_| HttpError::InvalidResponse {
1297                url: request.url.clone(),
1298                message: "the response body cannot be read in chunks".to_string(),
1299            })?;
1300
1301    Ok(HttpResponse::new(
1302        request.url.clone(),
1303        status,
1304        http_body_ref(FetchBody {
1305            url: request.url.clone(),
1306            reader,
1307            control,
1308            transferred: std::cell::Cell::new(0),
1309            total,
1310        }),
1311    )
1312    .with_headers(header_pairs)
1313    .with_content_length(total)
1314    .with_resumed(resumed))
1315}
1316
1317#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
1318async fn send_web(request: &HttpRequest, _control: HttpControl) -> Result<HttpResponse, HttpError> {
1319    let _ = request;
1320    Err(HttpError::UnsupportedFeature {
1321        operation: "web HTTP requests",
1322        feature: "web-http",
1323    })
1324}
1325
1326/// The platform's HTTP client.
1327///
1328/// A native transfer fails when the response, or the next piece of its body,
1329/// does not arrive within 60 seconds; a body that keeps arriving runs as long
1330/// as it needs. A socket left open by a phone that slept or lost its network
1331/// therefore ends in an error, and [`HttpClient::download_to`] resumes from the
1332/// bytes already on disk at the next attempt.
1333pub fn default_http_client() -> HttpClientRef {
1334    Arc::new(DefaultHttpClient::new())
1335}
1336
1337/// The HTTP client a composable reads. It is the platform client unless a composition local
1338/// provider above it supplies another one.
1339pub fn local_http_client() -> CompositionLocal<HttpClientRef> {
1340    crate::composition_locals::cached_local(
1341        |locals| &locals.http,
1342        || compositionLocalOfWithPolicy(default_http_client, Arc::ptr_eq),
1343    )
1344}
1345
1346#[cfg(test)]
1347#[path = "tests/http_tests.rs"]
1348mod tests;