diff --git a/clients/rust/README.md b/clients/rust/README.md index 99b4987e..bd6c855e 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -331,9 +331,7 @@ static CLIENT: LazyLock = LazyLock::new(|| { .expect("Objectstore client to build successfully") }); -static ATTACHMENTS: LazyLock = LazyLock::new(|| { - Usecase::from_static("attachments") -}); +static ATTACHMENTS: Usecase = Usecase::from_static("attachments"); async fn example() -> Result<()> { let session = CLIENT.session(ATTACHMENTS.for_project(42, 1337))?; diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index b66cdaf0..832ca474 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -158,7 +158,7 @@ impl ClientBuilder { /// normalized. #[derive(Debug, Clone)] pub struct Usecase { - name: Arc, + name: UsecaseName, compression: Option, expiration_policy: ExpirationPolicy, } @@ -173,22 +173,37 @@ impl Usecase { /// Returns [`InvalidUsecaseName`] if the name violates the naming rules on /// [`Usecase`]. pub fn new(name: &str) -> Result { - Ok(Self { - name: Arc::new(UsecaseName::new(name)?), - compression: Some(Compression::Zstd), - expiration_policy: ExpirationPolicy::default(), - }) + Ok(Self::with_name(UsecaseName::new(name)?)) } /// Creates a Usecase for a statically defined name. /// - /// Use [`Self::new`] for dynamic names. + /// Use [`Self::new`] for dynamic names. The result can initialize a `static`: + /// + /// ``` + /// use objectstore_client::Usecase; + /// + /// static ATTACHMENTS: Usecase = Usecase::from_static("attachments"); + /// assert_eq!(ATTACHMENTS.name(), "attachments"); + /// ``` /// /// # Panics /// - /// Panics if the name violates the naming rules on [`Usecase`]. - pub fn from_static(name: &'static str) -> Self { - Self::new(name).expect("invalid usecase name") + /// Panics if the name violates the naming rules on [`Usecase`]. In a const + /// context, this panic is reported as a compile error. + #[track_caller] + pub const fn from_static(name: &'static str) -> Self { + Self::with_name(UsecaseName::from_static(name)) + } + + /// Creates a Usecase with the default compression and expiration policy. + const fn with_name(name: UsecaseName) -> Self { + Self { + name, + compression: Some(Compression::Zstd), + // `ExpirationPolicy::default()` isn't callable in a const fn. + expiration_policy: ExpirationPolicy::Manual, + } } /// Returns the name of this usecase. @@ -587,10 +602,18 @@ mod tests { )); assert!(matches!( Usecase::new("testing/org=999/injected"), - Err(InvalidUsecaseName::InvalidChar('/')) + Err(InvalidUsecaseName::InvalidChar(7)) )); } + #[test] + fn test_usecase_from_static_const() { + const USECASE: Usecase = Usecase::from_static("testing"); + assert_eq!(USECASE.name(), "testing"); + assert_eq!(USECASE.compression(), Some(Compression::Zstd)); + assert_eq!(USECASE.expiration_policy(), ExpirationPolicy::default()); + } + #[test] #[should_panic(expected = "invalid usecase name")] fn test_invalid_usecase_panics() { diff --git a/objectstore-types/src/usecase.rs b/objectstore-types/src/usecase.rs index 02fa5f66..69387ece 100644 --- a/objectstore-types/src/usecase.rs +++ b/objectstore-types/src/usecase.rs @@ -1,10 +1,13 @@ //! Validated usecase names shared by clients and the server. use std::borrow::Borrow; +use std::cmp::Ordering; use std::fmt; +use std::hash::{Hash, Hasher}; use std::str::FromStr; +use std::sync::Arc; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Minimum length of a usecase name in bytes. pub const MIN_USECASE_NAME_LENGTH: usize = 3; @@ -12,6 +15,21 @@ pub const MIN_USECASE_NAME_LENGTH: usize = 3; /// Maximum length of a usecase name in bytes. pub const MAX_USECASE_NAME_LENGTH: usize = 64; +/// Message for [`InvalidUsecaseName::InvalidLength`]. +const INVALID_LENGTH: &str = "invalid usecase name: must be 3-64 ASCII characters long"; + +/// Message for [`InvalidUsecaseName::InvalidChar`], without the position. +const INVALID_CHAR: &str = "invalid usecase name: expected ASCII letters, digits, '_' or '-'"; + +/// Storage for a validated [`Usecase`] name. +#[derive(Clone)] +enum Repr { + /// A name validated by [`Usecase::from_static`], stored without allocating. + Static(&'static str), + /// A name validated at runtime by [`Usecase::new`]. + Dynamic(Arc), +} + /// A validated, case-sensitive name for an Objectstore workload. /// /// Names must be 3–64 ASCII characters from `[A-Za-z0-9_-]`. Any permitted @@ -29,33 +47,97 @@ pub const MAX_USECASE_NAME_LENGTH: usize = 64; /// assert!(Usecase::new("testing/org=999/injected").is_err()); /// # Ok::<(), objectstore_types::usecase::InvalidUsecaseName>(()) /// ``` -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] -#[serde(transparent)] -pub struct Usecase(String); +/// +/// Use [`Usecase::from_static`] for statically known names and [`Usecase::new`] +/// for dynamic names. +#[derive(Clone)] +pub struct Usecase(Repr); impl Usecase { /// Creates a validated usecase name from a borrowed or owned string. /// + /// Use [`Self::from_static`] for statically defined names. + /// /// # Errors /// /// Returns [`InvalidUsecaseName`] if the name violates the rules on /// [`Usecase`]. - pub fn new(name: impl Into) -> Result { - let name = name.into(); - if !(MIN_USECASE_NAME_LENGTH..=MAX_USECASE_NAME_LENGTH).contains(&name.len()) { - return Err(InvalidUsecaseName::InvalidLength); - } - for c in name.chars() { - if !c.is_ascii_alphanumeric() && c != '_' && c != '-' { - return Err(InvalidUsecaseName::InvalidChar(c)); - } + pub fn new(name: impl AsRef) -> Result { + let name = name.as_ref(); + validate(name)?; + Ok(Self(Repr::Dynamic(name.into()))) + } + + /// Creates a usecase name from a name known at compile time. + /// + /// Use [`Self::new`] for dynamic names. The name is stored without + /// allocating, so the result can initialize a `static`: + /// + /// ``` + /// use objectstore_types::usecase::Usecase; + /// + /// static ATTACHMENTS: Usecase = Usecase::from_static("attachments"); + /// assert_eq!(ATTACHMENTS.as_str(), "attachments"); + /// ``` + /// + /// # Panics + /// + /// Panics if the name violates the rules on [`Usecase`]. In a const + /// context, this panic is reported as a compile error: + /// + /// ```compile_fail + /// use objectstore_types::usecase::Usecase; + /// + /// static BROKEN: Usecase = Usecase::from_static("attachments/v2"); + /// ``` + #[track_caller] + pub const fn from_static(name: &'static str) -> Self { + match validate(name) { + Ok(()) => Self(Repr::Static(name)), + // Const panics only accept a single `&str`, so the position is omitted. + Err(InvalidUsecaseName::InvalidLength) => panic!("{}", INVALID_LENGTH), + Err(InvalidUsecaseName::InvalidChar(_)) => panic!("{}", INVALID_CHAR), } - Ok(Self(name)) } /// Returns the usecase name as a string slice. pub fn as_str(&self) -> &str { - &self.0 + match &self.0 { + Repr::Static(name) => name, + Repr::Dynamic(name) => name, + } + } +} + +impl fmt::Debug for Usecase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Usecase").field(&self.as_str()).finish() + } +} + +impl PartialEq for Usecase { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Eq for Usecase {} + +impl PartialOrd for Usecase { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Usecase { + fn cmp(&self, other: &Self) -> Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl Hash for Usecase { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); } } @@ -103,7 +185,16 @@ impl TryFrom for Usecase { impl From for String { fn from(name: Usecase) -> Self { - name.0 + name.as_str().to_owned() + } +} + +impl Serialize for Usecase { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) } } @@ -112,7 +203,7 @@ impl<'de> Deserialize<'de> for Usecase { where D: Deserializer<'de>, { - Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + deserializer.deserialize_str(UsecaseVisitor) } } @@ -120,11 +211,56 @@ impl<'de> Deserialize<'de> for Usecase { #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum InvalidUsecaseName { /// The name is outside the permitted length range. - #[error("usecase name must be 3–64 ASCII characters long")] + #[error("{message}", message = INVALID_LENGTH)] InvalidLength, - /// The name contains a character outside the permitted set. - #[error("invalid character {0:?} in usecase name; expected ASCII letters, digits, '_' or '-'")] - InvalidChar(char), + /// The name contains a character outside the permitted set, starting at + /// this byte position. + #[error("{message}, at position {0}", message = INVALID_CHAR)] + InvalidChar(usize), +} + +/// Checks whether a name follows the rules on [`Usecase`]. +const fn validate(name: &str) -> Result<(), InvalidUsecaseName> { + let bytes = name.as_bytes(); + if bytes.len() < MIN_USECASE_NAME_LENGTH || bytes.len() > MAX_USECASE_NAME_LENGTH { + return Err(InvalidUsecaseName::InvalidLength); + } + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if !b.is_ascii_alphanumeric() && b != b'_' && b != b'-' { + return Err(InvalidUsecaseName::InvalidChar(i)); + } + i += 1; + } + Ok(()) +} + +/// Deserializes a [`Usecase`] from a string or UTF-8 bytes. +struct UsecaseVisitor; + +impl serde::de::Visitor<'_> for UsecaseVisitor { + type Value = Usecase; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a usecase name") + } + + fn visit_str(self, name: &str) -> Result + where + E: serde::de::Error, + { + Usecase::new(name).map_err(E::custom) + } + + fn visit_bytes(self, bytes: &[u8]) -> Result + where + E: serde::de::Error, + { + let name = std::str::from_utf8(bytes) + .map_err(|_| E::invalid_value(serde::de::Unexpected::Bytes(bytes), &self))?; + self.visit_str(name) + } } #[cfg(test)] @@ -142,17 +278,50 @@ mod tests { for name in ["", "_", "ab", &"a".repeat(65)] { assert_eq!(Usecase::new(name), Err(InvalidUsecaseName::InvalidLength)); } - for (name, character) in [ - ("testing/org=999/injected", '/'), - ("test.name", '.'), - ("test name", ' '), - ("café", 'é'), + for (name, position) in [ + ("testing/org=999/injected", 7), + ("test.name", 4), + ("test name", 4), + ("café", 3), + ("ab€cd", 2), + ("ab🦀cd", 2), ] { assert_eq!( Usecase::new(name), - Err(InvalidUsecaseName::InvalidChar(character)) + Err(InvalidUsecaseName::InvalidChar(position)) ); } + assert_eq!( + InvalidUsecaseName::InvalidChar(7).to_string(), + "invalid usecase name: expected ASCII letters, digits, '_' or '-', at position 7" + ); + } + + #[test] + fn from_static_validates_in_const_context() { + const NAME: Usecase = Usecase::from_static("debug-files"); + assert_eq!(NAME.as_str(), "debug-files"); + } + + #[test] + #[should_panic(expected = "invalid usecase name")] + fn from_static_panics_on_invalid_name() { + Usecase::from_static("testing/org=999/injected"); + } + + #[test] + fn static_and_dynamic_names_are_equal() { + use std::collections::HashSet; + + let dynamic = Usecase::new("debug-files").unwrap(); + let static_ = Usecase::from_static("debug-files"); + assert_eq!(dynamic, static_); + assert_eq!(dynamic.cmp(&static_), Ordering::Equal); + assert_eq!(format!("{dynamic:?}"), r#"Usecase("debug-files")"#); + assert_eq!(format!("{static_:?}"), r#"Usecase("debug-files")"#); + let set = HashSet::from([dynamic]); + assert!(set.contains(&static_)); + assert!(set.contains("debug-files")); } #[test] @@ -161,6 +330,9 @@ mod tests { let serialized = serde_json::to_string(&name).unwrap(); assert_eq!(serialized, r#""Debug_Files-123""#); assert_eq!(serde_json::from_str::(&serialized).unwrap(), name); + // Escapes can't be borrowed from the input, so this takes the transient path. + let escaped = r#""Debug\u005fFiles-123""#; + assert_eq!(serde_json::from_str::(escaped).unwrap(), name); assert!(serde_json::from_str::(r#""_""#).is_err()); }