//! Storage-neutral attachment persistence interfaces. use std::error::Error; use std::fmt; use std::future::Future; use std::pin::Pin; use std::time::Duration; use serde::Deserialize; use serde::Serialize; /// Result returned by [`AttachmentStore`] operations. pub type AttachmentStoreResult = Result; /// Future returned by [`AttachmentStore::upload`]. pub type UploadFuture<'a> = Pin> + Send + 'a>>; /// Future returned by [`AttachmentStore::resolve`]. pub type ResolveFuture<'a> = Pin> + Send + 'a>>; /// Uploads attachments and resolves metadata for stored files. /// /// Implementations choose whether uploaded attachments remain inline or become file references. /// File references returned by [`AttachmentStore::upload`] must be resolvable by /// [`AttachmentStore::resolve`]. pub trait AttachmentStore: Send + Sync { /// Uploads an attachment and returns the representation callers should retain. fn upload(&self, request: UploadRequest) -> UploadFuture<'_>; /// Resolves metadata and, when requested, a fresh URL for a stored attachment. fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a>; } /// Attachment data supplied to [`AttachmentStore::upload`]. #[derive(Clone, Eq, PartialEq)] pub struct UploadRequest { /// Optional name associated with the attachment. pub file_name: Option, /// Attachment bytes to persist. pub data: Vec, } impl fmt::Debug for UploadRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("UploadRequest") .field("file_name", &self.file_name) .field("data", &"") .finish() } } /// Parameters supplied to [`AttachmentStore::resolve`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ResolveRequest<'a> { /// Identifier assigned by the attachment store. pub file_id: &'a str, /// Minimum remaining lifetime required for the returned file URL. /// /// When absent, implementations must omit the file URL and may return cached metadata. When /// present, implementations must return a URL that remains valid for at least this duration /// after the resolve operation completes. pub download_url_ttl: Option, } /// Metadata used to validate and interpret an attachment. #[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct AttachmentMetadata { /// Name associated with the attachment. #[serde(default, skip_serializing_if = "Option::is_none")] pub file_name: Option, /// MD5 digest of the attachment bytes. #[serde(default, skip_serializing_if = "Option::is_none")] pub digest: Option, /// Length of the attachment byte payload. #[serde(default, skip_serializing_if = "Option::is_none")] pub size_bytes: Option, /// IANA media type used to interpret the attachment bytes. #[serde(default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, /// Metadata specific to the attachment's format. #[serde(default, skip_serializing_if = "Option::is_none")] pub format_specific: Option, /// URL for reading the attachment bytes when requested during resolution. /// /// Implementations must omit this field when [`ResolveRequest::download_url_ttl`] is absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub file_url: Option, } impl fmt::Debug for AttachmentMetadata { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let file_url = self.file_url.as_ref().map(|_| ""); formatter .debug_struct("AttachmentMetadata") .field("file_name", &self.file_name) .field("digest", &self.digest) .field("size_bytes", &self.size_bytes) .field("mime_type", &self.mime_type) .field("format_specific", &self.format_specific) .field("file_url", &file_url) .finish() } } /// Metadata specific to an attachment format. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum FormatSpecificMetadata { /// Metadata for an image attachment. Image(ImageMetadata), } /// Metadata specific to an image attachment. #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct ImageMetadata { /// Image width in pixels. #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, /// Image height in pixels. #[serde(default, skip_serializing_if = "Option::is_none")] pub height: Option, } /// Representation callers should retain after uploading an attachment. #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] pub enum UploadResult { /// Attachment data remains inline. Inline { /// Original attachment bytes passed to [`AttachmentStore::upload`]. bytes: Vec, }, /// Attachment data is available through an external file reference. File { /// Identifier assigned by the attachment store. file_id: String, }, } impl fmt::Debug for UploadResult { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Inline { .. } => formatter .debug_struct("Inline") .field("bytes", &"") .finish(), Self::File { file_id } => formatter .debug_struct("File") .field("file_id", file_id) .finish(), } } } /// Leaves attachments inline instead of storing them. pub struct InlineAttachmentStore; impl AttachmentStore for InlineAttachmentStore { fn upload(&self, request: UploadRequest) -> UploadFuture<'_> { Box::pin(async move { Ok(UploadResult::Inline { bytes: request.data, }) }) } fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a> { let file_id = request.file_id; Box::pin(async move { Err(AttachmentStoreError::new( AttachmentStoreErrorKind::NotFound, format!("attachment `{file_id}` was not found"), )) }) } } /// Category of failure returned by an [`AttachmentStore`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AttachmentStoreErrorKind { /// The requested attachment could not be resolved. NotFound, /// The attachment data or metadata is invalid. InvalidAttachment, /// The backing store could not complete the operation. Backend, } /// Error returned by an [`AttachmentStore`] implementation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct AttachmentStoreError { kind: AttachmentStoreErrorKind, message: String, } impl AttachmentStoreError { /// Creates an error without exposing backend-specific error types. pub fn new(kind: AttachmentStoreErrorKind, message: impl Into) -> Self { Self { kind, message: message.into(), } } /// Returns the category callers can use to handle this error. #[must_use] pub const fn kind(&self) -> AttachmentStoreErrorKind { self.kind } } impl fmt::Display for AttachmentStoreError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { self.message.fmt(formatter) } } impl Error for AttachmentStoreError {} #[cfg(test)] #[path = "lib_tests.rs"] mod tests;