Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions clients/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
.expect("Objectstore client to build successfully")
});

static ATTACHMENTS: LazyLock<Usecase> = 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))?;
Expand Down
45 changes: 34 additions & 11 deletions clients/rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ impl ClientBuilder {
/// normalized.
#[derive(Debug, Clone)]
pub struct Usecase {
name: Arc<UsecaseName>,
name: UsecaseName,
compression: Option<Compression>,
expiration_policy: ExpirationPolicy,
}
Expand All @@ -173,22 +173,37 @@ impl Usecase {
/// Returns [`InvalidUsecaseName`] if the name violates the naming rules on
/// [`Usecase`].
pub fn new(name: &str) -> Result<Self, InvalidUsecaseName> {
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.
Expand Down Expand Up @@ -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() {
Expand Down
226 changes: 199 additions & 27 deletions objectstore-types/src/usecase.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
//! 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;

/// 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<str>),
}

/// A validated, case-sensitive name for an Objectstore workload.
///
/// Names must be 3–64 ASCII characters from `[A-Za-z0-9_-]`. Any permitted
Expand All @@ -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<String>) -> Result<Self, InvalidUsecaseName> {
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<str>) -> Result<Self, InvalidUsecaseName> {
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<Ordering> {
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<H: Hasher>(&self, state: &mut H) {
self.as_str().hash(state);
}
}

Expand Down Expand Up @@ -103,7 +185,16 @@ impl TryFrom<String> for Usecase {

impl From<Usecase> for String {
fn from(name: Usecase) -> Self {
name.0
name.as_str().to_owned()
}
}

impl Serialize for Usecase {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}

Expand All @@ -112,19 +203,64 @@ 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)
}
}

/// An error indicating that a usecase name is invalid.
#[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<E>(self, name: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Usecase::new(name).map_err(E::custom)
}

fn visit_bytes<E>(self, bytes: &[u8]) -> Result<Self::Value, E>
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)]
Expand All @@ -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]
Expand All @@ -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::<Usecase>(&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::<Usecase>(escaped).unwrap(), name);
assert!(serde_json::from_str::<Usecase>(r#""_""#).is_err());
}

Expand Down
Loading