├── .tool-versions ├── test ├── test_helper.exs ├── support │ └── generators.ex ├── shortuuid │ ├── core_test.exs │ └── builder_test.exs └── shortuuid_test.exs ├── .formatter.exs ├── bench ├── decode.exs └── encode.exs ├── .gitignore ├── lib ├── shortuuid.ex └── shortuuid │ ├── behaviour.ex │ ├── builder.ex │ └── core.ex ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── mix.exs ├── mix.lock ├── CHANGELOG.md └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir v1.18.0-otp-27 2 | erlang 27.2 3 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Code.require_file("test/support/generators.ex") 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /bench/decode.exs: -------------------------------------------------------------------------------- 1 | Benchee.run(%{ 2 | "decode/1" => fn -> 3 | ShortUUID.decode("keATfB8JP2ggT7U9JZrpV9") 4 | end 5 | }) -------------------------------------------------------------------------------- /bench/encode.exs: -------------------------------------------------------------------------------- 1 | Benchee.run(%{ 2 | "encode/1 hyphenated uuid string" => fn -> 3 | ShortUUID.encode("2a162ee5-02f4-4701-9e87-72762cbce5e2") 4 | end, 5 | 6 | "encode/1 unhyphenated uuid string" => fn -> 7 | ShortUUID.encode("2a162ee502f447019e8772762cbce5e2") 8 | end, 9 | 10 | "encode/1 uuid string with braces" => fn -> 11 | ShortUUID.encode("{2a162ee5-02f4-4701-9e87-72762cbce5e2}") 12 | end 13 | }) 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | shortuuid-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | .elixir_ls 29 | -------------------------------------------------------------------------------- /lib/shortuuid.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID do 2 | @moduledoc """ 3 | ShortUUID is a module for encoding and decoding UUIDs using a base57 alphabet. 4 | 5 | ## Functions 6 | 7 | - `encode/1` - Encodes a UUID into a shorter string using base57 alphabet 8 | - `encode!/1` - Same as encode/1 but raises an error on failure 9 | - `decode/1` - Decodes a shortened string back into a UUID 10 | - `decode!/1` - Same as decode/1 but raises an error on failure 11 | 12 | ## Example 13 | 14 | iex> ShortUUID.encode("550e8400-e29b-41d4-a716-446655440000") 15 | {:ok, "H9cNmGXLEc8NWcZzSThA9S"} 16 | 17 | iex> ShortUUID.decode("H9cNmGXLEc8NWcZzSThA9S") 18 | {:ok, "550e8400-e29b-41d4-a716-446655440000"} 19 | 20 | For custom alphabets and more options, see `ShortUUID.Builder`. 21 | To implement your own compatible ShortUUID module, use `ShortUUID.Behaviour`. 22 | """ 23 | use ShortUUID.Builder, alphabet: :base57_shortuuid 24 | end 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-22.04 12 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 13 | strategy: 14 | matrix: 15 | elixir: ["1.14", "1.15", "1.16"] 16 | otp: ["25.3.2.3"] 17 | env: 18 | MIX_ENV: test 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Setup elixir 25 | uses: erlef/setup-beam@v1 26 | with: 27 | elixir-version: ${{ matrix.elixir }} 28 | otp-version: ${{ matrix.otp }} 29 | 30 | - name: Install Dependencies 31 | run: mix deps.get 32 | 33 | - name: Validate Formatting 34 | run: mix format --check-formatted 35 | 36 | - name: Run Credo 37 | run: mix credo --strict 38 | 39 | - name: Run Tests 40 | run: MIX_ENV=test mix coveralls.github -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2024 Goran Pedić 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.Mixfile do 2 | use Mix.Project 3 | 4 | @name "ShortUUID" 5 | @version "4.1.0" 6 | @url "https://github.com/gpedic/ex_shortuuid" 7 | 8 | def project do 9 | [ 10 | app: :shortuuid, 11 | name: @name, 12 | version: @version, 13 | elixir: "~> 1.4", 14 | test_coverage: [tool: ExCoveralls], 15 | preferred_cli_env: [ 16 | coveralls: :test, 17 | "coveralls.detail": :test, 18 | "coveralls.post": :test, 19 | "coveralls.html": :test, 20 | "coveralls.cobertura": :test 21 | ], 22 | aliases: aliases(), 23 | build_embedded: Mix.env() == :prod, 24 | start_permanent: Mix.env() == :prod, 25 | package: package(), 26 | docs: docs(), 27 | deps: deps() 28 | ] 29 | end 30 | 31 | def application do 32 | [extra_applications: []] 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:elixir_uuid, "~> 1.2", only: :test}, 38 | {:stream_data, "~> 0.5", only: :test}, 39 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 40 | {:excoveralls, "~> 0.18", only: :test}, 41 | {:benchee, "~> 1.3", only: [:dev, :test], runtime: false}, 42 | {:dialyxir, "~> 1.3", only: [:dev], runtime: false}, 43 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false} 44 | ] 45 | end 46 | 47 | defp aliases do 48 | [ 49 | "bench.encode": ["run bench/encode.exs"], 50 | "bench.decode": ["run bench/decode.exs"] 51 | ] 52 | end 53 | 54 | defp docs do 55 | [ 56 | extras: [ 57 | "CHANGELOG.md": [], 58 | "LICENSE.md": [title: "License"], 59 | "README.md": [title: "Overview"] 60 | ], 61 | main: "readme", 62 | source_ref: "v#{@version}", 63 | source_url: @url, 64 | formatters: ["html"] 65 | ] 66 | end 67 | 68 | defp package do 69 | # These are the default files included in the package 70 | [ 71 | name: :shortuuid, 72 | description: "ShortUUID - generate concise, unambiguous, URL-safe UUIDs", 73 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 74 | maintainers: ["Goran Pedić"], 75 | licenses: ["MIT"], 76 | links: %{"GitHub" => @url} 77 | ] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/shortuuid/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.Behaviour do 2 | @moduledoc """ 3 | Defines the behavior for ShortUUID-compatible modules. 4 | 5 | This behavior ensures consistent interface across different ShortUUID implementations. 6 | Any module implementing this behavior should provide encode/decode functionality 7 | for converting UUIDs to shorter string representations and back. 8 | 9 | ## Required Callbacks 10 | 11 | - `encode/1` - Encodes a standard UUID into a shorter string 12 | - `encode!/1` - Encodes a UUID, raising an exception on invalid input 13 | - `decode/1` - Decodes a shortened UUID string back into standard UUID format 14 | - `decode!/1` - Decodes a shortened UUID, raising an exception on invalid input 15 | """ 16 | 17 | @doc """ 18 | Encodes a UUID string into a shorter string representation. 19 | 20 | ## Parameters 21 | 22 | - `uuid` - A standard UUID string (with or without hyphens) 23 | 24 | ## Returns 25 | 26 | - `{:ok, encoded}` - Successfully encoded string 27 | - `{:error, message}` - Error with descriptive message 28 | """ 29 | @callback encode(uuid :: String.t()) :: {:ok, String.t()} | {:error, String.t()} 30 | 31 | @doc """ 32 | Encodes a UUID string into a shorter string representation. 33 | Raises an ArgumentError if the input is invalid. 34 | 35 | ## Parameters 36 | 37 | - `uuid` - A standard UUID string (with or without hyphens) 38 | 39 | ## Returns 40 | 41 | - `encoded` - Successfully encoded string 42 | 43 | ## Raises 44 | 45 | - `ArgumentError` - If the input is invalid 46 | """ 47 | @callback encode!(uuid :: String.t()) :: String.t() | no_return() 48 | 49 | @doc """ 50 | Decodes a shortened UUID string back into standard UUID format. 51 | 52 | ## Parameters 53 | 54 | - `string` - A shortened UUID string 55 | 56 | ## Returns 57 | 58 | - `{:ok, uuid}` - Successfully decoded UUID 59 | - `{:error, message}` - Error with descriptive message 60 | """ 61 | @callback decode(string :: String.t()) :: {:ok, String.t()} | {:error, String.t()} 62 | 63 | @doc """ 64 | Decodes a shortened UUID string back into standard UUID format. 65 | Raises an ArgumentError if the input is invalid. 66 | 67 | ## Parameters 68 | 69 | - `string` - A shortened UUID string 70 | 71 | ## Returns 72 | 73 | - `uuid` - Successfully decoded UUID 74 | 75 | ## Raises 76 | 77 | - `ArgumentError` - If the input is invalid 78 | """ 79 | @callback decode!(string :: String.t()) :: String.t() | no_return() 80 | end 81 | -------------------------------------------------------------------------------- /test/support/generators.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.TestGenerators do 2 | @moduledoc false 3 | use ExUnitProperties 4 | 5 | # Generates a V4 UUID with a 50% chance of removing hyphens, 6 | # randomly applies downcasing, upcasing, or capitalization. 7 | def uuid_generator do 8 | StreamData.map(StreamData.constant(:ok), fn _ -> 9 | uuid = UUID.uuid4() 10 | 11 | uuid = 12 | case :rand.uniform(3) do 13 | 1 -> uuid 14 | 2 -> String.replace(uuid, "-", "") 15 | 3 -> "{#{uuid}}" 16 | end 17 | 18 | case :rand.uniform(3) do 19 | 1 -> String.downcase(uuid) 20 | 2 -> String.upcase(uuid) 21 | 3 -> String.capitalize(uuid) 22 | end 23 | end) 24 | end 25 | 26 | def valid_emoji_alphabet_generator do 27 | # Creates a list of emoji characters 28 | # Misc Symbols & Pictographs 29 | all_chars = 30 | Enum.to_list(0x1F300..0x1F3FF) 31 | # Pictographs Extended 32 | |> Kernel.++(Enum.to_list(0x1F400..0x1F4FF)) 33 | # Transport & Map Symbols 34 | |> Kernel.++(Enum.to_list(0x1F500..0x1F5FF)) 35 | # Emoticons 36 | |> Kernel.++(Enum.to_list(0x1F600..0x1F64F)) 37 | # Transport & Map Symbols Extended 38 | |> Kernel.++(Enum.to_list(0x1F680..0x1F6FF)) 39 | # Supplemental Symbols & Pictographs 40 | |> Kernel.++(Enum.to_list(0x1F900..0x1F9FF)) 41 | |> List.to_string() 42 | |> String.graphemes() 43 | |> Enum.uniq() 44 | 45 | generate_alphabet(all_chars) 46 | end 47 | 48 | def valid_alphanumeric_alphabet_generator do 49 | # Creates a list of alphanumeric characters 50 | all_chars = 51 | ?0..?9 52 | |> Enum.to_list() 53 | |> Kernel.++(Enum.to_list(?A..?Z)) 54 | |> Kernel.++(Enum.to_list(?a..?z)) 55 | |> List.to_string() 56 | |> String.graphemes() 57 | 58 | generate_alphabet(all_chars) 59 | end 60 | 61 | def valid_url_safe_alphabet_generator do 62 | # Creates a list of URL-safe characters 63 | all_chars = 64 | ?0..?9 65 | |> Enum.to_list() 66 | |> Kernel.++(Enum.to_list(?A..?Z)) 67 | |> Kernel.++(Enum.to_list(?a..?z)) 68 | # URL-safe special chars 69 | |> Kernel.++(String.to_charlist("-._~+/")) 70 | |> List.to_string() 71 | |> String.graphemes() 72 | 73 | generate_alphabet(all_chars) 74 | end 75 | 76 | defp generate_alphabet(source_chars) do 77 | StreamData.bind(StreamData.integer(16..256), fn length -> 78 | subset = 79 | source_chars 80 | |> Enum.shuffle() 81 | |> Enum.take(length) 82 | 83 | StreamData.constant(Enum.join(subset)) 84 | end) 85 | end 86 | 87 | def invalid_uuid_generator do 88 | StreamData.one_of([ 89 | StreamData.string(:alphanumeric, min_length: 1, max_length: 50), 90 | StreamData.binary(min_length: 1, max_length: 50), 91 | StreamData.integer(), 92 | StreamData.boolean(), 93 | StreamData.constant(nil) 94 | ]) 95 | end 96 | 97 | def random_binary_uuid do 98 | StreamData.binary(length: 16) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, 5 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 6 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 8 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 9 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 10 | "ex_doc": {:hex, :ex_doc, "0.36.0", "9c4519323dfe2f88d0643c1b911d1d343105f5a9d75bd0eeb01274e7aa3e9ed7", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "fca4211955a7d107132ec549614c8b48d26245d7cb9349526f8bab71533ecf93"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 12 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 13 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 18 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 19 | "stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"}, 20 | } 21 | -------------------------------------------------------------------------------- /lib/shortuuid/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.Builder do 2 | @moduledoc """ 3 | ShortUUID.Builder is a module for encoding and decoding UUIDs (Universally Unique Identifiers) using various predefined or custom alphabets. 4 | 5 | ## Usage 6 | 7 | To create your module, simply `use` it and optionally provide an alphabet option: 8 | 9 | defmodule MyModule do 10 | use ShortUUID.Builder, alphabet: :base58 11 | end 12 | 13 | The `alphabet` option must be one of the predefined alphabets or a custom string (16+ unique characters). 14 | 15 | ## Predefined Alphabets 16 | 17 | The following predefined alphabets are available: 18 | 19 | - `:base57_shortuuid` - "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 20 | - `:base32` - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" 21 | - `:base32_crockford` - "0123456789ABCDEFGHJKMNPQRSTVWXYZ" 22 | - `:base32_hex` - "0123456789ABCDEFGHIJKLMNOPQRSTUV" 23 | - `:base32_z` - "ybndrfg8ejkmcpqxot1uwisza345h769" 24 | - `:base58` - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 25 | - `:base62` - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 26 | - `:base64` - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" 27 | - `:base64_url` - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" 28 | 29 | ## Functions 30 | 31 | The following functions are available for encoding and decoding UUIDs: 32 | 33 | - `encode/1` - Encodes a UUID using the specified or default alphabet. 34 | - `encode!/1` - Encodes a UUID using the specified or default alphabet, raising an error on failure. 35 | - `decode/1` - Decodes a string into a UUID using the specified or default alphabet. 36 | - `decode!/1` - Decodes a string into a UUID using the specified or default alphabet, raising an error on failure. 37 | 38 | ## Example 39 | 40 | iex> ShortUUID.encode("550e8400-e29b-41d4-a716-446655440000") 41 | {:ok, "H9cNmGXLEc8NWcZzSThA9S"} 42 | 43 | iex> ShortUUID.decode("H9cNmGXLEc8NWcZzSThA9S") 44 | {:ok, "550e8400-e29b-41d4-a716-446655440000"} 45 | """ 46 | 47 | @predefined_alphabets %{ 48 | base57_shortuuid: "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", 49 | base32: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", 50 | base32_crockford: "0123456789ABCDEFGHJKMNPQRSTVWXYZ", 51 | base32_hex: "0123456789ABCDEFGHIJKLMNOPQRSTUV", 52 | base32_z: "ybndrfg8ejkmcpqxot1uwisza345h769", 53 | base58: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", 54 | base62: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 55 | base64: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", 56 | base64_url: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" 57 | } 58 | 59 | @max_alphabet_length 256 60 | 61 | defmacro __using__(opts) do 62 | alphabet_expr = Keyword.fetch!(opts, :alphabet) 63 | expanded_alphabet = Macro.expand(alphabet_expr, __CALLER__) 64 | 65 | validated_alphabet = validate_alphabet!(expanded_alphabet) 66 | base = String.length(validated_alphabet) 67 | encoded_length = ceil(:math.log(2 ** 128) / :math.log(base)) 68 | padding_char = String.first(validated_alphabet) 69 | 70 | quote do 71 | import Bitwise 72 | alias ShortUUID.Core 73 | @behaviour ShortUUID.Behaviour 74 | 75 | @alphabet unquote(validated_alphabet) 76 | @padding_char unquote(padding_char) 77 | @alphabet_tuple @alphabet |> String.graphemes() |> List.to_tuple() 78 | @codepoint_index @alphabet |> String.graphemes() |> Enum.with_index() |> Map.new() 79 | @base String.length(@alphabet) 80 | @encoded_length unquote(encoded_length) 81 | 82 | @spec encode(String.t()) :: {:ok, String.t()} | {:error, String.t()} 83 | def encode(uuid), 84 | do: Core.encode_binary(uuid, @base, @alphabet_tuple, @encoded_length, @padding_char) 85 | 86 | @spec encode!(String.t()) :: String.t() | no_return() 87 | def encode!(uuid) do 88 | case encode(uuid) do 89 | {:ok, encoded} -> encoded 90 | {:error, msg} -> raise ArgumentError, message: msg 91 | end 92 | end 93 | 94 | @spec decode(String.t()) :: {:ok, String.t()} | {:error, String.t()} 95 | def decode(string), 96 | do: Core.decode_string(string, @base, @codepoint_index, @encoded_length) 97 | 98 | @spec decode!(String.t()) :: String.t() | no_return() 99 | def decode!(string) do 100 | case decode(string) do 101 | {:ok, decoded} -> decoded 102 | {:error, msg} -> raise ArgumentError, message: msg 103 | end 104 | end 105 | end 106 | end 107 | 108 | defp validate_alphabet!(alphabet) when is_atom(alphabet) do 109 | predefined = Map.get(@predefined_alphabets, alphabet) 110 | 111 | if is_nil(predefined), 112 | do: raise(ArgumentError, "Unknown alphabet atom: #{inspect(alphabet)}"), 113 | else: predefined 114 | end 115 | 116 | defp validate_alphabet!(alphabet) when is_binary(alphabet) do 117 | graphemes = String.graphemes(alphabet) 118 | 119 | if length(graphemes) < 16 do 120 | raise ArgumentError, "Alphabet must contain at least 16 characters" 121 | end 122 | 123 | if length(graphemes) > @max_alphabet_length do 124 | raise ArgumentError, 125 | "Alphabet must not contain more than #{@max_alphabet_length} characters" 126 | end 127 | 128 | if length(Enum.uniq(graphemes)) != length(graphemes) do 129 | raise ArgumentError, "Alphabet must not contain duplicate characters" 130 | end 131 | 132 | alphabet 133 | end 134 | 135 | defp validate_alphabet!(other) do 136 | raise ArgumentError, 137 | "Alphabet must be a literal string or supported atom, got: #{inspect(other)}" 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## v4.0.0 (25.12.2024) 9 | 10 | Breaking changes: 11 | * Dropped support for binary UUID input 12 | 13 | ### Added 14 | * Support for custom alphabets 15 | * Predefined alphabets (base32, base58, base62, base64, etc.) 16 | * `ShortUUID.Builder` module for creating custom ShortUUID modules 17 | 18 | ### Changed 19 | * Moved core functionality to `ShortUUID.Core` 20 | * Simplified main `ShortUUID` module interface 21 | * Improved error messages and validation 22 | 23 | ```elixir 24 | # Old v3.x code still works, the ShortUUID module uses the same alphabet as in V3 25 | # If you just want to keep using ShortUUID no changes are required 26 | ShortUUID.encode(uuid) 27 | 28 | # New in v4.x you can define use one of a list of predefined alphabets or define your own 29 | defmodule MyUUID do 30 | use ShortUUID.Builder, alphabet: :base58 31 | end 32 | 33 | MyUUID.encode(uuid) 34 | 35 | defmodule MyCustomUUID do 36 | use ShortUUID.Builder, alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" 37 | end 38 | 39 | MyCustomUUID.encode(uuid) 40 | ``` 41 | 42 | ## [Released] 43 | 44 | ## v3.0.0 (15.07.2023) 45 | 46 | Breaking change, ShortUUIDs created by `< v3.0.0` will produce bad results when decoded. 47 | The new output of `encode` is the reverse of the previous output. 48 | This follows a change in other languages ShortUUID libraries. 49 | 50 | To migrate ShortUUIDs created by `< v3.0.0` reverse them before passing to `decode`. 51 | 52 | ```elixir 53 | # UUID "00000001-0001-0001-0001-000000000001" encoded using v2.1.2 to "UD6ibhr3V4YXvriP822222" 54 | # reversing the encoded string before decode with v3.0.0 will produce the correct result 55 | iex> "UD6ibhr3V4YXvriP822222" |> String.reverse() |> ShortUUID.decode!() 56 | "00000001-0001-0001-0001-000000000001" 57 | ``` 58 | 59 | ### Changed 60 | * move most significant bit to the beginning of the encoded result similar to libraries in other languages (most importantly python shortuuid) 61 | * drop support for decoding of un-padded ShortUUIDs 62 | * drop support for formats other than regular hyphenated and unhyphenated UUIDs, MS format and binary UUIDs like are stored in PostgreSQL uuid type 63 | * refactor code for better readability 64 | * improve encode and decode performance 65 | 66 | ### Benchmarks 67 | Results are not comparable to previous benchmarks due to them being run on a different system 68 | ``` 69 | Operating System: macOS 70 | CPU Information: Apple M2 Max 71 | Number of Available Cores: 12 72 | Available memory: 32 GB 73 | Elixir 1.15.2 74 | Erlang 25.3.2.3 75 | 76 | Benchmark suite executing with the following configuration: 77 | warmup: 2 s 78 | time: 5 s 79 | memory time: 0 ns 80 | parallel: 1 81 | inputs: none specified 82 | ``` 83 | 84 | * v3.0.0 85 | ``` 86 | Name ips average deviation median 99th % 87 | encode/1 binary uuid 1212.57 K 0.82 μs ±1995.67% 0.75 μs 1.00 μs 88 | encode/1 unhyphenated uuid string 788.79 K 1.27 μs ±984.70% 1.13 μs 1.63 μs 89 | encode/1 hyphenated uuid string 753.56 K 1.33 μs ±1106.96% 1.17 μs 1.67 μs 90 | encode/1 uuid string with braces 722.36 K 1.38 μs ±1188.51% 1.21 μs 1.83 μs 91 | decode/1 1.15 M 868.43 ns ±1506.27% 751 ns 1334 ns 92 | ``` 93 | 94 | * v2.1.1 95 | ``` 96 | Name ips average deviation median 99th % 97 | encode/1 binary uuid 1018.43 K 0.98 μs ±1224.91% 0.88 μs 1.25 μs 98 | encode/1 unhyphenated uuid string 849.67 K 1.18 μs ±1171.07% 1.08 μs 1.42 μs 99 | encode/1 hyphenated uuid string 731.91 K 1.37 μs ±691.40% 1.29 μs 1.63 μs 100 | encode/1 uuid string with braces 569.16 K 1.76 μs ±833.41% 1.63 μs 2.17 μs 101 | decode/1 1.00 M 996.37 ns ±781.87% 918 ns 1376 ns 102 | ``` 103 | 104 | ## v2.1.1 (2019-02-18) 105 | 106 | * speed improvements 107 | 108 | Benchmarked on 2018 Macbook Pro 13 (non-touch), results are just a snapshot 109 | and not averaged. 110 | 111 | before: 112 | 113 | ``` 114 | ## ShortUUIDBench 115 | benchmark name iterations average time 116 | encode/1 uuid binary 500000 5.93 µs/op 117 | encode/1 uuid string not hyphenated 100000 10.71 µs/op 118 | encode/1 uuid string 100000 15.35 µs/op 119 | encode/1 uuid string with braces 100000 17.22 µs/op 120 | decode/1 100000 15.05 µs/op 121 | ``` 122 | 123 | after: 124 | 125 | ``` 126 | ## ShortUUIDBench 127 | benchmark name iterations average time 128 | encode/1 uuid binary 500000 3.54 µs/op 129 | encode/1 uuid string not hyphenated 500000 4.12 µs/op 130 | encode/1 uuid string 500000 4.54 µs/op 131 | encode/1 uuid string with braces 500000 5.84 µs/op 132 | decode/1 500000 7.97 µs/op 133 | ``` 134 | 135 | ## v2.1.0 (2019-02-08) 136 | 137 | * support directly encoding binary UUID 138 | 139 | ## v2.0.1 (2019-01-31) 140 | 141 | * add error fallbacks for encode/decode for the case where input is not string 142 | * update test cases 143 | * update docs 144 | 145 | ## v2.0.0 (2019-01-29) 146 | 147 | * drop support for custom alphabets 148 | * use fixed alphabet _23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz_ as it seems to be by far the most widely used shortuuid alphabet -------------------------------------------------------------------------------- /test/shortuuid/core_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.CoreTest do 2 | use ExUnit.Case, async: true 3 | doctest ShortUUID.Core 4 | 5 | alias ShortUUID.Core 6 | 7 | @test_alphabet "0123456789ABCDEF" 8 | @test_tuple @test_alphabet |> String.graphemes() |> List.to_tuple() 9 | @test_index @test_alphabet |> String.graphemes() |> Enum.with_index() |> Map.new() 10 | @test_base String.length(@test_alphabet) 11 | @test_length ceil(:math.log(2 ** 128) / :math.log(@test_base)) 12 | # Use first char for zero-padding 13 | @test_zero_char String.first(@test_alphabet) 14 | 15 | describe "parse_uuid/1" do 16 | test "handles various UUID formats" do 17 | uuid = "550e8400-e29b-41d4-a716-446655440000" 18 | assert {:ok, _} = Core.parse_uuid(uuid) 19 | assert {:ok, _} = Core.parse_uuid(String.replace(uuid, "-", "")) 20 | assert {:ok, _} = Core.parse_uuid("{#{uuid}}") 21 | assert {:ok, _} = Core.parse_uuid("{#{String.replace(uuid, "-", "")}}") 22 | end 23 | 24 | test "rejects invalid UUIDs" do 25 | assert {:error, _} = Core.parse_uuid("invalid") 26 | assert {:error, _} = Core.parse_uuid("") 27 | assert {:error, _} = Core.parse_uuid(nil) 28 | end 29 | end 30 | 31 | describe "encode_binary/5" do 32 | test "encodes UUIDs with dashes" do 33 | uuid = "550e8400-e29b-41d4-a716-446655440000" 34 | 35 | assert {:ok, encoded} = 36 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 37 | 38 | assert is_binary(encoded) 39 | assert String.length(encoded) == @test_length 40 | end 41 | 42 | test "encodes UUIDs without dashes" do 43 | uuid = "550e8400e29b41d4a716446655440000" 44 | 45 | assert {:ok, encoded} = 46 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 47 | 48 | assert is_binary(encoded) 49 | assert String.length(encoded) == @test_length 50 | end 51 | 52 | test "encodes UUIDs with curly braces" do 53 | uuid = "{550e8400-e29b-41d4-a716-446655440000}" 54 | 55 | assert {:ok, encoded} = 56 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 57 | 58 | assert is_binary(encoded) 59 | assert String.length(encoded) == @test_length 60 | end 61 | 62 | test "handles zero padding correctly" do 63 | uuid = "00000000-0000-0000-0000-000000000000" 64 | 65 | {:ok, encoded} = 66 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 67 | 68 | assert String.starts_with?(encoded, @test_zero_char) 69 | end 70 | 71 | test "rejects invalid input" do 72 | assert {:error, _} = 73 | Core.encode_binary( 74 | "invalid", 75 | @test_base, 76 | @test_tuple, 77 | @test_length, 78 | @test_zero_char 79 | ) 80 | 81 | assert {:error, _} = 82 | Core.encode_binary("", @test_base, @test_tuple, @test_length, @test_zero_char) 83 | 84 | assert {:error, _} = 85 | Core.encode_binary(nil, @test_base, @test_tuple, @test_length, @test_zero_char) 86 | end 87 | 88 | test "rejects binary input" do 89 | binary = 90 | <<0x55, 0x0E, 0x84, 0x00, 0xE2, 0x9B, 0x41, 0xD4, 0xA7, 0x16, 0x44, 0x66, 0x55, 0x44, 91 | 0x00, 0x00>> 92 | 93 | assert {:error, "Invalid UUID"} = 94 | Core.encode_binary(binary, @test_base, @test_tuple, @test_length, @test_zero_char) 95 | end 96 | end 97 | 98 | describe "decode_string/4" do 99 | test "decodes valid strings" do 100 | uuid = "550e8400-e29b-41d4-a716-446655440000" 101 | 102 | {:ok, encoded} = 103 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 104 | 105 | assert {:ok, decoded} = Core.decode_string(encoded, @test_base, @test_index, @test_length) 106 | assert decoded == uuid 107 | end 108 | 109 | test "handles zero value correctly" do 110 | uuid = "00000000-0000-0000-0000-000000000000" 111 | 112 | {:ok, encoded} = 113 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 114 | 115 | assert String.starts_with?(encoded, @test_zero_char) 116 | assert {:ok, decoded} = Core.decode_string(encoded, @test_base, @test_index, @test_length) 117 | assert decoded == uuid 118 | end 119 | 120 | test "rejects invalid length" do 121 | assert {:error, _} = Core.decode_string("too-short", @test_base, @test_index, @test_length) 122 | end 123 | 124 | test "rejects invalid characters" do 125 | valid_length = String.duplicate("X", @test_length) 126 | assert {:error, _} = Core.decode_string(valid_length, @test_base, @test_index, @test_length) 127 | end 128 | end 129 | 130 | describe "round trip encoding/decoding" do 131 | test "preserves UUID through encode/decode cycle" do 132 | uuid = "550e8400-e29b-41d4-a716-446655440000" 133 | 134 | {:ok, encoded} = 135 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 136 | 137 | assert {:ok, ^uuid} = Core.decode_string(encoded, @test_base, @test_index, @test_length) 138 | end 139 | 140 | test "handles edge cases" do 141 | # nil UUID 142 | uuid = "00000000-0000-0000-0000-000000000000" 143 | 144 | {:ok, encoded} = 145 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 146 | 147 | assert String.starts_with?(encoded, @test_zero_char) 148 | assert {:ok, ^uuid} = Core.decode_string(encoded, @test_base, @test_index, @test_length) 149 | 150 | # max UUID 151 | uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" 152 | 153 | {:ok, encoded} = 154 | Core.encode_binary(uuid, @test_base, @test_tuple, @test_length, @test_zero_char) 155 | 156 | assert {:ok, ^uuid} = Core.decode_string(encoded, @test_base, @test_index, @test_length) 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /test/shortuuid/builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.BuilderTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | doctest ShortUUID.Builder 5 | 6 | import ShortUUID.TestGenerators 7 | import StreamData 8 | 9 | describe "custom alphabets" do 10 | defmodule CustomAlphabet do 11 | use ShortUUID.Builder, alphabet: "0123456789ABCDEF" 12 | end 13 | 14 | test "works with custom alphabet" do 15 | uuid = "00000000-0000-0000-0000-000000000001" 16 | {:ok, encoded} = CustomAlphabet.encode(uuid) 17 | {:ok, decoded} = CustomAlphabet.decode(encoded) 18 | assert decoded == uuid 19 | end 20 | 21 | test "uses first character for zero padding" do 22 | uuid = "00000000-0000-0000-0000-000000000000" 23 | {:ok, encoded} = CustomAlphabet.encode(uuid) 24 | # "0" is first char in alphabet 25 | assert String.starts_with?(encoded, "0") 26 | assert {:ok, ^uuid} = CustomAlphabet.decode(encoded) 27 | end 28 | 29 | test "supports unicode characters in custom alphabets" do 30 | defmodule UnicodeUUID do 31 | use ShortUUID.Builder, alphabet: "🌟💫✨⭐️🌙🌎🌍🌏🌑🌒🌓🌔🌕🌖🌗🌘" 32 | end 33 | 34 | uuid = UUID.uuid4() 35 | {:ok, encoded} = UnicodeUUID.encode(uuid) 36 | {:ok, decoded} = UnicodeUUID.decode(encoded) 37 | 38 | assert decoded == uuid 39 | assert String.length(encoded) == 32 40 | 41 | defmodule SmileyUUID do 42 | use ShortUUID.Builder, 43 | alphabet: "😀😃😄😁😅😂🤣😊😇😉😌😍🥰😘😋😛😜🤪😝🤑🤗🤔🤨😐😑😶😏😒🙄😬🤥😪😴🤤😷🤒🤕🤢🤮🤧🥵🥶🥴😵🤯🤠🥳😎🤓🧐😕😟🙁😓😮😯😲😳🥺😦😧😨😰" 44 | end 45 | 46 | {:ok, encoded2} = SmileyUUID.encode(uuid) 47 | {:ok, decoded2} = SmileyUUID.decode(encoded2) 48 | 49 | assert decoded2 == uuid 50 | assert String.length(encoded2) == 22 51 | end 52 | end 53 | 54 | describe "predefined alphabets" do 55 | test "all predefined alphabets handle encode/decode cycle" do 56 | test_uuid = "550e8400-e29b-41d4-a716-446655440000" 57 | nil_uuid = "00000000-0000-0000-0000-000000000000" 58 | 59 | predefined_alphabets = [ 60 | :base57_shortuuid, 61 | :base32, 62 | :base32_crockford, 63 | :base32_hex, 64 | :base32_z, 65 | :base58, 66 | :base62, 67 | :base64, 68 | :base64_url 69 | ] 70 | 71 | for alphabet <- predefined_alphabets do 72 | module_name = Module.concat(["ShortUUID", "BuilderTest", "#{alphabet}"]) 73 | 74 | Module.create( 75 | module_name, 76 | quote do 77 | use ShortUUID.Builder, alphabet: unquote(alphabet) 78 | end, 79 | Macro.Env.location(__ENV__) 80 | ) 81 | 82 | # Test regular UUID encoding/decoding 83 | {:ok, encoded} = module_name.encode(test_uuid) 84 | assert is_binary(encoded) 85 | assert {:ok, ^test_uuid} = module_name.decode(encoded) 86 | 87 | # Test nil UUID (all zeros) encoding/decoding 88 | {:ok, encoded_nil} = module_name.encode(nil_uuid) 89 | assert {:ok, ^nil_uuid} = module_name.decode(encoded_nil) 90 | end 91 | end 92 | end 93 | 94 | describe "validation" do 95 | test "rejects invalid alphabets" do 96 | assert_raise ArgumentError, "Unknown alphabet atom: :not_an_alphabet", fn -> 97 | defmodule UnknownAlphabet do 98 | use ShortUUID.Builder, alphabet: :not_an_alphabet 99 | end 100 | end 101 | 102 | assert_raise ArgumentError, "Alphabet must contain at least 16 characters", fn -> 103 | defmodule TooShortAlphabet do 104 | use ShortUUID.Builder, alphabet: "abc" 105 | end 106 | end 107 | 108 | assert_raise ArgumentError, "Alphabet must not contain duplicate characters", fn -> 109 | defmodule DuplicateChars do 110 | use ShortUUID.Builder, alphabet: "AABCDEFGHIJKLMNOPQRSTUVWXYZ234567" 111 | end 112 | end 113 | 114 | assert_raise ArgumentError, 115 | "Alphabet must be a literal string or supported atom, got: 12345", 116 | fn -> 117 | defmodule InvalidType do 118 | use ShortUUID.Builder, alphabet: 12_345 119 | end 120 | end 121 | 122 | assert_raise ArgumentError, 123 | "Alphabet must be a literal string or supported atom, got: [\"a\", \"b\", \"c\"]", 124 | fn -> 125 | defmodule InvalidFunctionCall do 126 | use ShortUUID.Builder, alphabet: ["a", "b", "c"] 127 | end 128 | end 129 | end 130 | 131 | test "rejects too long alphabets" do 132 | assert_raise ArgumentError, "Alphabet must not contain more than 256 characters", fn -> 133 | defmodule TooLongAlphabet do 134 | use ShortUUID.Builder, alphabet: unquote(String.duplicate("*", 257)) 135 | end 136 | end 137 | end 138 | end 139 | 140 | @tag property: true 141 | test "custom alphabets maintain encoding length" do 142 | alphabet_generator = 143 | one_of([ 144 | valid_emoji_alphabet_generator(), 145 | valid_alphanumeric_alphabet_generator(), 146 | valid_url_safe_alphabet_generator() 147 | ]) 148 | 149 | check all( 150 | test_alphabet <- alphabet_generator, 151 | uuid <- uuid_generator() 152 | ) do 153 | module_name = Module.concat(["ShortUUID", "BuilderTest", "Test#{System.unique_integer()}"]) 154 | 155 | Module.create( 156 | module_name, 157 | quote do 158 | use ShortUUID.Builder, alphabet: unquote(test_alphabet) 159 | end, 160 | Macro.Env.location(__ENV__) 161 | ) 162 | 163 | {:ok, encoded} = module_name.encode(uuid) 164 | expected_length = ceil(:math.log(2 ** 128) / :math.log(String.length(test_alphabet))) 165 | assert String.length(encoded) == expected_length 166 | end 167 | end 168 | 169 | @tag property: true 170 | test "encode/decode works with randomly generated valid alphabets" do 171 | alphabet_generator = 172 | one_of([ 173 | valid_emoji_alphabet_generator(), 174 | valid_alphanumeric_alphabet_generator(), 175 | valid_url_safe_alphabet_generator() 176 | ]) 177 | 178 | check all(test_alphabet <- alphabet_generator) do 179 | uuid = UUID.uuid4() 180 | 181 | module_name = Module.concat(["ShortUUID", "BuilderTest", "Dyn#{System.unique_integer()}"]) 182 | 183 | Module.create( 184 | module_name, 185 | quote do 186 | use ShortUUID.Builder, alphabet: unquote(test_alphabet) 187 | end, 188 | Macro.Env.location(__ENV__) 189 | ) 190 | 191 | {:ok, encoded} = module_name.encode(uuid) 192 | {:ok, decoded} = module_name.decode(encoded) 193 | 194 | assert decoded == uuid 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /lib/shortuuid/core.ex: -------------------------------------------------------------------------------- 1 | defmodule ShortUUID.Core do 2 | @moduledoc """ 3 | Core module for ShortUUID encoding and decoding. 4 | 5 | This module provides the core functionality for encoding and decoding UUIDs 6 | using various alphabets. It includes functions for parsing UUIDs, encoding 7 | them into shorter strings, and decoding those strings back into UUIDs. 8 | 9 | ## Functions 10 | 11 | - `parse_uuid/1` - Parses a UUID string into a normalized format. 12 | - `encode_binary/5` - Encodes a UUID into a shorter string using the specified alphabet. 13 | - `decode_string/4` - Decodes a shortened string back into a UUID. 14 | - `format_uuid/1` - Formats an integer value as a UUID string. 15 | - `encode_int/5` - Encodes an integer value into a string using the specified alphabet. 16 | - `decode_to_int/3` - Decodes a string into an integer value using the specified alphabet. 17 | - `int_to_string/4` - Converts an integer value into a string using the specified alphabet. 18 | """ 19 | 20 | import Bitwise 21 | 22 | @type uuid_string :: String.t() 23 | @type normalized_uuid :: String.t() 24 | @type short_uuid :: String.t() 25 | 26 | @spec parse_uuid(String.t()) :: {:ok, normalized_uuid} | {:error, String.t()} 27 | @doc """ 28 | Parses and normalizes various UUID string formats. 29 | 30 | ## Examples 31 | 32 | iex> ShortUUID.Core.parse_uuid("550e8400-e29b-41d4-a716-446655440000") 33 | {:ok, "550e8400e29b41d4a716446655440000"} 34 | 35 | iex> ShortUUID.Core.parse_uuid("{550e8400-e29b-41d4-a716-446655440000}") 36 | {:ok, "550e8400e29b41d4a716446655440000"} 37 | 38 | iex> ShortUUID.Core.parse_uuid("not-a-uuid") 39 | {:error, "Invalid UUID"} 40 | """ 41 | def parse_uuid( 42 | <> 44 | ) do 45 | {:ok, 46 | <>} 48 | end 49 | 50 | def parse_uuid(<<_::binary-size(32)>> = uuid), do: {:ok, uuid} 51 | 52 | def parse_uuid(<>), do: parse_uuid(uuid) 53 | def parse_uuid(<>), do: {:ok, uuid} 54 | 55 | def parse_uuid(_), do: {:error, "Invalid UUID"} 56 | 57 | @spec encode_binary(uuid_string, pos_integer, tuple, pos_integer, String.t()) :: 58 | {:ok, short_uuid} | {:error, String.t()} 59 | @doc """ 60 | Encodes a UUID string into a shorter string using the specified alphabet and base. 61 | Takes a UUID string, base number, alphabet tuple, desired length, and padding character. 62 | 63 | ## Examples 64 | 65 | iex> alphabet = String.graphemes("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") |> List.to_tuple() 66 | iex> ShortUUID.Core.encode_binary("550e8400-e29b-41d4-a716-446655440000", 58, alphabet, 22, "1") 67 | {:ok, "BWBeN28Vb7cMEx7Ym8AUzs"} 68 | 69 | iex> alphabet = String.graphemes("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") |> List.to_tuple() 70 | iex> ShortUUID.Core.encode_binary("invalid", 58, alphabet, 22, "1") 71 | {:error, "Invalid UUID"} 72 | """ 73 | def encode_binary(input, base, alphabet_tuple, encoded_length, padding) do 74 | with {:ok, bin_uuid} <- parse_uuid(input), 75 | {:ok, decoded} <- Base.decode16(bin_uuid, case: :mixed) do 76 | encode_int(decoded, base, alphabet_tuple, encoded_length, padding) 77 | else 78 | _ -> {:error, "Invalid UUID"} 79 | end 80 | end 81 | 82 | @spec encode_int(binary, pos_integer, tuple, pos_integer, String.t()) :: 83 | {:ok, short_uuid} | {:error, String.t()} 84 | @doc """ 85 | Encodes a 128-bit integer into a string using the specified base and alphabet. 86 | Pads the result to the desired length using the padding character. 87 | """ 88 | def encode_int(<>, base, alphabet_tuple, encoded_length, padding_char) do 89 | encoded = 90 | int_to_string(int_value, base, alphabet_tuple) 91 | |> pad_string(encoded_length, padding_char) 92 | 93 | if String.length(encoded) == encoded_length do 94 | {:ok, encoded} 95 | else 96 | {:error, "Encoding resulted in incorrect length"} 97 | end 98 | end 99 | 100 | defp pad_string(string, length, padding_char) do 101 | padding_length = length - String.length(string) 102 | String.duplicate(to_string(padding_char), max(0, padding_length)) <> string 103 | end 104 | 105 | @spec decode_string(String.t(), pos_integer, map, pos_integer) :: 106 | {:ok, uuid_string} | {:error, String.t()} 107 | @doc """ 108 | Decodes a shortened string back into a UUID using the specified base and alphabet. 109 | Validates the input length and ensures the decoded value is within valid UUID range. 110 | 111 | ## Examples 112 | 113 | iex> alphabet = String.graphemes("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") |> Enum.with_index() |> Map.new() 114 | iex> ShortUUID.Core.decode_string("BWBeN28Vb7cMEx7Ym8AUzs", 58, alphabet, 22) 115 | {:ok, "550e8400-e29b-41d4-a716-446655440000"} 116 | 117 | iex> alphabet = String.graphemes("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") |> Enum.with_index() |> Map.new() 118 | iex> ShortUUID.Core.decode_string("invalid", 58, alphabet, 22) 119 | {:error, "Invalid input"} 120 | """ 121 | def decode_string(string, base, codepoint_index, encoded_length) when is_binary(string) do 122 | with true <- String.length(string) == encoded_length, 123 | {:ok, value} <- decode_to_int(string, base, codepoint_index), 124 | true <- value >>> 128 == 0 do 125 | # Format the UUID after verifying it's within valid range 126 | {:ok, format_uuid(value)} 127 | else 128 | _ -> {:error, "Invalid input"} 129 | end 130 | end 131 | 132 | def decode_string(_, _, _, _), do: {:error, "Invalid input"} 133 | 134 | @spec format_uuid(non_neg_integer) :: uuid_string 135 | @doc """ 136 | Formats a 128-bit integer as a standard UUID string with dashes. 137 | Converts the integer to a 32-character hex string and inserts dashes in the correct positions. 138 | 139 | ## Examples 140 | 141 | iex> ShortUUID.Core.format_uuid(0x550e8400e29b41d4a716446655440000) 142 | "550e8400-e29b-41d4-a716-446655440000" 143 | """ 144 | def format_uuid(int_value) when is_integer(int_value) do 145 | <> 146 | |> Base.encode16(case: :lower) 147 | |> insert_dashes() 148 | end 149 | 150 | defp insert_dashes( 151 | <> 153 | ) do 154 | a <> "-" <> b <> "-" <> c <> "-" <> d <> "-" <> e 155 | end 156 | 157 | # Decodes a string into its integer representation using the specified base and alphabet. 158 | # Returns an error if any character in the string is not in the alphabet. 159 | defp decode_to_int(string, base, codepoint_index) do 160 | string 161 | |> String.graphemes() 162 | |> Enum.reduce_while({:ok, 0}, fn char, {:ok, acc} -> 163 | case Map.fetch(codepoint_index, char) do 164 | {:ok, value} -> {:cont, {:ok, acc * base + value}} 165 | # Return error instead of continuing 166 | :error -> {:halt, {:error, "Invalid character"}} 167 | end 168 | end) 169 | end 170 | 171 | # Converts an integer to a string using the specified base and alphabet. 172 | # Uses tail recursion with an accumulator for efficiency. 173 | defp int_to_string(number, base, alphabet_tuple, acc \\ []) 174 | defp int_to_string(0, _, _, acc), do: to_string(acc) 175 | 176 | defp int_to_string(number, base, alphabet_tuple, acc) when number > 0 do 177 | int_to_string( 178 | div(number, base), 179 | base, 180 | alphabet_tuple, 181 | [elem(alphabet_tuple, rem(number, base)) | acc] 182 | ) 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShortUUID 2 | 3 | ![Build Status](https://github.com/gpedic/ex_shortuuid/actions/workflows/ci.yml/badge.svg?branch=master) 4 | [![Coverage Status](https://coveralls.io/repos/github/gpedic/ex_shortuuid/badge.svg)](https://coveralls.io/github/gpedic/ex_shortuuid) 5 | [![Module Version](https://img.shields.io/hexpm/v/shortuuid.svg)](https://hex.pm/packages/shortuuid) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/shortuuid/) 7 | [![Total Download](https://img.shields.io/hexpm/dt/shortuuid.svg)](https://hex.pm/packages/shortuuid) 8 | [![License](https://img.shields.io/hexpm/l/shortuuid.svg)](https://github.com/gpedic/ex_shortuuid/blob/master/LICENSE.md) 9 | [![Last Updated](https://img.shields.io/github/last-commit/gpedic/shortuuid.svg)](https://github.com/gpedic/ex_shortuuid/commits/master) 10 | 11 | 12 | 13 | ShortUUID is a lightweight Elixir library for generating short, unique IDs in URLs. It turns standard UUIDs into smaller strings ideal for use in URLs. 14 | You can choose from a set of predefined alphabets or define your own. 15 | The default alphabet includes lowercase letters, uppercase letters, and digits, omitting characters like 'l', '1', 'I', 'O', and '0' to keep them readable. 16 | 17 | **Note:** Different ShortUUID implementations be compatible as long as they use the same alphabet. However, there is no official standard, so if you plan to use ShortUUID with other libraries, it's a good idea to research and test for compatibility. 18 | 19 | Unlike some other solutions, ShortUUID does not produce UUIDs on its own as there are already plenty of libraries to do so. To generate UUIDs, use libraries such as 20 | [Elixir UUID](https://github.com/zyro/elixir-uuid), [Erlang UUID](https://github.com/okeuday/uuid) and also [Ecto](https://hexdocs.pm/ecto/Ecto.UUID.html) as it can generate version 4 UUIDs. 21 | 22 | ShortUUID supports common UUID formats and is case-insensitive. 23 | 24 | ## Compatibility 25 | 26 | ### v4.0.0 breaking changes 27 | 28 | Raw binary UUID input (as `<<...>>`) is no longer supported. UUIDs must be provided as strings in standard UUID format (`"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`) or as 32-character hex strings without hyphens. 29 | 30 | Examples of supported formats: 31 | ```elixir 32 | # Supported 33 | "550e8400-e29b-41d4-a716-446655440000" # With hyphens 34 | "550e8400e29b41d4a716446655440000" # Without hyphens 35 | 36 | # No longer supported in v4.0.0 37 | <<85, 14, 132, 0, 226, 155, 65, 212, 167, 22, 68, 102, 85, 68, 0, 0>> 38 | ``` 39 | 40 | ### v3.0.0 breaking changes 41 | 42 | Changed bit order and padding behavior to align with other language implementations: 43 | - Most significant bits are now encoded first 44 | - Padding characters appear at the end of the string 45 | - Compatible with Python's [shortuuid](https://github.com/skorokithakis/shortuuid) v1.0.0+ and Node.js [short-uuid](https://github.com/oculus42/short-uuid) 46 | 47 | Before `v3.0.0` 48 | ```elixir 49 | 50 | iex> "00000001-0001-0001-0001-000000000001" |> ShortUUID.encode 51 | {:ok, "UD6ibhr3V4YXvriP822222"} 52 | 53 | ``` 54 | 55 | After `v3.0.0` 56 | ```elixir 57 | 58 | iex> "00000001-0001-0001-0001-000000000001" |> ShortUUID.encode 59 | {:ok, "222228PirvXY4V3rhbi6DU"} 60 | 61 | ``` 62 | 63 | To migrate ShortUUIDs created using `< v3.0.0` reverse them before passing to `decode`. 64 | 65 | ```elixir 66 | # UUID "00000001-0001-0001-0001-000000000001" encoded using v2.1.2 to "UD6ibhr3V4YXvriP822222" 67 | # reversing the encoded string before decode with v3.0.0 will produce the correct result 68 | iex> "UD6ibhr3V4YXvriP822222" |> String.reverse() |> ShortUUID.decode!() 69 | "00000001-0001-0001-0001-000000000001" 70 | ``` 71 | 72 | *Warning:* Decoding ShortUUIDs created using a version `< v3.0.0` without reversing the string first will not fail but produce an incorrect result 73 | 74 | ```elixir 75 | iex> "UD6ibhr3V4YXvriP822222" |> ShortUUID.decode!() === "00000001-0001-0001-0001-000000000001" 76 | false 77 | iex> "UD6ibhr3V4YXvriP822222" |> ShortUUID.decode() 78 | {:ok, "933997ef-eb92-293f-b202-2a879fc84be9"} 79 | ``` 80 | 81 | ## Installation 82 | 83 | Add `:shortuuid` to your list of dependencies in `mix.exs`: 84 | 85 | ```elixir 86 | def deps do 87 | [ 88 | {:shortuuid, "~> 4.0"} 89 | ] 90 | end 91 | ``` 92 | 93 | ## Examples 94 | 95 | ```elixir 96 | iex> "f98e80e7-9923-4173-8408-98f8254912ad" |> ShortUUID.encode 97 | {:ok, "nQtAWSRQ6ByybDtRs7dQwE"} 98 | 99 | iex> "f98e80e7-9923-4173-8408-98f8254912ad" |> ShortUUID.encode! 100 | "nQtAWSRQ6ByybDtRs7dQwE" 101 | 102 | iex> "nQtAWSRQ6ByybDtRs7dQwE" |> ShortUUID.decode 103 | {:ok, "f98e80e7-9923-4173-8408-98f8254912ad"} 104 | 105 | iex> "nQtAWSRQ6ByybDtRs7dQwE" |> ShortUUID.decode! 106 | "f98e80e7-9923-4173-8408-98f8254912ad" 107 | ``` 108 | 109 | ## Using ShortUUID with Ecto 110 | 111 | If you would like to use ShortUUID with Ecto schemas try [Ecto.ShortUUID](https://github.com/gpedic/ecto_shortuuid). 112 | 113 | It provides a custom Ecto type which allows for ShortUUID primary and foreign keys while staying compatible with `:binary_key` (`EctoUUID`). 114 | 115 | ## Custom Alphabets 116 | 117 | Starting with version `v4.0.0`, ShortUUID allows you to define custom alphabets for encoding and decoding UUIDs. You can use predefined alphabets or define your own. 118 | 119 | ### Restrictions 120 | 121 | - The alphabet must contain at least 16 unique characters. 122 | - The alphabet must not contain duplicate characters. 123 | 124 | ### Predefined Alphabets 125 | 126 | Starting with version `v4.0.0`, the following predefined alphabets are available: 127 | 128 | - `:base57_shortuuid` - "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 129 | - `:base32` - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" 130 | - `:base32_crockford` - "0123456789ABCDEFGHJKMNPQRSTVWXYZ" 131 | - `:base32_hex` - "0123456789ABCDEFGHIJKLMNOPQRSTUV" 132 | - `:base32_z` - "ybndrfg8ejkmcpqxot1uwisza345h769" 133 | - `:base58` - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 134 | - `:base62` - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 135 | - `:base64` - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" 136 | - `:base64_url` - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" 137 | 138 | ### Using a custom or predefined alphabet 139 | 140 | ```elixir 141 | defmodule MyBase58UUID do 142 | use ShortUUID.Builder, alphabet: :base58 143 | end 144 | 145 | defmodule MyCustomUUID do 146 | use ShortUUID.Builder, alphabet: "0123456789ABCDEF" 147 | end 148 | 149 | iex> MyBase58UUID.encode("550e8400-e29b-41d4-a716-446655440000") 150 | {:ok, "BWBeN28Vb7cMEx7Ym8AUzs"} 151 | 152 | iex> MyBase58UUID.decode("BWBeN28Vb7cMEx7Ym8AUzs") 153 | {:ok, "550e8400-e29b-41d4-a716-446655440000"} 154 | ``` 155 | 156 | ### Just for fun 157 | 158 | Since v4.0.0 alphabets are not limited to alphanumeric characters either 159 | 160 | ```elixir 161 | defmodule UnicodeUUID do 162 | use ShortUUID.Builder, alphabet: "🌟💫✨⭐️🌙🌎🌍🌏🌑🌒🌓🌔🌕🌖🌗🌘" 163 | end 164 | 165 | iex> UnicodeUUID.encode("550e8400-e29b-41d4-a716-446655440000") 166 | {:ok, "🌎🌎🌟🌗🌑🌙🌟🌟🌗✨🌒🌔🌙💫🌖🌙🌓🌏💫🌍🌙🌙🌍🌍🌎🌎🌙🌙🌟🌟🌟🌟"} 167 | 168 | 169 | defmodule SmileyUUID do 170 | use ShortUUID.Builder, alphabet: "😀😊😄😍🥰😘😜🤪😋🤔😌🧐😐😑😶😮😲😱😴🥱😪😢😭😤😎🤓😇😈👻👽🤖🤡💀" 171 | end 172 | 173 | iex> SmileyUUID.encode("550e8400-e29b-41d4-a716-446655440000") 174 | {:ok, "😊🤪😢😘💀🥰😲😊🤡🤖🤔😊😘😤👽🤓👻😊👽😲😋😀😭😇😲🤖"} 175 | 176 | ``` 177 | 178 | ## Implementing Compatible Modules 179 | 180 | Starting with version `v4.1.0`, ShortUUID provides a behavior that you can implement to create compatible modules. 181 | This is useful if you want to create your own ShortUUID-compatible module with custom functionality while maintaining a consistent interface. 182 | 183 | ```elixir 184 | defmodule MyCustomShortUUID do 185 | @behaviour ShortUUID.Behaviour 186 | 187 | @spec encode(String.t()) :: {:ok, String.t()} | {:error, String.t()} 188 | def encode(uuid) do 189 | # Your custom implementation here 190 | {:ok, "encoded_" <> uuid} 191 | end 192 | 193 | @spec encode!(String.t()) :: String.t() | no_return() 194 | def encode!(uuid) do 195 | case encode(uuid) do 196 | {:ok, encoded} -> encoded 197 | {:error, msg} -> raise ArgumentError, message: msg 198 | end 199 | end 200 | 201 | @spec decode(String.t()) :: {:ok, String.t()} | {:error, String.t()} 202 | def decode(encoded) do 203 | # Your custom implementation here 204 | if String.starts_with?(encoded, "encoded_") do 205 | {:ok, String.replace_prefix(encoded, "encoded_", "")} 206 | else 207 | {:error, "Invalid format"} 208 | end 209 | end 210 | 211 | @spec decode!(String.t()) :: String.t() | no_return() 212 | def decode!(encoded) do 213 | case decode(encoded) do 214 | {:ok, decoded} -> decoded 215 | {:error, msg} -> raise ArgumentError, message: msg 216 | end 217 | end 218 | end 219 | ``` 220 | 221 | The `ShortUUID.Behaviour` requires implementing four callbacks: 222 | - `encode/1` - Encode a UUID into a shorter representation 223 | - `encode!/1` - Same as encode/1 but raises on error 224 | - `decode/1` - Decode a shortened UUID back to standard format 225 | - `decode!/1` - Same as decode/1 but raises on error 226 | 227 | Using this behavior ensures that your custom implementation will be compatible with code expecting a ShortUUID-compatible module. 228 | 229 | ## Documentation 230 | 231 | Look up the full documentation at [https://hexdocs.pm/shortuuid](https://hexdocs.pm/shortuuid). 232 | 233 | ## Acknowledgments 234 | 235 | Inspired by [shortuuid](https://github.com/skorokithakis/shortuuid). 236 | 237 | ## Copyright and License 238 | 239 | Copyright (c) 2024 Goran Pedić 240 | 241 | This work is free. You can redistribute it and/or modify it under the 242 | terms of the MIT License. 243 | 244 | See the [LICENSE.md](./LICENSE.md) file for more details. -------------------------------------------------------------------------------- /test/shortuuid_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShortUUIDTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | doctest ShortUUID 5 | 6 | import ShortUUID.TestGenerators 7 | 8 | @niluuid "00000000-0000-0000-0000-000000000000" 9 | @base57_alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 10 | 11 | describe "encode/1" do 12 | test "should pad shorter ints" do 13 | uuid = "00000001-0001-0001-0001-000000000001" 14 | assert {:ok, "222228PirvXY4V3rhbi6DU"} = ShortUUID.encode(uuid) 15 | end 16 | 17 | test "should handle encoding nil UUID" do 18 | assert {:ok, "2222222222222222222222"} = ShortUUID.encode(@niluuid) 19 | assert "2222222222222222222222" = ShortUUID.encode!(@niluuid) 20 | end 21 | 22 | test "should encode regular UUIDs" do 23 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 24 | ShortUUID.encode("2a162ee5-02f4-4701-9e87-72762cbce5e2") 25 | 26 | assert "9VprZJ9U7Tgg2PJ8BfTAek" = ShortUUID.encode!("2a162ee5-02f4-4701-9e87-72762cbce5e2") 27 | 28 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 29 | ShortUUID.encode("2a162ee502f447019e8772762cbce5e2") 30 | 31 | assert "9VprZJ9U7Tgg2PJ8BfTAek" = ShortUUID.encode!("2a162ee502f447019e8772762cbce5e2") 32 | end 33 | 34 | test "should encode UUIDs in curly braces (MS format)" do 35 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 36 | ShortUUID.encode("{2a162ee5-02f4-4701-9e87-72762cbce5e2}") 37 | 38 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 39 | ShortUUID.encode("{2A162EE5-02F4-4701-9E87-72762CBCE5E2}") 40 | 41 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 42 | ShortUUID.encode("{2a162ee502f447019e8772762cbce5e2}") 43 | 44 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 45 | ShortUUID.encode("{2A162EE502F447019E8772762CBCE5E2}") 46 | end 47 | 48 | test "should encode uppercase UUIDs" do 49 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 50 | ShortUUID.encode("2A162EE5-02F4-4701-9E87-72762CBCE5E2") 51 | 52 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 53 | ShortUUID.encode("2A162EE502F447019E8772762CBCE5E2") 54 | 55 | assert {:ok, "9VprZJ9U7Tgg2PJ8BfTAek"} = 56 | ShortUUID.encode("2a162EE5-02f4-4701-9e87-72762CBCE5e2") 57 | end 58 | 59 | test "should not allow invalid UUIDs" do 60 | assert {:error, _} = ShortUUID.encode("") 61 | assert {:error, _} = ShortUUID.encode(0) 62 | assert {:error, _} = ShortUUID.encode(1) 63 | assert {:error, _} = ShortUUID.encode(nil) 64 | assert {:error, _} = ShortUUID.encode(true) 65 | assert {:error, _} = ShortUUID.decode(false) 66 | # has non hex value 67 | assert {:error, _} = ShortUUID.encode("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFG") 68 | # too short 69 | assert {:error, _} = ShortUUID.encode("FFFFFFFF-FFFF-FFFF-FFFF-58027") 70 | # too long 71 | assert {:error, _} = ShortUUID.encode("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFFF") 72 | end 73 | 74 | test "unicode in string errors" do 75 | assert {:error, "Invalid UUID"} = ShortUUID.encode("2á162ee502f447019e8772762cbce5e2") 76 | assert {:error, "Invalid UUID"} = ShortUUID.encode("2Ä162ee502f447019e8772762cbce5e2") 77 | end 78 | 79 | test "rejects binary input" do 80 | # Remove accepting binary input tests and replace with rejection tests 81 | assert {:error, "Invalid UUID"} = 82 | ShortUUID.encode( 83 | <<250, 98, 175, 128, 168, 97, 69, 108, 171, 119, 213, 103, 126, 46, 139, 168>> 84 | ) 85 | 86 | assert {:error, "Invalid UUID"} = 87 | ShortUUID.encode( 88 | <<0x2A, 0x16, 0x2E, 0xE5, 0x02, 0xF4, 0x47, 0x01, 0x9E, 0x87, 0x72, 0x76, 0x2C, 89 | 0xBC, 0xE5, 0xE2>> 90 | ) 91 | 92 | # min 93 | assert {:error, "Invalid UUID"} = 94 | ShortUUID.encode(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) 95 | 96 | # max 97 | assert {:error, "Invalid UUID"} = 98 | ShortUUID.encode( 99 | <<255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 100 | 255>> 101 | ) 102 | 103 | # more than 128 bit 104 | assert {:error, "Invalid UUID"} = ShortUUID.encode(<<1::size(136)>>) 105 | 106 | # less than 128 bit 107 | assert {:error, "Invalid UUID"} = ShortUUID.encode(<<1::size(120)>>) 108 | end 109 | end 110 | 111 | describe "encode!/1" do 112 | test "raises an ArgumentError for and invalid UUID" do 113 | assert_raise ArgumentError, fn -> 114 | ShortUUID.encode!("invalid-uuid") 115 | end 116 | end 117 | end 118 | 119 | describe "decode/1" do 120 | test "decodes a valid shortuuid" do 121 | assert {:ok, "2a162ee5-02f4-4701-9e87-72762cbce5e2"} = 122 | ShortUUID.decode("9VprZJ9U7Tgg2PJ8BfTAek") 123 | 124 | assert "2a162ee5-02f4-4701-9e87-72762cbce5e2" = ShortUUID.decode!("9VprZJ9U7Tgg2PJ8BfTAek") 125 | end 126 | 127 | test "returns an error for an invalid ShortUUID" do 128 | assert {:error, "Invalid input"} = ShortUUID.decode("invalid-shortuuid") 129 | assert {:error, "Invalid input"} = ShortUUID.decode("1eATfB8JP2ggT7U9JZrpV9") 130 | end 131 | 132 | test "fails to decode empty string to nil uuid" do 133 | assert {:error, "Invalid input"} = ShortUUID.decode("") 134 | 135 | assert_raise ArgumentError, fn -> 136 | ShortUUID.decode!("") 137 | end 138 | end 139 | 140 | test "handles decoding nil UUID" do 141 | assert {:ok, @niluuid} = ShortUUID.decode("2222222222222222222222") 142 | end 143 | 144 | test "raises ArgumentError on invalid string" do 145 | # invalid because contains letter not in alphabet 146 | assert_raise ArgumentError, fn -> 147 | ShortUUID.decode!("01lnotinalphabet") 148 | end 149 | 150 | # invalid because results in too large number 151 | assert_raise ArgumentError, fn -> 152 | ShortUUID.decode!("22222222222222222222223") 153 | end 154 | end 155 | 156 | test "fails when encoded value is > 128 bit" do 157 | assert {:error, _} = ShortUUID.decode("oZEq7ovRbLq6UnGMPwc8B6") 158 | end 159 | 160 | test "should not support legacy unpadded strings" do 161 | assert {:error, "Invalid input"} = ShortUUID.decode("") 162 | assert {:error, "Invalid input"} = ShortUUID.decode("222") 163 | end 164 | 165 | test "unicode in string errors" do 166 | assert {:error, _} = ShortUUID.decode("2á8cwPMGnU6qLbRvo7qEZo2") 167 | assert {:error, _} = ShortUUID.decode("2Ä8cwPMGnU6qLbRvo7qEZo2") 168 | end 169 | 170 | test "these should all error" do 171 | assert {:error, _} = ShortUUID.decode(nil) 172 | assert {:error, _} = ShortUUID.decode(0) 173 | assert {:error, _} = ShortUUID.decode(1) 174 | assert {:error, _} = ShortUUID.decode(true) 175 | assert {:error, _} = ShortUUID.decode(false) 176 | end 177 | 178 | test "reversed legacy short UUID will produce correct result" do 179 | assert {:ok, "00000001-0001-0001-0001-000000000001"} = 180 | "UD6ibhr3V4YXvriP822222" |> String.reverse() |> ShortUUID.decode() 181 | end 182 | end 183 | 184 | describe "decode!/1" do 185 | test "raises an ArgumentError for and invalid ShortUUID" do 186 | assert_raise ArgumentError, fn -> 187 | ShortUUID.decode!("invalid-shortuuid") 188 | end 189 | end 190 | end 191 | 192 | test "random UUID and shortUUID round-trip" do 193 | uuid = UUID.uuid4() 194 | {:ok, shortuuid} = ShortUUID.encode(uuid) 195 | {:ok, uuid2} = ShortUUID.decode(shortuuid) 196 | assert uuid == uuid2 197 | end 198 | 199 | # Update property test syntax 200 | @tag property: true 201 | test "uuid encoding and decoding (there and back again)", %{property: true} do 202 | check all(uuid <- uuid_generator()) do 203 | assert {:ok, encoded_uuid} = ShortUUID.encode(uuid) 204 | assert {:ok, decoded_uuid} = ShortUUID.decode(encoded_uuid) 205 | assert normalize(uuid) == decoded_uuid 206 | end 207 | end 208 | 209 | @tag property: true 210 | test "rejects all binary input", %{property: true} do 211 | check all(binary <- random_binary_uuid()) do 212 | assert {:error, "Invalid UUID"} = ShortUUID.encode(binary) 213 | end 214 | end 215 | 216 | @tag property: true 217 | test "encoded UUIDs maintain length invariant", %{property: true} do 218 | check all(uuid <- uuid_generator()) do 219 | {:ok, encoded} = ShortUUID.encode(uuid) 220 | # base57 length should always be 22 221 | assert String.length(encoded) == 22 222 | 223 | # Special case: nil UUID (all zeros) 224 | {:ok, encoded_nil} = ShortUUID.encode(@niluuid) 225 | assert String.length(encoded_nil) == 22 226 | # Should start with first char 227 | assert String.starts_with?(encoded_nil, "2") 228 | 229 | # Special case: max UUID (all ones) 230 | {:ok, encoded_max} = ShortUUID.encode("ffffffff-ffff-ffff-ffff-ffffffffffff") 231 | assert String.length(encoded_max) == 22 232 | end 233 | end 234 | 235 | @tag property: true 236 | test "encoded UUIDs only contain valid alphabet characters", %{property: true} do 237 | check all(uuid <- uuid_generator()) do 238 | {:ok, encoded} = ShortUUID.encode(uuid) 239 | # Verify that every character in the encoded string is in our alphabet 240 | assert encoded |> String.graphemes() |> Enum.all?(&String.contains?(@base57_alphabet, &1)) 241 | end 242 | end 243 | 244 | @tag property: true 245 | test "decoded UUIDs always match UUID format", %{property: true} do 246 | check all(uuid <- uuid_generator()) do 247 | {:ok, encoded} = ShortUUID.encode(uuid) 248 | {:ok, decoded} = ShortUUID.decode(encoded) 249 | 250 | assert Regex.match?( 251 | ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 252 | decoded 253 | ) 254 | end 255 | end 256 | 257 | @tag property: true 258 | test "encoding maintains ordering of UUIDs", %{property: true} do 259 | check all( 260 | uuid1 <- uuid_generator(), 261 | uuid2 <- uuid_generator() 262 | ) do 263 | {:ok, encoded1} = ShortUUID.encode(uuid1) 264 | {:ok, encoded2} = ShortUUID.encode(uuid2) 265 | normalized1 = normalize(uuid1) 266 | normalized2 = normalize(uuid2) 267 | 268 | # If UUIDs are ordered, their encodings should maintain that order 269 | assert normalized1 <= normalized2 == encoded1 <= encoded2 270 | end 271 | end 272 | 273 | @tag property: true 274 | test "invalid UUIDs are rejected", %{property: true} do 275 | check all(invalid <- invalid_uuid_generator()) do 276 | assert {:error, _} = ShortUUID.encode(invalid) 277 | end 278 | end 279 | 280 | defp normalize(uuid) do 281 | uuid 282 | # Remove non-hex characters 283 | |> String.replace(~r/[^a-f0-9]/i, "") 284 | # Convert to lowercase 285 | |> String.downcase() 286 | # Add hyphens 287 | |> String.replace(~r/(.{8})(.{4})(.{4})(.{4})(.{12})/, "\\1-\\2-\\3-\\4-\\5") 288 | end 289 | end 290 | --------------------------------------------------------------------------------