Skip to main content

timvw_rust_runtime/
upload.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::ops::{Bound, RangeBounds};
4use std::sync::{Arc, Mutex, PoisonError};
5
6use bytes::{Bytes, BytesMut};
7use futures_core::Stream;
8use futures_util::{future, stream, StreamExt, TryStreamExt};
9
10use crate::maybe::BoxStream;
11use crate::{Body, BoxError, Error, MaybeSend, MaybeSync};
12
13type Chunks = BoxStream<'static, Result<Bytes, BoxError>>;
14type Once = Arc<Mutex<Option<Chunks>>>;
15#[cfg(not(target_family = "wasm"))]
16type Factory = Arc<dyn Fn() -> Chunks + Send + Sync>;
17#[cfg(target_family = "wasm")]
18type Factory = Arc<dyn Fn() -> Chunks>;
19
20/// Something to upload: a file, bytes, a reader or a stream, with a file name and a media type.
21///
22/// An `Upload` becomes a request body (`RequestBuilder::body(upload)`) or a multipart part
23/// ([`Part::upload`](crate::multipart::Part::upload)). Generated clients take one for every file parameter, and a
24/// `PathBuf`, `&Path`, `Vec<u8>` or `Bytes` converts into one.
25///
26/// | Source | Length | Retried |
27/// |---|---|---|
28/// | [`Upload::bytes`] (`Bytes`, `Vec<u8>`, `&'static [u8]`) | known | yes, without a copy |
29#[cfg_attr(
30    all(feature = "fs", not(target_family = "wasm")),
31    doc = "| [`Upload::path`], [`Upload::file`] | from the file's metadata | yes: the file is opened again |"
32)]
33#[cfg_attr(
34    not(all(feature = "fs", not(target_family = "wasm"))),
35    doc = "| `Upload::path`, `Upload::file` (feature `fs`, not on WebAssembly) | from the file's metadata | yes: the file is opened again |"
36)]
37/// | [`Upload::from_fn`] | [`Upload::len`] | yes: the function makes a fresh stream |
38#[cfg_attr(
39    all(feature = "fs", not(target_family = "wasm")),
40    doc = "| [`Upload::reader`], [`Upload::stream`] | [`Upload::len`] | no, unless [`Upload::buffer_for_retry`] holds it all |"
41)]
42#[cfg_attr(
43    not(all(feature = "fs", not(target_family = "wasm"))),
44    doc = "| `Upload::reader` (feature `fs`), [`Upload::stream`] | [`Upload::len`] | no, unless [`Upload::buffer_for_retry`] holds it all |"
45)]
46///
47/// When the length is known, the body (or the whole multipart form) is sent with a `Content-Length`, not chunked.
48#[cfg_attr(
49    all(feature = "fs", not(target_family = "wasm")),
50    doc = "A path's length is read by [`Upload::resolve`], which generated clients call; [`Upload::file`] is"
51)]
52#[cfg_attr(
53    not(all(feature = "fs", not(target_family = "wasm"))),
54    doc = "A path's length is read by [`Upload::resolve`], which generated clients call; `Upload::file` is"
55)]
56/// `Upload::path(path).resolve()`.
57///
58/// ```no_run
59/// # #[cfg(all(feature = "fs", not(target_family = "wasm")))]
60/// # async fn demo() -> Result<(), timvw_rust_runtime::Error> {
61/// use timvw_rust_runtime::Upload;
62///
63/// // Streamed from disk, named `photo.png`, `image/png` guessed from the extension.
64/// let file = Upload::file("data/photo.png").await?;
65/// // Bytes held in memory, with a name and a media type of your own.
66/// let image = Upload::bytes(vec![0x89, b'P', b'N', b'G']).file_name("cat.png").mime("image/png");
67/// // The second 5 MiB of a file, for an API that takes a large file in parts.
68/// let part = Upload::path("data/big.bin").slice(5 << 20..10 << 20).resolve().await?;
69/// # Ok(())
70/// # }
71/// ```
72#[derive(Clone)]
73pub struct Upload {
74    source: Source,
75    pub(crate) file_name: Option<Cow<'static, str>>,
76    pub(crate) mime: Option<Cow<'static, str>>,
77    /// The length of the whole source, before `start`/`end`, when known.
78    total: Option<u64>,
79    start: u64,
80    end: Option<u64>,
81    buffer_limit: Option<usize>,
82}
83
84#[derive(Clone)]
85enum Source {
86    Bytes(Bytes),
87    #[cfg(feature = "fs")]
88    File(Arc<std::path::Path>),
89    Once(Once),
90    Factory(Factory),
91}
92
93impl Upload {
94    /// Bytes held in memory: their length is known, and a retry sends them again without a copy.
95    pub fn bytes(bytes: impl Into<Bytes>) -> Self {
96        let bytes = bytes.into();
97        let total = Some(bytes.len() as u64);
98        Self::new(Source::Bytes(bytes), total)
99    }
100
101    /// A stream of chunks, read once: a retry cannot send it again unless [`Upload::buffer_for_retry`] holds it.
102    /// Its length is unknown (the body is chunked) unless set with [`Upload::len`]. The stream must be `Send`, except
103    /// on WebAssembly ([`MaybeSend`]).
104    pub fn stream<S, B, E>(stream: S) -> Self
105    where
106        S: Stream<Item = Result<B, E>> + MaybeSend + 'static,
107        B: Into<Bytes> + 'static,
108        E: Into<BoxError> + 'static,
109    {
110        let chunks: Chunks = Box::pin(stream.map_ok(Into::into).map_err(Into::into));
111        Self::new(Source::Once(Arc::new(Mutex::new(Some(chunks)))), None)
112    }
113
114    /// A stream that `make` starts afresh for every attempt, so a retry sends it again. Its length is unknown (the
115    /// body is chunked) unless set with [`Upload::len`]; every stream `make` returns must then have that length.
116    /// `make` must be `Send` and `Sync`, and its streams `Send`, except on WebAssembly ([`MaybeSend`], [`MaybeSync`]).
117    pub fn from_fn<F, S, B, E>(make: F) -> Self
118    where
119        F: Fn() -> S + MaybeSend + MaybeSync + 'static,
120        S: Stream<Item = Result<B, E>> + MaybeSend + 'static,
121        B: Into<Bytes> + 'static,
122        E: Into<BoxError> + 'static,
123    {
124        let factory: Factory =
125            Arc::new(move || Box::pin(make().map_ok(Into::into).map_err(Into::into)));
126        Self::new(Source::Factory(factory), None)
127    }
128
129    fn new(source: Source, total: Option<u64>) -> Self {
130        Self {
131            source,
132            file_name: None,
133            mime: None,
134            total,
135            start: 0,
136            end: None,
137            buffer_limit: None,
138        }
139    }
140
141    /// Sets the file name (multipart `filename`). A path's is its last component; a multipart part of an upload
142    /// without one is named `file`.
143    pub fn file_name(mut self, file_name: impl Into<Cow<'static, str>>) -> Self {
144        self.file_name = Some(file_name.into());
145        self
146    }
147
148    /// Sets the media type (the `Content-Type` of a multipart part), such as `image/png`. A path's is guessed from its
149    /// extension. A value that is not a valid header value is sent as `application/octet-stream`.
150    pub fn mime(mut self, mime: impl Into<Cow<'static, str>>) -> Self {
151        self.mime = Some(mime.into());
152        self
153    }
154
155    /// The file name: the one set with [`Upload::file_name`], or a path's last component. `None` for bytes, readers
156    /// and streams without one.
157    pub fn name(&self) -> Option<&str> {
158        self.file_name.as_deref()
159    }
160
161    /// The media type: the one set with [`Upload::mime`], or guessed from a path's extension.
162    pub fn media_type(&self) -> Option<&str> {
163        self.mime.as_deref()
164    }
165
166    /// Sets the length of the source in bytes, for a stream or a reader: the body is then sent with a
167    /// `Content-Length`, and the stream must have exactly that many bytes.
168    pub fn len(mut self, len: u64) -> Self {
169        self.total = Some(len);
170        self
171    }
172
173    /// Only the bytes in `range` of the source (of the current slice, when sliced again): a file is read from the
174    /// start of the range (a seek, no copy), bytes are sliced without a copy, a stream skips to it. For APIs that take
175    /// a large file in parts.
176    pub fn slice(mut self, range: impl RangeBounds<u64>) -> Self {
177        let from = match range.start_bound() {
178            Bound::Included(&start) => start,
179            Bound::Excluded(&start) => start.saturating_add(1),
180            Bound::Unbounded => 0,
181        };
182        let to = match range.end_bound() {
183            Bound::Included(&end) => Some(end.saturating_add(1)),
184            Bound::Excluded(&end) => Some(end),
185            Bound::Unbounded => None,
186        };
187        let start = self.start.saturating_add(from);
188        let end = to.map(|to| self.start.saturating_add(to));
189        self.end = match (self.end, end) {
190            (Some(old), Some(new)) => Some(old.min(new)),
191            (old, new) => old.or(new),
192        };
193        self.start = match self.end {
194            Some(end) => start.min(end),
195            None => start,
196        };
197        self
198    }
199
200    /// Lets a retry send a stream or a reader again: [`Upload::resolve`] reads it into memory, up to `limit` bytes.
201    /// A stream that ends within the limit becomes bytes (replayable, with a known length); a longer one is sent once,
202    /// as if this were not set. Without `resolve` (which generated clients call) this has no effect.
203    pub fn buffer_for_retry(mut self, limit: usize) -> Self {
204        self.buffer_limit = Some(limit);
205        self
206    }
207
208    /// The number of bytes the upload sends, when known: bytes, a resolved path, or a stream given a
209    /// [`len`](Upload::len).
210    pub fn content_length(&self) -> Option<u64> {
211        self.total.map(|total| {
212            let end = self.end.unwrap_or(total).min(total);
213            end.saturating_sub(self.start.min(total))
214        })
215    }
216
217    /// Whether a retry can send the upload again: bytes, a path and [`Upload::from_fn`] can, a stream or a reader
218    /// cannot (unless [`Upload::buffer_for_retry`] held it all when it was resolved).
219    pub fn is_replayable(&self) -> bool {
220        !matches!(self.source, Source::Once(_))
221    }
222
223    /// Gets the upload ready to send: reads a path's length from its metadata (an error when the file does not
224    /// exist), and reads a stream with [`Upload::buffer_for_retry`] into memory. Other uploads are returned as they
225    /// are.
226    pub async fn resolve(mut self) -> Result<Self, Error> {
227        #[cfg(all(feature = "fs", target_family = "wasm"))]
228        if let Source::File(path) = &self.source {
229            return Err(Error::io(no_file_system(path)));
230        }
231        #[cfg(all(feature = "fs", not(target_family = "wasm")))]
232        if let Source::File(path) = &self.source {
233            if self.total.is_none() {
234                let metadata = tokio::fs::metadata(&**path)
235                    .await
236                    .map_err(|error| Error::io(with_path(error, path)))?;
237                if !metadata.is_file() {
238                    return Err(Error::io(std::io::Error::other(format!(
239                        "{}: not a file",
240                        path.display()
241                    ))));
242                }
243                self.total = Some(metadata.len());
244            }
245            return Ok(self);
246        }
247        let Some(limit) = self.buffer_limit else {
248            return Ok(self);
249        };
250        let Source::Once(once) = &self.source else {
251            return Ok(self);
252        };
253        let taken = once.lock().unwrap_or_else(PoisonError::into_inner).take();
254        let Some(mut chunks) = taken else {
255            return Ok(self);
256        };
257        let mut buffer = BytesMut::new();
258        while let Some(chunk) = chunks.next().await {
259            let chunk = chunk.map_err(Error::io)?;
260            if buffer.len() + chunk.len() > limit {
261                // Too long to hold: sent once, starting with what was read.
262                let read = stream::iter([Ok(buffer.freeze()), Ok(chunk)]).chain(chunks);
263                let chunks: Chunks = Box::pin(read);
264                self.source = Source::Once(Arc::new(Mutex::new(Some(chunks))));
265                return Ok(self);
266            }
267            buffer.extend_from_slice(&chunk);
268        }
269        self.total = Some(buffer.len() as u64);
270        self.source = Source::Bytes(buffer.freeze());
271        Ok(self)
272    }
273}
274
275#[cfg(feature = "fs")]
276impl Upload {
277    /// A file, streamed from disk with tokio and opened again for each attempt, so a retry sends it again.
278    ///
279    /// The file name is the path's last component, the media type is guessed from its extension. Nothing is read
280    #[cfg_attr(
281        not(target_family = "wasm"),
282        doc = "until the request is sent; [`Upload::resolve`] (or [`Upload::file`]) reads the length, so the body is sent with"
283    )]
284    #[cfg_attr(
285        target_family = "wasm",
286        doc = "until the request is sent; [`Upload::resolve`] (or `Upload::file`) reads the length, so the body is sent with"
287    )]
288    /// a `Content-Length`, and fails early when the file does not exist.
289    ///
290    /// On WebAssembly there is no file system: the upload fails ([`Upload::resolve`], or the request) with an I/O
291    /// error of kind `Unsupported`. The function is there so that code taking a `PathBuf` (a generated model with a
292    /// file variant) compiles; send bytes or a stream instead.
293    pub fn path(path: impl Into<std::path::PathBuf>) -> Self {
294        let path: Arc<std::path::Path> = Arc::from(path.into());
295        let file_name = path
296            .file_name()
297            .map(|name| Cow::Owned(name.to_string_lossy().into_owned()));
298        let mime = mime_guess::from_path(&path)
299            .first()
300            .map(|mime| Cow::Owned(mime.essence_str().to_owned()));
301        let mut upload = Self::new(Source::File(path), None);
302        upload.file_name = file_name;
303        upload.mime = mime;
304        upload
305    }
306}
307
308#[cfg(all(feature = "fs", not(target_family = "wasm")))]
309impl Upload {
310    /// [`Upload::path`], resolved: its length read from the file's metadata, an error when there is no such file.
311    pub async fn file(path: impl Into<std::path::PathBuf>) -> Result<Self, Error> {
312        Self::path(path).resolve().await
313    }
314
315    /// A tokio reader, read once in chunks: a retry cannot send it again unless [`Upload::buffer_for_retry`] holds it.
316    /// Its length is unknown (the body is chunked) unless set with [`Upload::len`].
317    pub fn reader<R>(reader: R) -> Self
318    where
319        R: tokio::io::AsyncRead + Send + 'static,
320    {
321        Self::stream(read_chunks(Box::pin(reader), None))
322    }
323}
324
325/// The chunks of a reader, up to its end; fewer bytes than `expected` is an error.
326#[cfg(all(feature = "fs", not(target_family = "wasm")))]
327fn read_chunks<R>(
328    reader: R,
329    expected: Option<u64>,
330) -> impl Stream<Item = std::io::Result<Bytes>> + Send + 'static
331where
332    R: tokio::io::AsyncRead + Send + Unpin + 'static,
333{
334    use tokio::io::AsyncReadExt;
335    const CHUNK: usize = 64 * 1024;
336    stream::try_unfold((reader, 0u64), move |(mut reader, read)| async move {
337        let mut buffer = BytesMut::with_capacity(CHUNK);
338        let n = reader.read_buf(&mut buffer).await?;
339        if n == 0 {
340            return match expected {
341                Some(expected) if read < expected => Err(std::io::Error::new(
342                    std::io::ErrorKind::UnexpectedEof,
343                    format!("the upload ended after {read} of its {expected} bytes"),
344                )),
345                _ => Ok(None),
346            };
347        }
348        Ok(Some((buffer.freeze(), (reader, read + n as u64))))
349    })
350}
351
352/// The error of a file upload on WebAssembly, where there is no file system.
353#[cfg(all(feature = "fs", target_family = "wasm"))]
354fn no_file_system(path: &std::path::Path) -> std::io::Error {
355    std::io::Error::new(
356        std::io::ErrorKind::Unsupported,
357        format!(
358            "{}: no file system on WebAssembly: upload bytes or a stream instead",
359            path.display()
360        ),
361    )
362}
363
364#[cfg(all(feature = "fs", not(target_family = "wasm")))]
365fn with_path(error: std::io::Error, path: &std::path::Path) -> std::io::Error {
366    std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
367}
368
369/// The bytes of a file from `start` to `end`, `len` of them when known.
370#[cfg(all(feature = "fs", not(target_family = "wasm")))]
371fn file_chunks(
372    path: Arc<std::path::Path>,
373    start: u64,
374    end: Option<u64>,
375    len: Option<u64>,
376) -> Chunks {
377    use tokio::io::{AsyncReadExt, AsyncSeekExt};
378    let open = async move {
379        let mut file = tokio::fs::File::open(&*path)
380            .await
381            .map_err(|error| with_path(error, &path))?;
382        if start > 0 {
383            file.seek(std::io::SeekFrom::Start(start)).await?;
384        }
385        let limit = len
386            .or_else(|| end.map(|end| end.saturating_sub(start)))
387            .unwrap_or(u64::MAX);
388        Ok::<_, std::io::Error>(read_chunks(file.take(limit), len))
389    };
390    Box::pin(stream::once(open).try_flatten().map_err(BoxError::from))
391}
392
393/// The bytes of a stream from `start` to `end`.
394fn range(chunks: Chunks, start: u64, end: Option<u64>) -> Chunks {
395    if start == 0 && end.is_none() {
396        return chunks;
397    }
398    Box::pin(stream::unfold(
399        (chunks, 0u64),
400        move |(mut chunks, mut position)| async move {
401            loop {
402                if end.is_some_and(|end| position >= end) {
403                    return None;
404                }
405                let chunk = match chunks.next().await? {
406                    Ok(chunk) => chunk,
407                    Err(error) => return Some((Err(error), (chunks, position))),
408                };
409                let first = position;
410                position += chunk.len() as u64;
411                let from = start.max(first);
412                let to = end.map_or(position, |end| end.min(position));
413                if from < to {
414                    let slice = chunk.slice((from - first) as usize..(to - first) as usize);
415                    return Some((Ok(slice), (chunks, position)));
416                }
417            }
418        },
419    ))
420}
421
422/// The stream of a one-shot source, taken when it is first read; an error when it was taken before.
423fn take_once(once: Once) -> Chunks {
424    let taken = stream::once(future::lazy(move |_| {
425        once.lock().unwrap_or_else(PoisonError::into_inner).take()
426    }));
427    Box::pin(taken.flat_map(|chunks| match chunks {
428        Some(chunks) => chunks,
429        None => Box::pin(stream::once(future::ready(Err(BoxError::from(
430            "this upload is a stream that was sent already: a stream is read once",
431        ))))) as Chunks,
432    }))
433}
434
435impl From<Upload> for Body {
436    fn from(upload: Upload) -> Self {
437        let len = upload.content_length();
438        let (start, end) = (upload.start, upload.end);
439        match upload.source {
440            Source::Bytes(bytes) => {
441                let total = bytes.len() as u64;
442                let to = end.unwrap_or(total).min(total);
443                let from = start.min(to);
444                Body::from(bytes.slice(from as usize..to as usize))
445            }
446            #[cfg(all(feature = "fs", target_family = "wasm"))]
447            Source::File(path) => Body::wrap_stream(stream::once(future::ready(Err::<Bytes, _>(
448                no_file_system(&path),
449            )))),
450            #[cfg(all(feature = "fs", not(target_family = "wasm")))]
451            Source::File(path) => Body::replay(
452                Arc::new(move || {
453                    Body::sized_stream(file_chunks(path.clone(), start, end, len), len)
454                }),
455                len,
456            ),
457            Source::Factory(make) => Body::replay(
458                Arc::new(move || Body::sized_stream(range(make(), start, end), len)),
459                len,
460            ),
461            Source::Once(once) => Body::sized_stream(range(take_once(once), start, end), len),
462        }
463    }
464}
465
466impl From<Bytes> for Upload {
467    fn from(bytes: Bytes) -> Self {
468        Self::bytes(bytes)
469    }
470}
471
472impl From<Vec<u8>> for Upload {
473    fn from(bytes: Vec<u8>) -> Self {
474        Self::bytes(bytes)
475    }
476}
477
478impl From<&'static [u8]> for Upload {
479    fn from(bytes: &'static [u8]) -> Self {
480        Self::bytes(Bytes::from_static(bytes))
481    }
482}
483
484impl From<&Upload> for Upload {
485    fn from(upload: &Upload) -> Self {
486        upload.clone()
487    }
488}
489
490#[cfg(feature = "fs")]
491impl From<std::path::PathBuf> for Upload {
492    fn from(path: std::path::PathBuf) -> Self {
493        Self::path(path)
494    }
495}
496
497#[cfg(feature = "fs")]
498impl From<&std::path::PathBuf> for Upload {
499    fn from(path: &std::path::PathBuf) -> Self {
500        Self::path(path.clone())
501    }
502}
503
504#[cfg(feature = "fs")]
505impl From<&std::path::Path> for Upload {
506    fn from(path: &std::path::Path) -> Self {
507        Self::path(path)
508    }
509}
510
511/// Two uploads are equal when they send the same thing: equal bytes, the same path, or the same stream or
512/// [`from_fn`](Upload::from_fn) function (an upload and its clones), with the same file name, media type, length,
513/// slice and [`buffer_for_retry`](Upload::buffer_for_retry) limit. Two streams are never equal to each other, even
514/// with the same contents: they are not read to compare them. A path is compared as a path, not by the file's contents,
515/// and whether it was [`resolve`](Upload::resolve)d does not matter.
516///
517/// It lets a model that holds an upload (a generated multipart union with a file variant) derive `PartialEq`.
518impl PartialEq for Upload {
519    fn eq(&self, other: &Self) -> bool {
520        let same_source = match (&self.source, &other.source) {
521            (Source::Bytes(a), Source::Bytes(b)) => a == b,
522            #[cfg(feature = "fs")]
523            (Source::File(a), Source::File(b)) => a == b,
524            (Source::Once(a), Source::Once(b)) => Arc::ptr_eq(a, b),
525            (Source::Factory(a), Source::Factory(b)) => {
526                std::ptr::addr_eq(Arc::as_ptr(a), Arc::as_ptr(b))
527            }
528            _ => false,
529        };
530        #[cfg(feature = "fs")]
531        let total = matches!(self.source, Source::File(_)) || self.total == other.total;
532        #[cfg(not(feature = "fs"))]
533        let total = self.total == other.total;
534        same_source
535            && total
536            && self.file_name == other.file_name
537            && self.mime == other.mime
538            && self.start == other.start
539            && self.end == other.end
540            && self.buffer_limit == other.buffer_limit
541    }
542}
543
544impl fmt::Debug for Upload {
545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546        let mut debug = f.debug_struct("Upload");
547        match &self.source {
548            Source::Bytes(bytes) => debug.field("bytes", &bytes.len()),
549            #[cfg(feature = "fs")]
550            Source::File(path) => debug.field("path", path),
551            Source::Once(_) => debug.field("stream", &true),
552            Source::Factory(_) => debug.field("from_fn", &true),
553        };
554        debug
555            .field("file_name", &self.file_name)
556            .field("mime", &self.mime)
557            .field("content_length", &self.content_length())
558            .finish_non_exhaustive()
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    async fn read(upload: Upload) -> Bytes {
567        Body::from(upload).collect_bytes().await.unwrap()
568    }
569
570    fn chunks(
571        parts: &[&'static str],
572    ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
573        stream::iter(
574            parts
575                .iter()
576                .map(|part| Ok(Bytes::from_static(part.as_bytes())))
577                .collect::<Vec<_>>(),
578        )
579    }
580
581    #[test]
582    fn uploads_are_equal_when_they_send_the_same_thing() {
583        let bytes = Upload::bytes(&b"abc"[..]).file_name("a.txt");
584        assert_eq!(bytes, bytes.clone());
585        assert_eq!(bytes, Upload::bytes(b"abc".to_vec()).file_name("a.txt"));
586        assert_ne!(bytes, Upload::bytes(&b"abc"[..]));
587        assert_ne!(bytes, bytes.clone().mime("text/plain"));
588        assert_ne!(bytes, bytes.clone().slice(1..));
589        let stream = Upload::stream(stream::iter([Ok::<_, std::io::Error>(&b"abc"[..])]));
590        assert_eq!(stream, stream.clone());
591        assert_ne!(
592            stream,
593            Upload::stream(stream::iter([Ok::<_, std::io::Error>(&b"abc"[..])]))
594        );
595        assert_ne!(stream, Upload::bytes(&b"abc"[..]));
596        let from_fn = Upload::from_fn(|| stream::iter([Ok::<_, std::io::Error>(&b"abc"[..])]));
597        assert_eq!(from_fn, from_fn.clone());
598        assert_ne!(from_fn, stream);
599    }
600
601    #[cfg(feature = "fs")]
602    #[tokio::test]
603    async fn paths_are_equal_as_paths() {
604        let file = tempfile::NamedTempFile::new().unwrap();
605        let path = Upload::path(file.path());
606        assert_eq!(path, Upload::from(file.path().to_path_buf()));
607        assert_eq!(path, path.clone().resolve().await.unwrap());
608        assert_ne!(path, Upload::path("other.txt"));
609    }
610
611    #[tokio::test]
612    async fn bytes_are_replayable_and_sliced_without_a_copy() {
613        let upload = Upload::bytes("hello world").slice(6..);
614        assert_eq!(upload.content_length(), Some(5));
615        let body = Body::from(upload);
616        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(5));
617        let copy = body.try_clone().unwrap();
618        assert_eq!(body.collect_bytes().await.unwrap(), "world");
619        assert_eq!(copy.collect_bytes().await.unwrap(), "world");
620        assert_eq!(
621            read(Upload::bytes("hello").slice(1..=2).slice(1..)).await,
622            "l"
623        );
624        assert_eq!(read(Upload::bytes("hello").slice(10..)).await, "");
625    }
626
627    #[tokio::test]
628    async fn a_stream_is_read_once() {
629        let upload = Upload::stream(chunks(&["ab", "cd"]));
630        assert!(!upload.is_replayable());
631        let copy = upload.clone();
632        let body = Body::from(upload);
633        assert!(body.try_clone().is_none());
634        assert_eq!(body.collect_bytes().await.unwrap(), "abcd");
635        assert!(Body::from(copy).collect_bytes().await.is_err());
636    }
637
638    #[tokio::test]
639    async fn a_stream_is_sliced_across_chunks() {
640        assert_eq!(
641            read(Upload::stream(chunks(&["ab", "cd", "ef"])).slice(1..5)).await,
642            "bcde"
643        );
644        let sized = Upload::stream(chunks(&["ab", "cd"])).len(4).slice(1..);
645        assert_eq!(sized.content_length(), Some(3));
646        assert_eq!(
647            http_body::Body::size_hint(&Body::from(sized)).exact(),
648            Some(3)
649        );
650    }
651
652    #[tokio::test]
653    async fn from_fn_is_replayed() {
654        let upload = Upload::from_fn(|| chunks(&["x", "y"])).len(2);
655        assert!(upload.is_replayable());
656        let body = Body::from(upload);
657        let copy = body.try_clone().unwrap();
658        assert_eq!(body.collect_bytes().await.unwrap(), "xy");
659        assert_eq!(copy.collect_bytes().await.unwrap(), "xy");
660    }
661
662    #[tokio::test]
663    async fn buffer_for_retry_holds_a_short_stream() {
664        let short = Upload::stream(chunks(&["ab", "cd"]))
665            .buffer_for_retry(4)
666            .resolve()
667            .await
668            .unwrap();
669        assert!(short.is_replayable());
670        assert_eq!(short.content_length(), Some(4));
671        assert_eq!(read(short).await, "abcd");
672
673        let long = Upload::stream(chunks(&["ab", "cd", "e"]))
674            .buffer_for_retry(4)
675            .resolve()
676            .await
677            .unwrap();
678        assert!(!long.is_replayable());
679        assert_eq!(long.content_length(), None);
680        assert_eq!(read(long).await, "abcde");
681    }
682
683    #[cfg(all(feature = "fs", not(target_family = "wasm")))]
684    #[tokio::test]
685    async fn a_file_is_resolved_reopened_and_sliced() {
686        let dir = tempfile::tempdir().unwrap();
687        let path = dir.path().join("notes.json");
688        std::fs::write(&path, "0123456789").unwrap();
689
690        let upload = Upload::path(&path);
691        assert_eq!(upload.file_name.as_deref(), Some("notes.json"));
692        assert_eq!(upload.mime.as_deref(), Some("application/json"));
693        assert_eq!(upload.content_length(), None);
694        let upload = upload.resolve().await.unwrap();
695        assert_eq!(upload.content_length(), Some(10));
696
697        let body = Body::from(upload.clone());
698        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
699        let copy = body.try_clone().unwrap();
700        assert_eq!(body.collect_bytes().await.unwrap(), "0123456789");
701        assert_eq!(copy.collect_bytes().await.unwrap(), "0123456789");
702
703        let slice = upload.slice(2..5);
704        assert_eq!(slice.content_length(), Some(3));
705        assert_eq!(read(slice.clone()).await, "234");
706        assert_eq!(read(slice.slice(1..)).await, "34");
707        let tail = Upload::file(&path).await.unwrap().slice(8..20);
708        assert_eq!(tail.content_length(), Some(2));
709        assert_eq!(read(tail).await, "89");
710    }
711
712    #[cfg(all(feature = "fs", not(target_family = "wasm")))]
713    #[tokio::test]
714    async fn a_missing_file_fails_to_resolve() {
715        let error = Upload::file("/no/such/file.bin").await.unwrap_err();
716        assert!(error.is_io(), "{error:?}");
717        assert!(error.to_string().contains("/no/such/file.bin"), "{error}");
718    }
719
720    #[cfg(all(feature = "fs", not(target_family = "wasm")))]
721    #[tokio::test]
722    async fn a_file_that_shrank_is_an_error() {
723        let dir = tempfile::tempdir().unwrap();
724        let path = dir.path().join("data.bin");
725        std::fs::write(&path, "0123456789").unwrap();
726        let upload = Upload::file(&path).await.unwrap();
727        std::fs::write(&path, "0123").unwrap();
728        assert!(Body::from(upload).collect_bytes().await.is_err());
729    }
730
731    #[cfg(all(feature = "fs", not(target_family = "wasm")))]
732    #[tokio::test]
733    async fn a_reader_is_read_once() {
734        let upload = Upload::reader(&b"from a reader"[..]);
735        assert!(!upload.is_replayable());
736        assert_eq!(read(upload).await, "from a reader");
737    }
738}