invoist 1.0.1

Official Rust client for the Invoist API: extract invoices, read a household's bills, verify webhooks.
Documentation
use std::path::Path;

use bytes::Bytes;

/// A file to upload: its bytes, a filename, and optionally a media type.
///
/// The bytes are held in memory (cheaply cloned), so a retried upload sends
/// exactly the same file again.
///
/// ```
/// use invoist::UploadFile;
///
/// let file = UploadFile::new("invoice.pdf", b"%PDF-1.7 ...".to_vec());
/// assert_eq!(file.content_type(), "application/pdf");
///
/// let scan = UploadFile::new("scan", vec![0xff, 0xd8, 0xff]).with_content_type("image/jpeg");
/// assert_eq!(scan.content_type(), "image/jpeg");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UploadFile {
    filename: String,
    data: Bytes,
    content_type: Option<String>,
}

impl UploadFile {
    /// A file from bytes already in memory. The media type is guessed from
    /// the filename's extension (`.pdf`, `.png`, `.jpg`/`.jpeg`) unless set
    /// with [`with_content_type`](UploadFile::with_content_type).
    pub fn new(filename: impl Into<String>, data: impl Into<Bytes>) -> Self {
        UploadFile {
            filename: filename.into(),
            data: data.into(),
            content_type: None,
        }
    }

    /// Reads a file from disk, named after its last path component.
    ///
    /// This blocks while reading; in async code prefer
    /// [`from_path_async`](UploadFile::from_path_async).
    pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
        let path = path.as_ref();
        let data = std::fs::read(path)?;
        Ok(UploadFile::new(file_name(path), data))
    }

    /// Reads a file from disk without blocking the async runtime.
    pub async fn from_path_async(path: impl AsRef<Path>) -> std::io::Result<Self> {
        let path = path.as_ref();
        let data = tokio::fs::read(path).await?;
        Ok(UploadFile::new(file_name(path), data))
    }

    /// Sets the media type explicitly, such as `application/pdf`.
    pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
        self.content_type = Some(content_type.into());
        self
    }

    /// The filename sent with the upload.
    pub fn filename(&self) -> &str {
        &self.filename
    }

    /// The file's bytes.
    pub fn data(&self) -> &Bytes {
        &self.data
    }

    /// The media type sent with the upload: the one set explicitly, or the
    /// one the filename suggests, or `application/octet-stream`.
    pub fn content_type(&self) -> &str {
        if let Some(ct) = &self.content_type {
            return ct;
        }
        let ext = self
            .filename
            .rsplit_once('.')
            .map(|(_, ext)| ext.to_ascii_lowercase());
        match ext.as_deref() {
            Some("pdf") => "application/pdf",
            Some("png") => "image/png",
            Some("jpg" | "jpeg") => "image/jpeg",
            _ => "application/octet-stream",
        }
    }
}

fn file_name(path: &Path) -> String {
    path.file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "upload".to_string())
}

/// A downloaded file: its bytes exactly as uploaded, and its media type.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileDownload {
    /// The file's bytes.
    pub bytes: Bytes,
    /// The stored media type (`Content-Type`), such as `application/pdf`.
    pub content_type: Option<String>,
}

impl FileDownload {
    /// The file's bytes, consuming the download.
    pub fn into_bytes(self) -> Bytes {
        self.bytes
    }
}