diff --git a/README.md b/README.md index 8acfcd2..692befa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ ## Supported - [CUID](https://github.com/ericelliott/cuid) -- [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) +- [UUID v4](https://en.wikipedia.org/wiki/Universally_unique_identifier) +- [UUID v7](https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#section-5.2) - [NanoID](https://github.com/ai/nanoid) - [ULID](https://github.com/ulid/spec) - [Snowflake ID](https://en.wikipedia.org/wiki/Snowflake_ID) @@ -27,4 +28,5 @@ gleam add ids 1. [Original CUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) 2. [Elixir CUID](https://github.com/duailibe/cuid) 3. [Ecto UUID](https://github.com/elixir-ecto/ecto/blob/v3.5.4/lib/ecto/uuid.ex) -4. [Rust Snowflake ID](https://github.com/BinChengZhao/snowflake-rs) +4. [Elixir UUID](https://github.com/bitwalker/uniq) +5. [Rust Snowflake ID](https://github.com/BinChengZhao/snowflake-rs) diff --git a/src/ids/uuid.gleam b/src/ids/uuid.gleam index 77b1be2..10cb86f 100644 --- a/src/ids/uuid.gleam +++ b/src/ids/uuid.gleam @@ -5,6 +5,8 @@ //// import gleam/bit_string +import gleam/result +import gleam/erlang @external(erlang, "crypto", "strong_rand_bytes") fn crypto_strong_rand_bytes(n: Int) -> BitString @@ -24,89 +26,213 @@ pub fn generate_v4() -> Result(String, String) { let <> = crypto_strong_rand_bytes(16) - let << - a1:size(4), - a2:size(4), - a3:size(4), - a4:size(4), - a5:size(4), - a6:size(4), - a7:size(4), - a8:size(4), - b1:size(4), - b2:size(4), - b3:size(4), - b4:size(4), - c1:size(4), - c2:size(4), - c3:size(4), - c4:size(4), - d1:size(4), - d2:size(4), - d3:size(4), - d4:size(4), - e1:size(4), - e2:size(4), - e3:size(4), - e4:size(4), - e5:size(4), - e6:size(4), - e7:size(4), - e8:size(4), - e9:size(4), - e10:size(4), - e11:size(4), - e12:size(4), - >> = <> + cast(<>) +} + +/// Generates a version 7 UUID. The version 7 UUID produced +/// by this function is generated using a cryptographically secure +/// random number generator and includes a unix timestamp. +/// +/// ### Usage +/// ```gleam +/// import ids/uuid +/// +/// let assert Ok(id) = uuid.generate_v7() +/// ``` +/// +pub fn generate_v7() -> Result(String, String) { + let timestamp = erlang.system_time(erlang.Millisecond) + generate_v7_from_timestamp(timestamp) +} + +/// Generates a version 7 UUID from a given unix timestamp in milliseconds. +pub fn generate_v7_from_timestamp(timestamp: Int) -> Result(String, String) { + let <<_:size(48), _:size(4), a:size(12), _:size(2), b:size(62)>> = + crypto_strong_rand_bytes(16) - let bitstr_id = << - e(a1), - e(a2), - e(a3), - e(a4), - e(a5), - e(a6), - e(a7), - e(a8), - 45, - e(b1), - e(b2), - e(b3), - e(b4), - 45, - e(c1), - e(c2), - e(c3), - e(c4), - 45, - e(d1), - e(d2), - e(d3), - e(d4), - 45, - e(e1), - e(e2), - e(e3), - e(e4), - e(e5), - e(e6), - e(e7), - e(e8), - e(e9), - e(e10), - e(e11), - e(e12), - >> + cast(<>) +} - case bit_string.to_string(bitstr_id) { - Ok(str_id) -> - str_id - |> Ok - Error(_) -> { - let error: String = "Error: BitString could not be converted to String." - error - |> Error +/// Decodes a version 7 UUID to #(timestamp, version, random_a, rfc_variant, random_b). +pub fn decode_v7( + uuid_v7: String, +) -> Result(#(Int, Int, BitString, Int, BitString), String) { + uuid_v7 + |> bit_string.from_string() + |> dump() + |> result.try(fn(d) { + case d { + << + timestamp:unsigned-size(48), + ver:unsigned-size(4), + a:bit_string-size(12), + var:unsigned-size(2), + b:bit_string-size(62), + >> -> Ok(#(timestamp, ver, a, var, b)) + _other -> Error("Error: Couldn't match raw UUID v7.") } + }) +} + +fn cast(raw_uuid: BitString) -> Result(String, String) { + case raw_uuid { + << + a1:size(4), + a2:size(4), + a3:size(4), + a4:size(4), + a5:size(4), + a6:size(4), + a7:size(4), + a8:size(4), + b1:size(4), + b2:size(4), + b3:size(4), + b4:size(4), + c1:size(4), + c2:size(4), + c3:size(4), + c4:size(4), + d1:size(4), + d2:size(4), + d3:size(4), + d4:size(4), + e1:size(4), + e2:size(4), + e3:size(4), + e4:size(4), + e5:size(4), + e6:size(4), + e7:size(4), + e8:size(4), + e9:size(4), + e10:size(4), + e11:size(4), + e12:size(4), + >> -> + << + e(a1), + e(a2), + e(a3), + e(a4), + e(a5), + e(a6), + e(a7), + e(a8), + 45, + e(b1), + e(b2), + e(b3), + e(b4), + 45, + e(c1), + e(c2), + e(c3), + e(c4), + 45, + e(d1), + e(d2), + e(d3), + e(d4), + 45, + e(e1), + e(e2), + e(e3), + e(e4), + e(e5), + e(e6), + e(e7), + e(e8), + e(e9), + e(e10), + e(e11), + e(e12), + >> + |> bit_string.to_string() + |> result.replace_error( + "Error: BitString could not be converted to String.", + ) + + _other -> Error("Error: Raw UUID is malformed.") + } +} + +fn dump(uuid: BitString) -> Result(BitString, String) { + case uuid { + << + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + 45, + b1, + b2, + b3, + b4, + 45, + c1, + c2, + c3, + c4, + 45, + d1, + d2, + d3, + d4, + 45, + e1, + e2, + e3, + e4, + e5, + e6, + e7, + e8, + e9, + e10, + e11, + e12, + >> -> + << + d(a1):size(4), + d(a2):size(4), + d(a3):size(4), + d(a4):size(4), + d(a5):size(4), + d(a6):size(4), + d(a7):size(4), + d(a8):size(4), + d(b1):size(4), + d(b2):size(4), + d(b3):size(4), + d(b4):size(4), + d(c1):size(4), + d(c2):size(4), + d(c3):size(4), + d(c4):size(4), + d(d1):size(4), + d(d2):size(4), + d(d3):size(4), + d(d4):size(4), + d(e1):size(4), + d(e2):size(4), + d(e3):size(4), + d(e4):size(4), + d(e5):size(4), + d(e6):size(4), + d(e7):size(4), + d(e8):size(4), + d(e9):size(4), + d(e10):size(4), + d(e11):size(4), + d(e12):size(4), + >> + |> Ok() + _other -> Error("Error: UUID is malformed.") } } @@ -130,3 +256,24 @@ fn e(n: Int) -> Int { 15 -> 102 } } + +fn d(n: Int) -> Int { + case n { + 48 -> 0 + 49 -> 1 + 50 -> 2 + 51 -> 3 + 52 -> 4 + 53 -> 5 + 54 -> 6 + 55 -> 7 + 56 -> 8 + 57 -> 9 + 97 -> 10 + 98 -> 11 + 99 -> 12 + 100 -> 13 + 101 -> 14 + 102 -> 15 + } +} diff --git a/test/ids/uuid_test.gleam b/test/ids/uuid_test.gleam index 8ecd7e3..5acf767 100644 --- a/test/ids/uuid_test.gleam +++ b/test/ids/uuid_test.gleam @@ -1,8 +1,9 @@ import gleeunit/should import ids/uuid import gleam/bit_string +import gleam/erlang -pub fn gen_test() { +pub fn gen_v4_test() { let assert Ok(id) = uuid.generate_v4() let assert << @@ -19,3 +20,38 @@ pub fn gen_test() { should.be_true(True) } + +pub fn gen_v7_test() { + let assert Ok(id_1) = uuid.generate_v7() + + let assert << + _:size(64), + 45, + _:size(32), + 45, + _:size(32), + 45, + _:size(32), + 45, + _:size(96), + >> = bit_string.from_string(id_1) + + should.be_true(True) +} + +pub fn decode_v7_test() { + let timestamp = erlang.system_time(erlang.Millisecond) + let assert Ok(id) = uuid.generate_v7_from_timestamp(timestamp) + + let assert Ok(#(timestamp, version, _random_a, rfc_variant, _random_b)) = + uuid.decode_v7(id) + timestamp + |> should.equal(timestamp) + version + |> should.equal(7) + rfc_variant + |> should.equal(2) + + uuid.decode_v7("123") + |> should.be_error() +}