Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ergonomics and docs #5

Merged
merged 6 commits into from
Nov 26, 2023
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
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
Loading