File size: 7,774 Bytes
afa0cbf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | //! 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<T> = Result<T, AttachmentStoreError>;
/// Future returned by [`AttachmentStore::upload`].
pub type UploadFuture<'a> =
Pin<Box<dyn Future<Output = AttachmentStoreResult<UploadResult>> + Send + 'a>>;
/// Future returned by [`AttachmentStore::resolve`].
pub type ResolveFuture<'a> =
Pin<Box<dyn Future<Output = AttachmentStoreResult<AttachmentMetadata>> + 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<String>,
/// Attachment bytes to persist.
pub data: Vec<u8>,
}
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", &"<redacted>")
.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<Duration>,
}
/// 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<String>,
/// MD5 digest of the attachment bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
/// Length of the attachment byte payload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
/// IANA media type used to interpret the attachment bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// Metadata specific to the attachment's format.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_specific: Option<FormatSpecificMetadata>,
/// 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<String>,
}
impl fmt::Debug for AttachmentMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let file_url = self.file_url.as_ref().map(|_| "<redacted>");
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<u32>,
/// Image height in pixels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
/// 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<u8>,
},
/// 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", &"<redacted>")
.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<String>) -> 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;
|