use std::fmt;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use enumset::{EnumSet, EnumSetType};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum HttpsUrlError {
#[error("https url cannot be empty")]
Empty,
#[error("https url must start with `https://`")]
NonHttpsScheme,
#[error("https url is missing a host component")]
MissingHost,
#[error("https url cannot contain control characters")]
ControlChar,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct HttpsUrl(String);
impl HttpsUrl {
pub fn parse(value: impl Into<String>) -> Result<Self, HttpsUrlError> {
let value = value.into();
if value.is_empty() {
return Err(HttpsUrlError::Empty);
}
if value.chars().any(char::is_control) {
return Err(HttpsUrlError::ControlChar);
}
let Some(rest) = value.strip_prefix("https://") else {
return Err(HttpsUrlError::NonHttpsScheme);
};
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
if host_end == 0 {
return Err(HttpsUrlError::MissingHost);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for HttpsUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for HttpsUrl {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(D::Error::custom)
}
}
impl TryFrom<String> for HttpsUrl {
type Error = HttpsUrlError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<HttpsUrl> for String {
fn from(value: HttpsUrl) -> Self {
value.0
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum S3UriError {
#[error("s3 uri cannot be empty")]
Empty,
#[error("s3 uri must start with `s3://`")]
BadScheme,
#[error("s3 uri is missing a bucket component")]
MissingBucket,
#[error("s3 uri is missing a key component")]
MissingKey,
#[error("s3 uri cannot contain control characters")]
ControlChar,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct S3Uri(String);
impl S3Uri {
pub fn parse(value: impl Into<String>) -> Result<Self, S3UriError> {
let value = value.into();
if value.is_empty() {
return Err(S3UriError::Empty);
}
if value.chars().any(char::is_control) {
return Err(S3UriError::ControlChar);
}
let Some(rest) = value.strip_prefix("s3://") else {
return Err(S3UriError::BadScheme);
};
let Some((bucket, key)) = rest.split_once('/') else {
return Err(S3UriError::MissingKey);
};
if bucket.is_empty() {
return Err(S3UriError::MissingBucket);
}
if key.is_empty() {
return Err(S3UriError::MissingKey);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn bucket(&self) -> &str {
self.0
.strip_prefix("s3://")
.and_then(|rest| rest.split_once('/'))
.map_or("", |(bucket, _)| bucket)
}
#[must_use]
pub fn key(&self) -> &str {
self.0
.strip_prefix("s3://")
.and_then(|rest| rest.split_once('/'))
.map_or("", |(_, key)| key)
}
}
impl fmt::Display for S3Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for S3Uri {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(D::Error::custom)
}
}
impl TryFrom<String> for S3Uri {
type Error = S3UriError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<S3Uri> for String {
fn from(value: S3Uri) -> Self {
value.0
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum MediaTypeError {
#[error("media type cannot be empty")]
Empty,
#[error("media type must be `type/subtype` (RFC 6838)")]
MissingSlash,
#[error("media type top-level cannot be empty")]
MissingTopLevel,
#[error("media type subtype cannot be empty")]
MissingSubtype,
#[error("media type contains an invalid character")]
InvalidCharacter,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct MediaType(String);
impl MediaType {
pub fn parse(value: impl Into<String>) -> Result<Self, MediaTypeError> {
let value = value.into();
if value.is_empty() {
return Err(MediaTypeError::Empty);
}
let Some((top, rest)) = value.split_once('/') else {
return Err(MediaTypeError::MissingSlash);
};
if top.is_empty() {
return Err(MediaTypeError::MissingTopLevel);
}
let subtype_end = rest.find(';').unwrap_or(rest.len());
let subtype = &rest[..subtype_end];
if subtype.is_empty() {
return Err(MediaTypeError::MissingSubtype);
}
if !top.bytes().all(is_mime_token_byte) || !subtype.bytes().all(is_mime_token_byte) {
return Err(MediaTypeError::InvalidCharacter);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn top_level(&self) -> &str {
self.0.split_once('/').map_or("", |(top, _)| top)
}
#[must_use]
pub fn subtype(&self) -> &str {
let after_slash = self.0.split_once('/').map_or("", |(_, rest)| rest);
let subtype_end = after_slash.find(';').unwrap_or(after_slash.len());
&after_slash[..subtype_end]
}
}
impl fmt::Display for MediaType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for MediaType {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(D::Error::custom)
}
}
impl TryFrom<String> for MediaType {
type Error = MediaTypeError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<MediaType> for String {
fn from(value: MediaType) -> Self {
value.0
}
}
const fn is_mime_token_byte(byte: u8) -> bool {
byte.is_ascii_uppercase()
|| byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(
byte,
b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'-' | b'+' | b'.'
)
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum AwsAccountIdError {
#[error("aws account id must be 12 digits")]
NotTwelveDigits,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct AwsAccountId(String);
impl AwsAccountId {
pub fn parse(value: impl Into<String>) -> Result<Self, AwsAccountIdError> {
let value = value.into();
if value.len() == 12 && value.bytes().all(|b| b.is_ascii_digit()) {
Ok(Self(value))
} else {
Err(AwsAccountIdError::NotTwelveDigits)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AwsAccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for AwsAccountId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(D::Error::custom)
}
}
impl TryFrom<String> for AwsAccountId {
type Error = AwsAccountIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<AwsAccountId> for String {
fn from(value: AwsAccountId) -> Self {
value.0
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ProviderFileIdError {
#[error("provider file id cannot be empty")]
Empty,
#[error("provider file id cannot contain control characters")]
ControlChar,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct ProviderFileId(String);
impl ProviderFileId {
pub fn parse(value: impl Into<String>) -> Result<Self, ProviderFileIdError> {
let value = value.into();
if value.is_empty() {
return Err(ProviderFileIdError::Empty);
}
if value.chars().any(char::is_control) {
return Err(ProviderFileIdError::ControlChar);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProviderFileId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for ProviderFileId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(D::Error::custom)
}
}
impl TryFrom<String> for ProviderFileId {
type Error = ProviderFileIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<ProviderFileId> for String {
fn from(value: ProviderFileId) -> Self {
value.0
}
}
#[derive(EnumSetType, Debug, Hash, Serialize, Deserialize)]
#[enumset(serialize_repr = "list")]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
Url,
InlineBytes,
ProviderFile,
S3,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum MediaSource {
Url {
url: HttpsUrl,
},
InlineBytes {
mime: MediaType,
#[serde(with = "base64_bytes")]
data: Vec<u8>,
},
ProviderFile {
file_id: ProviderFileId,
},
S3 {
uri: S3Uri,
#[serde(skip_serializing_if = "Option::is_none", default)]
bucket_owner: Option<AwsAccountId>,
},
}
impl MediaSource {
#[must_use]
pub const fn kind(&self) -> SourceKind {
match self {
Self::Url { .. } => SourceKind::Url,
Self::InlineBytes { .. } => SourceKind::InlineBytes,
Self::ProviderFile { .. } => SourceKind::ProviderFile,
Self::S3 { .. } => SourceKind::S3,
}
}
}
mod base64_bytes {
use super::{BASE64_STANDARD, Deserialize, Deserializer, Engine, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&BASE64_STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
BASE64_STANDARD
.decode(s.as_bytes())
.map_err(serde::de::Error::custom)
}
}
#[must_use]
pub const fn all_source_kinds() -> EnumSet<SourceKind> {
EnumSet::all()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_url_accepts_typical() {
let u = HttpsUrl::parse("https://example.com/img.png").unwrap();
assert_eq!(u.as_str(), "https://example.com/img.png");
}
#[test]
fn https_url_rejects_http_scheme() {
assert_eq!(
HttpsUrl::parse("http://example.com/x"),
Err(HttpsUrlError::NonHttpsScheme)
);
}
#[test]
fn https_url_rejects_missing_host() {
assert_eq!(
HttpsUrl::parse("https:///x"),
Err(HttpsUrlError::MissingHost)
);
}
#[test]
fn https_url_rejects_empty() {
assert_eq!(HttpsUrl::parse(""), Err(HttpsUrlError::Empty));
}
#[test]
fn https_url_rejects_control_chars() {
assert_eq!(
HttpsUrl::parse("https://example.com/\n"),
Err(HttpsUrlError::ControlChar)
);
}
#[test]
fn https_url_deserialize_revalidates() {
let err = serde_json::from_str::<HttpsUrl>("\"http://x\"").unwrap_err();
assert!(err.to_string().contains("https"));
}
#[test]
fn s3_uri_accepts_typical() {
let u = S3Uri::parse("s3://my-bucket/path/to/object").unwrap();
assert_eq!(u.bucket(), "my-bucket");
assert_eq!(u.key(), "path/to/object");
}
#[test]
fn s3_uri_rejects_wrong_scheme() {
assert!(matches!(
S3Uri::parse("https://b/k"),
Err(S3UriError::BadScheme)
));
}
#[test]
fn s3_uri_rejects_missing_key() {
assert!(matches!(
S3Uri::parse("s3://my-bucket"),
Err(S3UriError::MissingKey)
));
assert!(matches!(
S3Uri::parse("s3://my-bucket/"),
Err(S3UriError::MissingKey)
));
}
#[test]
fn s3_uri_rejects_missing_bucket() {
assert!(matches!(
S3Uri::parse("s3:///key"),
Err(S3UriError::MissingBucket)
));
}
#[test]
fn media_type_accepts_typical() {
let m = MediaType::parse("image/png").unwrap();
assert_eq!(m.top_level(), "image");
assert_eq!(m.subtype(), "png");
}
#[test]
fn media_type_strips_parameter_from_subtype() {
let m = MediaType::parse("text/plain; charset=utf-8").unwrap();
assert_eq!(m.top_level(), "text");
assert_eq!(m.subtype(), "plain");
}
#[test]
fn media_type_rejects_missing_slash() {
assert_eq!(MediaType::parse("image"), Err(MediaTypeError::MissingSlash));
}
#[test]
fn media_type_rejects_empty_halves() {
assert_eq!(
MediaType::parse("/png"),
Err(MediaTypeError::MissingTopLevel)
);
assert_eq!(
MediaType::parse("image/"),
Err(MediaTypeError::MissingSubtype)
);
}
#[test]
fn media_type_rejects_disallowed_chars() {
assert!(matches!(
MediaType::parse("image/png?"),
Err(MediaTypeError::InvalidCharacter)
));
}
#[test]
fn aws_account_id_accepts_twelve_digits() {
let a = AwsAccountId::parse("123456789012").unwrap();
assert_eq!(a.as_str(), "123456789012");
}
#[test]
fn aws_account_id_rejects_wrong_length() {
assert!(matches!(
AwsAccountId::parse("123"),
Err(AwsAccountIdError::NotTwelveDigits)
));
}
#[test]
fn aws_account_id_rejects_non_digit() {
assert!(matches!(
AwsAccountId::parse("12345678901a"),
Err(AwsAccountIdError::NotTwelveDigits)
));
}
#[test]
fn provider_file_id_accepts_typical() {
let f = ProviderFileId::parse("file-abc123").unwrap();
assert_eq!(f.as_str(), "file-abc123");
}
#[test]
fn provider_file_id_rejects_empty() {
assert!(matches!(
ProviderFileId::parse(""),
Err(ProviderFileIdError::Empty)
));
}
#[test]
fn media_source_kind_matches_variant() {
let url = MediaSource::Url {
url: HttpsUrl::parse("https://x/y").unwrap(),
};
assert_eq!(url.kind(), SourceKind::Url);
let bytes = MediaSource::InlineBytes {
mime: MediaType::parse("image/png").unwrap(),
data: vec![1, 2, 3],
};
assert_eq!(bytes.kind(), SourceKind::InlineBytes);
}
#[test]
fn media_source_url_round_trip() {
let src = MediaSource::Url {
url: HttpsUrl::parse("https://example.com/a.png").unwrap(),
};
let json = serde_json::to_string(&src).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["source"], "url");
assert_eq!(value["url"], "https://example.com/a.png");
let back: MediaSource = serde_json::from_str(&json).unwrap();
assert_eq!(src, back);
}
#[test]
fn media_source_inline_bytes_encodes_base64() {
let src = MediaSource::InlineBytes {
mime: MediaType::parse("image/png").unwrap(),
data: b"hello".to_vec(),
};
let json = serde_json::to_string(&src).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["source"], "inline_bytes");
assert_eq!(value["mime"], "image/png");
assert_eq!(value["data"], "aGVsbG8="); let back: MediaSource = serde_json::from_str(&json).unwrap();
assert_eq!(src, back);
}
#[test]
fn media_source_provider_file_round_trip() {
let src = MediaSource::ProviderFile {
file_id: ProviderFileId::parse("file-x").unwrap(),
};
let json = serde_json::to_string(&src).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["source"], "provider_file");
assert_eq!(value["file_id"], "file-x");
let back: MediaSource = serde_json::from_str(&json).unwrap();
assert_eq!(src, back);
}
#[test]
fn media_source_s3_with_bucket_owner_round_trip() {
let src = MediaSource::S3 {
uri: S3Uri::parse("s3://b/k").unwrap(),
bucket_owner: Some(AwsAccountId::parse("123456789012").unwrap()),
};
let json = serde_json::to_string(&src).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["source"], "s3");
assert_eq!(value["uri"], "s3://b/k");
assert_eq!(value["bucket_owner"], "123456789012");
let back: MediaSource = serde_json::from_str(&json).unwrap();
assert_eq!(src, back);
}
#[test]
fn media_source_s3_without_bucket_owner_omits_field() {
let src = MediaSource::S3 {
uri: S3Uri::parse("s3://b/k").unwrap(),
bucket_owner: None,
};
let json = serde_json::to_string(&src).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value.get("bucket_owner").is_none());
let back: MediaSource = serde_json::from_str(&json).unwrap();
assert_eq!(src, back);
}
#[test]
fn source_kind_enumset_round_trip() {
let mut set = EnumSet::new();
set.insert(SourceKind::Url);
set.insert(SourceKind::InlineBytes);
let json = serde_json::to_string(&set).unwrap();
assert!(json.contains("url"));
assert!(json.contains("inline_bytes"));
let back: EnumSet<SourceKind> = serde_json::from_str(&json).unwrap();
assert_eq!(set, back);
}
#[test]
fn all_source_kinds_contains_every_variant() {
let all = all_source_kinds();
assert!(all.contains(SourceKind::Url));
assert!(all.contains(SourceKind::InlineBytes));
assert!(all.contains(SourceKind::ProviderFile));
assert!(all.contains(SourceKind::S3));
}
}