use std::{collections::BTreeMap, fmt};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use uuid::Uuid;
use crate::{detect_content_type, file_extension, sha256_hex, variant::Variant};
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum BlobError {
#[error("filename must not be empty")]
EmptyFilename,
#[error("blob key must not be empty")]
EmptyKey,
#[error("byte size mismatch: expected {expected}, actual {actual}")]
ByteSizeMismatch { expected: u64, actual: u64 },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Blob {
id: Uuid,
key: String,
filename: String,
content_type: Option<String>,
byte_size: u64,
checksum: String,
metadata: BTreeMap<String, Value>,
service_name: String,
created_at: DateTime<Utc>,
}
impl Blob {
pub fn create(
data: Bytes,
filename: impl Into<String>,
content_type: Option<&str>,
metadata: BTreeMap<String, Value>,
service_name: impl Into<String>,
) -> Result<Self, BlobError> {
Self::create_with_key(
Self::generate_key(),
data,
filename,
content_type,
metadata,
service_name,
)
}
pub fn create_with_key(
key: impl Into<String>,
data: Bytes,
filename: impl Into<String>,
content_type: Option<&str>,
metadata: BTreeMap<String, Value>,
service_name: impl Into<String>,
) -> Result<Self, BlobError> {
let key = key.into();
if key.trim().is_empty() {
return Err(BlobError::EmptyKey);
}
let filename = filename.into();
if filename.trim().is_empty() {
return Err(BlobError::EmptyFilename);
}
Ok(Self {
id: Uuid::now_v7(),
key,
content_type: detect_content_type(&filename, content_type),
byte_size: data.len() as u64,
checksum: Self::checksum_for(&data),
filename,
metadata,
service_name: service_name.into(),
created_at: Utc::now(),
})
}
pub fn create_before_direct_upload(
key: impl Into<String>,
filename: impl Into<String>,
byte_size: u64,
checksum: impl Into<String>,
content_type: Option<&str>,
metadata: BTreeMap<String, Value>,
service_name: impl Into<String>,
) -> Result<Self, BlobError> {
let key = key.into();
if key.trim().is_empty() {
return Err(BlobError::EmptyKey);
}
let filename = filename.into();
if filename.trim().is_empty() {
return Err(BlobError::EmptyFilename);
}
Ok(Self {
id: Uuid::now_v7(),
key,
content_type: detect_content_type(&filename, content_type),
byte_size,
checksum: checksum.into(),
filename,
metadata,
service_name: service_name.into(),
created_at: Utc::now(),
})
}
pub fn compose(
blobs: &[Self],
filename: impl Into<String>,
service_name: impl Into<String>,
) -> Result<Self, BlobError> {
let filename = filename.into();
if filename.trim().is_empty() {
return Err(BlobError::EmptyFilename);
}
let content_type = blobs.iter().find_map(|blob| blob.content_type.clone());
let byte_size = blobs.iter().map(|blob| blob.byte_size).sum();
let mut metadata = BTreeMap::new();
metadata.insert("composed".to_owned(), Value::Bool(true));
metadata.insert("parts".to_owned(), Value::from(blobs.len() as u64));
Ok(Self {
id: Uuid::now_v7(),
key: Self::generate_key(),
filename,
content_type,
byte_size,
checksum: String::new(),
metadata,
service_name: service_name.into(),
created_at: Utc::now(),
})
}
#[must_use]
pub fn variant(&self, transformations: BTreeMap<String, Value>) -> Variant {
Variant::new(self.clone(), transformations)
}
#[must_use]
pub fn id(&self) -> Uuid {
self.id
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn filename(&self) -> &str {
&self.filename
}
#[must_use]
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
#[must_use]
pub fn byte_size(&self) -> u64 {
self.byte_size
}
#[must_use]
pub fn checksum(&self) -> &str {
&self.checksum
}
#[must_use]
pub fn metadata(&self) -> &BTreeMap<String, Value> {
&self.metadata
}
#[must_use]
pub fn service_name(&self) -> &str {
&self.service_name
}
#[must_use]
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
#[must_use]
pub fn extension(&self) -> Option<&str> {
file_extension(&self.filename)
}
#[must_use]
pub fn is_image(&self) -> bool {
self.content_type
.as_deref()
.is_some_and(|content_type| content_type.starts_with("image/"))
}
#[must_use]
pub fn is_video(&self) -> bool {
self.content_type
.as_deref()
.is_some_and(|content_type| content_type.starts_with("video/"))
}
#[must_use]
pub fn is_audio(&self) -> bool {
self.content_type
.as_deref()
.is_some_and(|content_type| content_type.starts_with("audio/"))
}
#[must_use]
pub fn is_text(&self) -> bool {
self.content_type.as_deref().is_some_and(|content_type| {
content_type.starts_with("text/")
|| matches!(content_type, "application/json" | "application/xml")
})
}
#[must_use]
pub fn with_metadata(mut self, metadata: BTreeMap<String, Value>) -> Self {
self.metadata.extend(metadata);
self
}
#[must_use]
pub fn with_content_type(mut self, content_type: Option<String>) -> Self {
self.content_type = content_type;
self
}
pub fn validate_payload(&self, data: &Bytes) -> Result<(), BlobError> {
let actual = data.len() as u64;
if self.byte_size != actual {
return Err(BlobError::ByteSizeMismatch {
expected: self.byte_size,
actual,
});
}
Ok(())
}
#[must_use]
pub fn checksum_for(data: &Bytes) -> String {
sha256_hex(data)
}
#[must_use]
pub fn generate_key() -> String {
const ALPHABET: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let seed = sha256_hex(Uuid::now_v7().as_bytes());
seed.as_bytes()
.iter()
.take(28)
.map(|byte| ALPHABET[(usize::from(*byte)) % ALPHABET.len()] as char)
.collect()
}
}
impl fmt::Display for Blob {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{} ({})", self.filename, self.key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn blob(filename: &str, content_type: Option<&str>, data: &[u8]) -> Blob {
Blob::create(
Bytes::copy_from_slice(data),
filename.to_owned(),
content_type,
BTreeMap::new(),
"memory",
)
.expect("blob should build")
}
#[test]
fn test_create_computes_byte_size() {
let blob = blob("hello.txt", None, b"Hello world");
assert_eq!(blob.byte_size(), 11);
}
#[test]
fn test_create_computes_checksum() {
let blob = blob("hello.txt", None, b"Hello world");
assert_eq!(
blob.checksum(),
Blob::checksum_for(&Bytes::from_static(b"Hello world"))
);
}
#[test]
fn test_create_detects_content_type_from_extension() {
let blob = blob("hello.txt", None, b"Hello world");
assert_eq!(blob.content_type(), Some("text/plain"));
}
#[test]
fn test_create_prefers_explicit_content_type() {
let blob = blob("hello.txt", Some("application/custom"), b"Hello world");
assert_eq!(blob.content_type(), Some("application/custom"));
}
#[test]
fn test_create_replaces_octet_stream_with_filename_hint() {
let blob = blob(
"hello.txt",
Some("application/octet-stream"),
b"Hello world",
);
assert_eq!(blob.content_type(), Some("text/plain"));
}
#[test]
fn test_create_with_custom_key() {
let blob = Blob::create_with_key(
"custom-key",
Bytes::from_static(b"Hello world"),
"hello.txt",
None,
BTreeMap::new(),
"memory",
)
.expect("blob should build");
assert_eq!(blob.key(), "custom-key");
}
#[test]
fn test_create_with_key_rejects_empty_key() {
let error = Blob::create_with_key(
" ",
Bytes::new(),
"hello.txt",
None,
BTreeMap::new(),
"memory",
)
.expect_err("should fail");
assert_eq!(error, BlobError::EmptyKey);
}
#[test]
fn test_create_rejects_empty_filename() {
let error = Blob::create(Bytes::new(), "", None, BTreeMap::new(), "memory")
.expect_err("should fail");
assert_eq!(error, BlobError::EmptyFilename);
}
#[test]
fn test_create_before_direct_upload_preserves_checksum() {
let blob = Blob::create_before_direct_upload(
"direct-key",
"racecar.jpg",
42,
"checksum",
Some("image/jpeg"),
BTreeMap::new(),
"memory",
)
.expect("blob should build");
assert_eq!(blob.checksum(), "checksum");
assert_eq!(blob.byte_size(), 42);
}
#[test]
fn test_create_before_direct_upload_preserves_metadata_and_service_name() {
let mut metadata = BTreeMap::new();
metadata.insert("identified".to_owned(), Value::Bool(false));
metadata.insert("custom".to_owned(), serde_json::json!({"width": 640}));
let blob = Blob::create_before_direct_upload(
"direct-key",
"racecar.jpg",
42,
"checksum",
Some("image/jpeg"),
metadata.clone(),
"mirror",
)
.expect("blob should build");
assert_eq!(blob.metadata(), &metadata);
assert_eq!(blob.service_name(), "mirror");
}
#[test]
fn test_create_before_direct_upload_rejects_empty_key() {
let error = Blob::create_before_direct_upload(
" ",
"racecar.jpg",
42,
"checksum",
Some("image/jpeg"),
BTreeMap::new(),
"memory",
)
.expect_err("should fail");
assert_eq!(error, BlobError::EmptyKey);
}
#[test]
fn test_direct_upload_without_content_type_leaves_type_helpers_false() {
let blob = Blob::create_before_direct_upload(
"direct-key",
"unknown_file",
100,
"checksum",
None,
BTreeMap::new(),
"memory",
)
.expect("blob should build");
assert_eq!(blob.content_type(), None);
assert!(!blob.is_image());
assert!(!blob.is_video());
assert!(!blob.is_audio());
assert!(!blob.is_text());
}
#[test]
fn test_generate_key_has_expected_shape() {
let key = Blob::generate_key();
assert_eq!(key.len(), 28);
assert!(
key.chars()
.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
);
}
#[test]
fn test_generate_key_is_unique_enough_for_two_calls() {
assert_ne!(Blob::generate_key(), Blob::generate_key());
}
#[test]
fn test_blob_type_helpers_for_image() {
let blob = blob("image.png", None, b"png");
assert!(blob.is_image());
assert!(!blob.is_video());
assert!(!blob.is_audio());
}
#[test]
fn test_blob_type_helpers_for_video() {
let blob = blob("movie.mp4", None, b"mp4");
assert!(blob.is_video());
assert!(!blob.is_text());
}
#[test]
fn test_blob_type_helpers_for_audio() {
let blob = blob("sound.mp3", None, b"mp3");
assert!(blob.is_audio());
assert!(!blob.is_image());
}
#[test]
fn test_blob_type_helpers_for_text() {
let blob = blob("hello.txt", None, b"Hello world");
assert!(blob.is_text());
}
#[test]
fn test_create_normalizes_explicit_content_type() {
let blob = blob("hello", Some(" TEXT/PLAIN "), b"Hello world");
assert_eq!(blob.content_type(), Some("text/plain"));
}
#[test]
fn test_extension_reads_filename_extension() {
let blob = blob("archive.tar", None, b"x");
assert_eq!(blob.extension(), Some("tar"));
}
#[test]
fn test_extension_returns_none_for_extensionless_filename() {
let blob = blob("archive", None, b"x");
assert_eq!(blob.extension(), None);
}
#[test]
fn test_with_metadata_merges_values() {
let mut metadata = BTreeMap::new();
metadata.insert("width".to_owned(), Value::from(100));
let blob = blob("image.png", None, b"png").with_metadata(metadata.clone());
assert_eq!(blob.metadata(), &metadata);
}
#[test]
fn test_with_metadata_overwrites_conflicting_keys() {
let mut metadata = BTreeMap::new();
metadata.insert("width".to_owned(), Value::from(100));
metadata.insert("height".to_owned(), Value::from(200));
let mut updates = BTreeMap::new();
updates.insert("width".to_owned(), Value::from(400));
updates.insert("identified".to_owned(), Value::Bool(true));
let blob = Blob::create(
Bytes::from_static(b"png"),
"image.png",
None,
metadata,
"memory",
)
.expect("blob should build")
.with_metadata(updates);
assert_eq!(blob.metadata().get("width"), Some(&Value::from(400)));
assert_eq!(blob.metadata().get("height"), Some(&Value::from(200)));
assert_eq!(blob.metadata().get("identified"), Some(&Value::Bool(true)));
}
#[test]
fn test_with_content_type_replaces_value() {
let blob = blob("hello.txt", None, b"Hello world")
.with_content_type(Some("application/json".to_owned()));
assert_eq!(blob.content_type(), Some("application/json"));
}
#[test]
fn test_validate_payload_rejects_wrong_size() {
let blob = blob("hello.txt", None, b"Hello world");
let error = blob
.validate_payload(&Bytes::from_static(b"short"))
.expect_err("size mismatch should fail");
assert_eq!(
error,
BlobError::ByteSizeMismatch {
expected: 11,
actual: 5
}
);
}
#[test]
fn test_validate_payload_accepts_matching_size() {
let blob = blob("hello.txt", None, b"Hello");
blob.validate_payload(&Bytes::from_static(b"Hello"))
.expect("matching payload should validate");
}
#[test]
fn test_zero_byte_blob_is_supported() {
let blob = blob("empty.txt", None, b"");
assert_eq!(blob.byte_size(), 0);
assert!(blob.is_text());
}
#[test]
fn test_compose_sums_sizes_and_marks_metadata() {
let first = blob("part-1.txt", None, b"123");
let second = blob("part-2.txt", None, b"456");
let composite =
Blob::compose(&[first, second], "all.txt", "memory").expect("compose should work");
assert_eq!(composite.byte_size(), 6);
assert_eq!(
composite.metadata().get("composed"),
Some(&Value::Bool(true))
);
}
#[test]
fn test_compose_uses_first_available_content_type_and_empty_checksum() {
let first = Blob::create_before_direct_upload(
"first",
"first",
1,
"checksum-1",
None,
BTreeMap::new(),
"memory",
)
.expect("blob should build");
let second = blob("image.png", Some("image/png"), b"png");
let composite =
Blob::compose(&[first, second], "all.bin", "archive").expect("compose should work");
assert_eq!(composite.content_type(), Some("image/png"));
assert_eq!(composite.checksum(), "");
assert_eq!(composite.service_name(), "archive");
assert_eq!(composite.metadata().get("parts"), Some(&Value::from(2_u64)));
}
#[test]
fn test_variant_builder_clones_blob_metadata() {
let variant = blob("racecar.jpg", None, b"jpg").variant(BTreeMap::new());
assert_eq!(variant.blob().filename(), "racecar.jpg");
}
}