Skip to content

Commit

Permalink
Merge pull request #5 from fbenkstein/ergonomics-and-docs
Browse files Browse the repository at this point in the history
Ergonomics and docs
  • Loading branch information
4kimov authored Nov 26, 2023
2 parents 9f98788 + 297c25d commit 6df6b7a
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 31 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion rustfmt.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ binop_separator = "Back"
trailing_comma = "Vertical"
trailing_semicolon = true
use_field_init_shorthand = true
format_macro_bodies = true
format_macro_bodies = true
format_code_in_doc_comments = true
117 changes: 107 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<T, Error>
pub type Result<T> = result::Result<T, Error>;

/// 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<String> {
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<String>,
}

impl Options {
/// Create an [Options] object.
pub fn new(
alphabet: Option<String>,
min_length: Option<u8>,
Expand All @@ -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<char>,
/// 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<String>,
}

impl Default for Sqids {
fn default() -> Self {
Sqids::new(None).unwrap()
Self::builder().build().unwrap()
}
}

impl Sqids {
pub fn new(options: Option<Options>) -> Result<Self> {
let options = options.unwrap_or_default();
let alphabet: Vec<char> = options.alphabet.chars().collect();
impl SqidsBuilder {
/// Create a [SqidsBuilder].
pub fn new() -> Self {
Self::default()
}

/// Build a [Sqids] object.
pub fn build(self) -> Result<Sqids> {
let alphabet: Vec<char> =
self.alphabet.unwrap_or_else(|| DEFAULT_ALPHABET.chars().collect());

for c in alphabet.iter() {
if c.len_utf8() > 1 {
Expand All @@ -94,8 +165,9 @@ impl Sqids {

let lowercase_alphabet: Vec<char> =
alphabet.iter().map(|c| c.to_ascii_lowercase()).collect();
let filtered_blocklist: HashSet<String> = options
let filtered_blocklist: HashSet<String> = self
.blocklist
.unwrap_or_else(default_blocklist)
.iter()
.filter_map(|word| {
let word = word.to_lowercase();
Expand All @@ -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<Options>) -> Result<Self> {
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<String> {
if numbers.is_empty() {
return Ok(String::new());
Expand All @@ -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<u64> {
let mut ret = Vec::new();

Expand Down
13 changes: 8 additions & 5 deletions tests/blocklist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down

0 comments on commit 6df6b7a

Please sign in to comment.