diff --git a/Cargo.toml b/Cargo.toml index 79e7304..4f2c781 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" keywords = ["ids", "encode", "short", "sqids", "hashids"] [dependencies] +derive_builder = "0.12.0" serde = "1.0.192" serde_json = "1.0.108" thiserror = "1.0.50" diff --git a/README.md b/README.md index 3471b9e..21aaeda 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ cargo add sqids Simple encode & decode: ```rust +# use sqids::Sqids; let sqids = Sqids::default(); let id = sqids.encode(&[1, 2, 3])?; // "86Rf07" let numbers = sqids.decode(&id); // [1, 2, 3] +# Ok::<(), sqids::Error>(()) ``` > **Note** @@ -54,37 +56,37 @@ let numbers = sqids.decode(&id); // [1, 2, 3] Enforce a *minimum* length for IDs: ```rust -let sqids = Sqids::new(Some(Options::new( - None, - Some(10), - None, -)))?; +# use sqids::Sqids; +let sqids = Sqids::builder() + .min_length(10) + .build()?; let id = sqids.encode(&[1, 2, 3])?; // "86Rf07xd4z" let numbers = sqids.decode(&id); // [1, 2, 3] +# Ok::<(), sqids::Error>(()) ``` Randomize IDs by providing a custom alphabet: ```rust -let sqids = Sqids::new(Some(Options::new( - Some("FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE".to_string()), - None, - None, -)))?; +# use sqids::Sqids; +let sqids = Sqids::builder() + .alphabet("FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE".chars().collect()) + .build()?; let id = sqids.encode(&[1, 2, 3])?; // "B4aajs" let numbers = sqids.decode(&id); // [1, 2, 3] +# Ok::<(), sqids::Error>(()) ``` Prevent specific words from appearing anywhere in the auto-generated IDs: ```rust -let sqids = Sqids::new(Some(Options::new( - None, - None, - Some(HashSet::from(["86Rf07".to_string()])), -)))?; +# use sqids::Sqids; +let sqids = Sqids::builder() + .blocklist(["86Rf07".to_string()].into()) + .build()?; let id = sqids.encode(&[1, 2, 3])?; // "se8ojk" let numbers = sqids.decode(&id); // [1, 2, 3] +# Ok::<(), sqids::Error>(()) ``` ## 📝 License diff --git a/rustfmt.toml b/rustfmt.toml index b4e9813..d79c9cf 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -10,4 +10,5 @@ binop_separator = "Back" trailing_comma = "Vertical" trailing_semicolon = true use_field_init_shorthand = true -format_macro_bodies = true \ No newline at end of file +format_macro_bodies = true +format_code_in_doc_comments = true diff --git a/src/lib.rs b/src/lib.rs index 89b9c58..8d389f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,33 +1,92 @@ +#![warn(missing_docs)] +#![allow(clippy::tabs_in_doc_comments)] +#![doc = include_str!("../README.md")] + +// Make the link to the LICENSE in README.md work. +#[cfg(doc)] +#[doc = include_str!("../LICENSE")] +/// +/// --- +/// **Note**: This is the crate's license and not an actual item. +pub const LICENSE: () = (); + use std::{cmp::min, collections::HashSet, result}; +use derive_builder::Builder; use thiserror::Error; +/// sqids Error type. #[derive(Error, Debug, Eq, PartialEq)] pub enum Error { + /// Alphabet cannot contain multibyte characters + /// + /// ``` + /// # use sqids::{Sqids, Error}; + /// let error = Sqids::builder().alphabet("☃️🦀🔥".chars().collect()).build().unwrap_err(); + /// assert_eq!(error, Error::AlphabetMultibyteCharacters); + /// ``` #[error("Alphabet cannot contain multibyte characters")] AlphabetMultibyteCharacters, + /// Alphabet length must be at least 3 + /// + /// ``` + /// # use sqids::{Sqids, Error}; + /// let error = Sqids::builder().alphabet("ab".chars().collect()).build().unwrap_err(); + /// assert_eq!(error, Error::AlphabetLength); + /// ``` #[error("Alphabet length must be at least 3")] AlphabetLength, + /// Alphabet must contain unique characters + /// + /// ``` + /// # use sqids::{Sqids, Error}; + /// let error = Sqids::builder().alphabet("aba".chars().collect()).build().unwrap_err(); + /// assert_eq!(error, Error::AlphabetUniqueCharacters); + /// ``` #[error("Alphabet must contain unique characters")] AlphabetUniqueCharacters, + /// Reached max attempts to re-generate the ID + /// + /// ``` + /// # use sqids::{Sqids, Error}; + /// let sqids = Sqids::builder() + /// .alphabet("abc".chars().collect()) + /// .min_length(3) + /// .blocklist(["aac".to_string(), "bba".to_string(), "ccb".to_string()].into()) + /// .build() + /// .unwrap(); + /// let error = sqids.encode(&[1]).unwrap_err(); + /// assert_eq!(error, Error::BlocklistMaxAttempts); + /// ``` #[error("Reached max attempts to re-generate the ID")] BlocklistMaxAttempts, } +/// type alias for Result pub type Result = result::Result; +/// The default alphabet used when none is given when creating a [Sqids]. +pub const DEFAULT_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +/// Returns the default blocklist when none is given when creating a [Sqids]. pub fn default_blocklist() -> HashSet { serde_json::from_str(include_str!("blocklist.json")).unwrap() } +/// Options for creating a [Sqids]. #[derive(Debug)] pub struct Options { + /// The [Sqids] alphabet. pub alphabet: String, + /// The minimum length of a sqid. pub min_length: u8, + /// Blocklist. When creating a sqid [Sqids] will try to avoid generating a string that begins + /// with one of these. pub blocklist: HashSet, } impl Options { + /// Create an [Options] object. pub fn new( alphabet: Option, min_length: Option, @@ -52,30 +111,42 @@ impl Options { impl Default for Options { fn default() -> Self { Options { - alphabet: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".to_string(), + alphabet: DEFAULT_ALPHABET.to_string(), min_length: 0, blocklist: default_blocklist(), } } } -#[derive(Debug)] +/// A generator for sqids. +#[derive(Debug, Builder)] +#[builder(build_fn(skip, error = "Error"), pattern = "owned")] pub struct Sqids { + /// The alphabet that is being used when generating sqids. alphabet: Vec, + /// The minimum length of a sqid. min_length: u8, + /// Blocklist. When creating a sqid strings that begins + /// with one of these will be avoided. blocklist: HashSet, } impl Default for Sqids { fn default() -> Self { - Sqids::new(None).unwrap() + Self::builder().build().unwrap() } } -impl Sqids { - pub fn new(options: Option) -> Result { - let options = options.unwrap_or_default(); - let alphabet: Vec = options.alphabet.chars().collect(); +impl SqidsBuilder { + /// Create a [SqidsBuilder]. + pub fn new() -> Self { + Self::default() + } + + /// Build a [Sqids] object. + pub fn build(self) -> Result { + let alphabet: Vec = + self.alphabet.unwrap_or_else(|| DEFAULT_ALPHABET.chars().collect()); for c in alphabet.iter() { if c.len_utf8() > 1 { @@ -94,8 +165,9 @@ impl Sqids { let lowercase_alphabet: Vec = alphabet.iter().map(|c| c.to_ascii_lowercase()).collect(); - let filtered_blocklist: HashSet = options + let filtered_blocklist: HashSet = self .blocklist + .unwrap_or_else(default_blocklist) .iter() .filter_map(|word| { let word = word.to_lowercase(); @@ -108,12 +180,35 @@ impl Sqids { .collect(); Ok(Sqids { - alphabet: Self::shuffle(&alphabet), - min_length: options.min_length, + alphabet: Sqids::shuffle(&alphabet), + min_length: self.min_length.unwrap_or(0), blocklist: filtered_blocklist, }) } +} + +impl Sqids { + /// Create a [Sqids] from [Options]. + pub fn new(options: Option) -> Result { + let options = options.unwrap_or_default(); + Self::builder() + .min_length(options.min_length) + .alphabet(options.alphabet.chars().collect()) + .blocklist(options.blocklist) + .build() + } + + /// Create a [SqidsBuilder]. + pub fn builder() -> SqidsBuilder { + SqidsBuilder::default() + } + /// Generate a sqid from a slice of numbers. + /// + /// When an sqid is generated it is checked against the [SqidsBuilder::blocklist]. When a + /// blocked word is encountered another attempt is made by shifting the alphabet. + /// When the alphabet is exhausted and all possible sqids for this input are blocked + /// [Error::BlocklistMaxAttempts] is returned. pub fn encode(&self, numbers: &[u64]) -> Result { if numbers.is_empty() { return Ok(String::new()); @@ -122,6 +217,8 @@ impl Sqids { self.encode_numbers(numbers, 0) } + /// Decode a sqid into a vector of numbers. When an invalid sqid is encountered an empty vector + /// is returned. pub fn decode(&self, id: &str) -> Vec { let mut ret = Vec::new(); diff --git a/tests/blocklist.rs b/tests/blocklist.rs index 470a7a5..433a8ce 100644 --- a/tests/blocklist.rs +++ b/tests/blocklist.rs @@ -39,11 +39,13 @@ fn blocklist() { None, None, Some(HashSet::from([ - "JSwXFaosAN".to_owned(), // normal result of 1st encoding, let's block that word on purpose + "JSwXFaosAN".to_owned(), /* normal result of 1st encoding, let's block that word on + * purpose */ "OCjV9JK64o".to_owned(), // result of 2nd encoding - "rBHf".to_owned(), // result of 3rd encoding is `4rBHfOiqd3`, let's block a substring - "79SM".to_owned(), // result of 4th encoding is `dyhgw479SM`, let's block the postfix - "7tE6".to_owned(), // result of 4th encoding is `7tE6jdAHLe`, let's block the prefix + "rBHf".to_owned(), /* result of 3rd encoding is `4rBHfOiqd3`, let's block a + * substring */ + "79SM".to_owned(), // result of 4th encoding is `dyhgw479SM`, let's block the postfix + "7tE6".to_owned(), // result of 4th encoding is `7tE6jdAHLe`, let's block the prefix ])), ))) .unwrap(); @@ -87,7 +89,8 @@ fn blocklist_filtering_in_constructor() { let sqids = Sqids::new(Some(Options::new( Some("ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string()), None, - Some(HashSet::from(["sxnzkl".to_owned()])), // lowercase blocklist in only-uppercase alphabet + Some(HashSet::from(["sxnzkl".to_owned()])), /* lowercase blocklist in only-uppercase + * alphabet */ ))) .unwrap();