diff --git a/src/ids/typeid.gleam b/src/ids/typeid.gleam new file mode 100644 index 0000000..5eacde1 --- /dev/null +++ b/src/ids/typeid.gleam @@ -0,0 +1,98 @@ +//// Module for generating TypeIDs. + +import gleam/bit_array +import gleam/regex +import gleam/result +import gleam/string +import ids/utils +import ids/uuid + +@internal +pub const alphabet = "0123456789abcdefghjkmnpqrstvwxyz" + +/// Generate a TypeID using the supplied prefix. Prefix may be an empty string. +/// +/// ### Usage +/// ```gleam +/// generate("user") // Ok("user_01hz7jxhgpfxh9m33bn41tcg9t") +/// generate("") // Ok("01hz7jv26zfnx9gpb0hvyc4pss") +/// ``` +pub fn generate(prefix prefix: String) -> Result(String, String) { + use uuid <- result.try(uuid.generate_v7()) + from_uuid(prefix: prefix, uuid: uuid) +} + +/// Generate a TypeID using the supplied prefix and UUID. Prefix may be an empty string. +/// +/// ### Usage +/// ```gleam +/// from_uuid("user", "018fcec0-b44b-7ce2-b187-2f08349beab9") // Ok("user_01hz7c1d2bfkhb31sf10t9qtns") +/// ``` +pub fn from_uuid( + prefix prefix: String, + uuid uuid: String, +) -> Result(String, String) { + let assert Ok(re) = regex.from_string("^([a-z]([a-z_]{0,61}[a-z])?)?$") + + case regex.check(re, prefix) { + True -> { + let p = case prefix { + "" -> "" + _ -> prefix <> "_" + } + + use raw_uuid <- result.try( + uuid + |> bit_array.from_string + |> uuid.dump, + ) + + let id = utils.encode_base32(raw_uuid, alphabet) + + Ok(p <> id) + } + False -> + Error( + "Error: Prefix must contain at most 63 characters and only lowercase alphabetic ASCII characters [a-z], or an underscore.", + ) + } +} + +/// Decode a TypeID into a tuple of #(prefix, uuid) +/// +/// ### Usage +/// ```gleam +/// decode("user_01hz7c1d2bfkhb31sf10t9qtns") // Ok(#("user", "018fcec0-b44b-7ce2-b187-2f08349beab9")) +/// ``` +pub fn decode(tid) -> Result(#(String, String), String) { + case tid |> string.reverse |> string.split_once(on: "_") { + Ok(#(xiffus, xiferp)) -> { + use uuid <- result.try( + xiffus + |> string.reverse + |> decode_suffix, + ) + Ok(#(string.reverse(xiferp), uuid)) + } + Error(Nil) -> { + use uuid <- result.try(decode_suffix(tid)) + Ok(#("", uuid)) + } + } +} + +fn decode_suffix(suffix) -> Result(String, String) { + use raw_uuid <- result.try( + suffix + |> utils.decode_base32(alphabet) + |> result.replace_error("Error: Couldn't decode suffix."), + ) + + use uuid <- result.try( + raw_uuid + |> uuid.cast + |> result.replace_error("Error: Couldn't decode UUID."), + ) + + Ok(uuid) +} diff --git a/src/ids/uuid.gleam b/src/ids/uuid.gleam index 68f25b8..d208fc6 100644 --- a/src/ids/uuid.gleam +++ b/src/ids/uuid.gleam @@ -5,8 +5,8 @@ //// import gleam/bit_array -import gleam/result import gleam/erlang +import gleam/result @external(erlang, "crypto", "strong_rand_bytes") fn crypto_strong_rand_bytes(n: Int) -> BitArray @@ -74,7 +74,8 @@ pub fn decode_v7( }) } -fn cast(raw_uuid: BitArray) -> Result(String, String) { +@internal +pub fn cast(raw_uuid: BitArray) -> Result(String, String) { case raw_uuid { << a1:size(4), @@ -157,7 +158,8 @@ fn cast(raw_uuid: BitArray) -> Result(String, String) { } } -fn dump(uuid: BitArray) -> Result(BitArray, String) { +@internal +pub fn dump(uuid: BitArray) -> Result(BitArray, String) { case uuid { << a1, diff --git a/test/ids/typeid_test.gleam b/test/ids/typeid_test.gleam new file mode 100644 index 0000000..a784c40 --- /dev/null +++ b/test/ids/typeid_test.gleam @@ -0,0 +1,99 @@ +import gleam/bit_array +import gleam/string +import gleeunit/should +import ids/typeid + +pub fn gen_test() { + typeid.generate("test") + |> should.be_ok + + typeid.generate("te_st") + |> should.be_ok + + typeid.generate("123") + |> should.be_error + + let assert Ok(id) = typeid.generate("test") + + id + |> string.starts_with("test_") + |> should.be_true + + id + |> string.length + |> should.equal(31) +} + +pub fn gen_empty_prefix_test() { + let assert Ok(id) = typeid.generate("") + + id + |> string.contains("_") + |> should.be_false + + id + |> string.length + |> should.equal(26) +} + +pub fn from_uuid_test() { + typeid.from_uuid("test", "wobble") + |> should.be_error + + typeid.from_uuid("test", "018fcec0-b44b-7ce2-b187-2f08349beab9") + |> should.be_ok + + let assert Ok(id) = + typeid.from_uuid("test", "018fcec0-b44b-7ce2-b187-2f08349beab9") + + id + |> should.equal("test_01hz7c1d2bfkhb31sf10t9qtns") +} + +pub fn decode_test() { + let assert Ok(id1) = typeid.generate("test") + let assert Ok(id2) = typeid.generate("") + let assert Ok(id3) = + typeid.from_uuid("test", "018fcec0-b44b-7ce2-b187-2f08349beab9") + + id1 + |> typeid.decode + |> should.be_ok + + id2 + |> typeid.decode + |> should.be_ok + + "" + |> typeid.decode + |> should.be_error + + "lkjgijkldsa" + |> typeid.decode + |> should.be_error + + let assert Ok(#(prefix1, suffix1)) = typeid.decode(id1) + let assert Ok(#(prefix2, _suffix2)) = typeid.decode(id2) + let assert Ok(#(_prefix3, suffix3)) = typeid.decode(id3) + + prefix1 + |> should.equal("test") + + prefix2 + |> should.equal("") + + let assert << + _:size(64), + 45, + _:size(32), + 45, + _:size(32), + 45, + _:size(32), + 45, + _:size(96), + >> = bit_array.from_string(suffix1) + + suffix3 + |> should.equal("018fcec0-b44b-7ce2-b187-2f08349beab9") +}