├── .codecov.yml ├── .github └── workflows │ ├── main.yml │ └── monitor_spec_updates.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── pry ├── rake ├── rspec └── update_spec_case_files.sh ├── lib ├── typeid.rb └── typeid │ ├── uuid.rb │ ├── uuid │ └── base32.rb │ └── version.rb ├── spec ├── invalid.yml ├── invalid_spec.rb ├── lib │ ├── typeid │ │ ├── uuid │ │ │ └── base32_spec.rb │ │ └── uuid_spec.rb │ └── typeid_spec.rb ├── spec_helper.rb ├── valid.yml └── valid_spec.rb └── typeid.gemspec /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "diff, files" 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "Main" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | test: 11 | runs-on: "ubuntu-latest" 12 | 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | 16 | - uses: "ruby/setup-ruby@v1.152.0" 17 | with: 18 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 19 | 20 | - name: "Run specs" 21 | run: "bin/rspec" 22 | 23 | - uses: "codecov/codecov-action@v3" 24 | with: 25 | token: "${{ secrets.CODECOV_TOKEN }}" 26 | 27 | tag: 28 | needs: "test" 29 | if: github.ref == 'refs/heads/main' 30 | runs-on: "ubuntu-latest" 31 | permissions: 32 | contents: "write" 33 | 34 | outputs: 35 | previous-version: "${{ steps.tag.outputs.previous-version }}" 36 | current-version: "${{ steps.tag.outputs.current-version }}" 37 | 38 | steps: 39 | - uses: "actions/checkout@v3" 40 | with: 41 | fetch-depth: 2 42 | 43 | - id: "tag" 44 | uses: "salsify/action-detect-and-tag-new-version@v2" 45 | 46 | rubygems-push: 47 | needs: "tag" 48 | if: "${{ needs.tag.outputs.previous-version != needs.tag.outputs.current-version }}" 49 | runs-on: "ubuntu-latest" 50 | 51 | steps: 52 | - uses: "actions/checkout@v3" 53 | 54 | - uses: "ruby/setup-ruby@v1.152.0" 55 | with: 56 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 57 | 58 | - name: "Publish to RubyGems" 59 | run: | 60 | mkdir -p $HOME/.gem 61 | touch $HOME/.gem/credentials 62 | chmod 0600 $HOME/.gem/credentials 63 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 64 | gem build *.gemspec 65 | gem push *.gem 66 | env: 67 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" 68 | 69 | gpr-push: 70 | needs: "tag" 71 | if: "${{ needs.tag.outputs.previous-version != needs.tag.outputs.current-version }}" 72 | runs-on: "ubuntu-latest" 73 | permissions: 74 | packages: "write" 75 | 76 | steps: 77 | - uses: "actions/checkout@v3" 78 | 79 | - uses: "ruby/setup-ruby@v1.152.0" 80 | with: 81 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 82 | 83 | - name: "Publish to GPR" 84 | run: | 85 | mkdir -p $HOME/.gem 86 | touch $HOME/.gem/credentials 87 | chmod 0600 $HOME/.gem/credentials 88 | printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 89 | gem build *.gemspec 90 | gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem 91 | env: 92 | GEM_HOST_API_KEY: "Bearer ${{secrets.GITHUB_TOKEN}}" 93 | OWNER: "${{ github.repository_owner }}" 94 | -------------------------------------------------------------------------------- /.github/workflows/monitor_spec_updates.yml: -------------------------------------------------------------------------------- 1 | name: "Monitor spec updates" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Midnight-ly 6 | 7 | jobs: 8 | check: 9 | runs-on: "ubuntu-latest" 10 | permissions: 11 | contents: "write" 12 | pull-requests: "write" 13 | 14 | steps: 15 | - uses: "actions/checkout@v3" 16 | 17 | - name: "Update spec case files" 18 | run: "bin/update_spec_case_files.sh" 19 | 20 | - uses: "peter-evans/create-pull-request@v5" 21 | with: 22 | branch: "monitor-spec-updates" 23 | title: "Spec case updates" 24 | assignees: "broothie" 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | coverage 3 | .yardoc 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.0 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | typeid (0.2.2) 5 | uuid7 (~> 0.2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | coderay (1.1.3) 11 | diff-lcs (1.5.0) 12 | docile (1.4.0) 13 | method_source (1.0.0) 14 | pry (0.14.2) 15 | coderay (~> 1.1) 16 | method_source (~> 1.0) 17 | rake (13.0.6) 18 | rexml (3.2.5) 19 | rspec (3.12.0) 20 | rspec-core (~> 3.12.0) 21 | rspec-expectations (~> 3.12.0) 22 | rspec-mocks (~> 3.12.0) 23 | rspec-core (3.12.2) 24 | rspec-support (~> 3.12.0) 25 | rspec-expectations (3.12.3) 26 | diff-lcs (>= 1.2.0, < 2.0) 27 | rspec-support (~> 3.12.0) 28 | rspec-mocks (3.12.5) 29 | diff-lcs (>= 1.2.0, < 2.0) 30 | rspec-support (~> 3.12.0) 31 | rspec-support (3.12.1) 32 | simplecov (0.22.0) 33 | docile (~> 1.1) 34 | simplecov-html (~> 0.11) 35 | simplecov_json_formatter (~> 0.1) 36 | simplecov-cobertura (2.1.0) 37 | rexml 38 | simplecov (~> 0.19) 39 | simplecov-html (0.12.3) 40 | simplecov_json_formatter (0.1.4) 41 | uuid7 (0.2.0) 42 | zeitwerk (~> 2.4) 43 | zeitwerk (2.6.8) 44 | 45 | PLATFORMS 46 | x86_64-darwin-20 47 | x86_64-linux 48 | 49 | DEPENDENCIES 50 | pry (~> 0.14.2) 51 | rake (~> 13.0) 52 | rspec (~> 3.12) 53 | simplecov (~> 0.22.0) 54 | simplecov-cobertura (~> 2.1) 55 | typeid! 56 | 57 | BUNDLED WITH 58 | 2.2.3 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeID Ruby 2 | 3 | ### A Ruby implementation of [TypeIDs](https://github.com/jetpack-io/typeid) 4 | 5 | [![Gem Version](https://badge.fury.io/rb/typeid.svg)](https://badge.fury.io/rb/typeid) 6 | [![RubyDoc](https://img.shields.io/badge/dynamic/xml?url=https%3A%2F%2Frubydoc.info%2Fgems%2Ftypeid&query=%2F%2Fdiv%5B%40id%3D%22menu%22%5D%2Fspan%5B%40class%3D%22title%22%5D%2Fsmall%5Btext()%5D&label=RubyDoc)](https://rubydoc.info/gems/typeid) 7 | [![Main](https://github.com/broothie/typeid-ruby/actions/workflows/main.yml/badge.svg)](https://github.com/broothie/typeid-ruby/actions/workflows/main.yml) 8 | [![codecov](https://codecov.io/gh/broothie/typeid-ruby/branch/main/graph/badge.svg?token=9XjyMNIb4z)](https://codecov.io/gh/broothie/typeid-ruby) 9 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 10 | 11 | TypeIDs are a modern, **type-safe**, globally unique identifier based on the upcoming 12 | UUIDv7 standard. They provide a ton of nice properties that make them a great choice 13 | as the primary identifiers for your data in a database, APIs, and distributed systems. 14 | Read more about TypeIDs in their [spec](https://github.com/jetpack-io/typeid). 15 | 16 | This particular implementation provides a Ruby library for generating and parsing TypeIDs. 17 | 18 | ## Installation 19 | 20 | ### If using bundler 21 | 22 | ```shell 23 | bundle add typeid 24 | ``` 25 | 26 | ### If not 27 | 28 | ```shell 29 | gem install typeid 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```ruby 35 | require "typeid" #=> true 36 | 37 | id = TypeID.new("user") #=> # 38 | id.to_s #=> user_01h46z1k2cf2av8mp4r7we4697 39 | 40 | other_id = TypeID.from_string(id.to_s) #=> # 41 | id == other_id #=> true 42 | ``` 43 | 44 | ## Attributions 45 | 46 | This gem depends on [uuid7](https://github.com/sprql/uuid7-ruby) by [sprql](https://github.com/sprql). 47 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | 2 | task default: :list 3 | 4 | desc "List tasks" 5 | task :list do 6 | sh "rake -AT" 7 | end 8 | 9 | desc "Run specs" 10 | task specs: %i[bundle] do 11 | sh "bin/rspec" 12 | end 13 | 14 | desc "Release a new version" 15 | task release: %i[bump bundle] 16 | 17 | desc "Bump the version" 18 | task :bump do 19 | # If missing "bump", run: `cargo install --git https://github.com/broothie/bump` 20 | sh "bump lib/typeid/version.rb" 21 | end 22 | 23 | desc "Run bundle install" 24 | task :bundle do 25 | sh "bundle install" 26 | end 27 | 28 | desc "Run YARD server" 29 | task :yard do 30 | sh "yard server --reload" 31 | end 32 | 33 | desc "Update spec case files" 34 | task :update_spec_case_files do |spec| 35 | sh "bin/update_spec_case_files.sh" 36 | end 37 | -------------------------------------------------------------------------------- /bin/pry: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pry' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("pry", "pry") 30 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rake", "rake") 30 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/update_spec_case_files.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | echo "Update valid.yml" 4 | curl https://raw.githubusercontent.com/jetify-com/typeid/main/spec/valid.yml > spec/valid.yml 5 | 6 | echo "Update invalid.yml" 7 | curl https://raw.githubusercontent.com/jetify-com/typeid/main/spec/invalid.yml > spec/invalid.yml 8 | -------------------------------------------------------------------------------- /lib/typeid.rb: -------------------------------------------------------------------------------- 1 | require_relative "./typeid/uuid.rb" 2 | 3 | # Represents a TypeID. 4 | # Provides accessors to the underlying prefix, suffix, and UUID. 5 | # Can be treated as a string. 6 | # 7 | # To generate a new +TypeID+: 8 | # TypeID.new("foo") #=> # 9 | # 10 | # To parse a +TypeID+ from a string: 11 | # TypeID.from_string("foo_01h4vjdvzefw18zfwz5dxw5y8g") #=> # 12 | # 13 | # To parse a +TypeID+ from a UUID: 14 | # TypeID.from_uuid("foo", "01893726-efee-7f02-8fbf-9f2b7bc2f910") #=> # 15 | # 16 | # To create a +TypeID+ from a timestamp (in milliseconds since the Unix epoch): 17 | # TypeID.new("foo", timestamp: 1688847445998) #=> # 18 | class TypeID < String 19 | MAX_PREFIX_LENGTH = 63 20 | 21 | class Error < StandardError; end 22 | 23 | # @return [String] 24 | attr_reader :prefix 25 | 26 | # @return [String] 27 | attr_reader :suffix 28 | alias type prefix 29 | 30 | # Parses a +TypeID+ from a string. 31 | # 32 | # @param string [String] string representation of a +TypeID+ 33 | # @return [TypeID] 34 | def self.from_string(string) 35 | case string.rpartition("_") 36 | in ["", "", suffix] 37 | from("", suffix) 38 | 39 | in [prefix, "_", suffix] 40 | raise Error, "prefix cannot be empty when there's a separator" if prefix.empty? 41 | 42 | from(prefix, suffix) 43 | else 44 | raise Error, "invalid typeid: #{string}" 45 | end 46 | end 47 | 48 | # Parses a +TypeID+ given a prefix and a raw UUID string. 49 | # 50 | # @param prefix [String] 51 | # @param uuid [String] 52 | # @return [TypeID] 53 | def self.from_uuid(prefix, uuid) 54 | from(prefix, TypeID::UUID.from_string(uuid).base32) 55 | end 56 | 57 | # Creates a +TypeID+ given a prefix string and a suffix string. 58 | # 59 | # @param prefix [String] 60 | # @param suffix [String] 61 | # @return [TypeID] 62 | def self.from(prefix, suffix) 63 | new(prefix, suffix: suffix) 64 | end 65 | 66 | # Returns the +nil+ TypeID. 67 | # 68 | # @return [TypeID] 69 | def self.nil 70 | @nil ||= from("", "0" * TypeID::UUID::Base32::ENCODED_STRING_LENGTH) 71 | end 72 | 73 | # Creates a +TypeID+ given a prefix and an optional suffix or timestamp (in milliseconds since the Unix epoch). 74 | # When given only a prefix, generates a new +TypeID+. 75 | # When +suffix+ or +timestamp+ is provided, creates a +TypeID+ from the given value. 76 | # 77 | # @param prefix [String] 78 | # @param timestamp [Integer] milliseconds since the Unix epoch 79 | # @param suffix [String] base32-encoded UUID 80 | def initialize( 81 | prefix, 82 | timestamp: TypeID::UUID.timestamp, 83 | suffix: TypeID::UUID.generate(timestamp: timestamp).base32 84 | ) 85 | raise Error, "prefix length cannot be greater than #{MAX_PREFIX_LENGTH}" if prefix.length > MAX_PREFIX_LENGTH 86 | raise Error, "prefix must be lowercase ASCII characters" unless prefix.match?(/^[a-z_]*$/) 87 | raise Error, "prefix cannot start or end with an underscore" if prefix.start_with?("_") || prefix.end_with?("_") 88 | raise Error, "suffix must be #{TypeID::UUID::Base32::ENCODED_STRING_LENGTH} characters" unless suffix.length == TypeID::UUID::Base32::ENCODED_STRING_LENGTH 89 | raise Error, "suffix must only contain the letters in '#{TypeID::UUID::Base32::ALPHABET}'" unless suffix.chars.all? { |char| TypeID::UUID::Base32::ALPHABET.include?(char) } 90 | raise Error, "suffix must start with a 0-7 digit to avoid overflows" unless ("0".."7").cover?(suffix.chars.first) 91 | 92 | @prefix = prefix 93 | @suffix = suffix 94 | 95 | super(string) 96 | end 97 | 98 | # Returns the UUID component of the +TypeID+, parsed from the suffix. 99 | # 100 | # @return [TypeID::UUID] 101 | def uuid 102 | TypeID::UUID.from_base32(suffix) 103 | end 104 | 105 | # @return [String] 106 | def inspect 107 | "#<#{self.class.name} #{self}>" 108 | end 109 | 110 | private 111 | 112 | # @return [String] 113 | def string 114 | return suffix if prefix.empty? 115 | 116 | "#{prefix}_#{suffix}" 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/typeid/uuid.rb: -------------------------------------------------------------------------------- 1 | require "uuid7" 2 | require_relative "./uuid/base32.rb" 3 | 4 | class TypeID < String 5 | # Represents a UUID. Can be treated as a string. 6 | class UUID < String 7 | # @return [Array] 8 | attr_reader :bytes 9 | 10 | # Utility method to generate a timestamp as milliseconds since the Unix epoch. 11 | # 12 | # @return [Integer] 13 | def self.timestamp 14 | Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) 15 | end 16 | 17 | # Generates a new +UUID+, using gem "uuid7". 18 | # 19 | # @param timestamp [Integer] milliseconds since the Unix epoch 20 | # @return [TypeID::UUID] 21 | def self.generate(timestamp: self.class.timestamp) 22 | from_string(UUID7.generate(timestamp: timestamp)) 23 | end 24 | 25 | # Parses a +UUID+ from a base32 +String+. 26 | # 27 | # @param string [String] base32-encoded UUID 28 | # @return [TypeID::UUID] 29 | def self.from_base32(string) 30 | new(TypeID::UUID::Base32.decode(string)) 31 | end 32 | 33 | # Parses a +UUID+ from a raw +String+. 34 | # 35 | # @param string [String] raw UUID 36 | # @return [TypeID::UUID] 37 | def self.from_string(string) 38 | bytes = string 39 | .tr("-", "") 40 | .chars 41 | .each_slice(2) 42 | .map { |pair| pair.join.to_i(16) } 43 | 44 | new(bytes) 45 | end 46 | 47 | # Initializes a +UUID+ from an array of bytes. 48 | # 49 | # @param bytes [Array] size 16 byte array 50 | def initialize(bytes) 51 | @bytes = bytes 52 | 53 | super(string) 54 | end 55 | 56 | # Returns the +UUID+ encoded as a base32 +String+. 57 | # 58 | # @return [String] 59 | def base32 60 | TypeID::UUID::Base32.encode(bytes) 61 | end 62 | 63 | # Returns the timestamp of the +UUID+ as milliseconds since the Unix epoch. 64 | # 65 | # @return [Integer] 66 | def timestamp 67 | bytes[0..5] 68 | .map.with_index { |byte, index| byte << (5 - index) * 8 } 69 | .inject(:|) 70 | end 71 | 72 | # @return [String] 73 | def inspect 74 | "#<#{self.class.name} #{to_s}>" 75 | end 76 | 77 | private 78 | 79 | # @return [String] 80 | def string 81 | bytes 82 | .map 83 | .with_index { |byte, index| ([4, 6, 8, 10].include?(index) ? "-%02x" : "%02x") % byte } 84 | .join 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/typeid/uuid/base32.rb: -------------------------------------------------------------------------------- 1 | class TypeID < String 2 | class UUID < String 3 | 4 | # Provides utilities for encoding and decoding UUIDs to and from base32. 5 | # Based on https://github.com/jetpack-io/typeid-go/blob/341e2b135e0609db272e6400f2f551487725824a/base32/base32.go. 6 | module Base32 7 | DECODED_BYTE_ARRAY_LENGTH = 16 8 | ENCODED_STRING_LENGTH = 26 9 | 10 | class Error < StandardError; end 11 | 12 | # Crockford's Base32 alphabet. 13 | ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz".freeze 14 | 15 | # Byte to index table for O(1) lookups when unmarshaling. 16 | # We use 0xFF as sentinel value for invalid indexes. 17 | DEC = [ 18 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 19 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 20 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 21 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 22 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 23 | 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0xFF, 0xFF, 24 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 25 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 26 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 27 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0A, 0x0B, 0x0C, 28 | 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0xFF, 0x12, 0x13, 0xFF, 0x14, 29 | 0x15, 0xFF, 0x16, 0x17, 0x18, 0x19, 0x1A, 0xFF, 0x1B, 0x1C, 30 | 0x1D, 0x1E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 31 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 32 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 33 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 34 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 35 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 36 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 37 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 38 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 39 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 40 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 41 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 42 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 43 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 44 | ].freeze 45 | 46 | # Encodes a size 16 byte +Array+ into a size 26 +String+. 47 | # Based on https://github.com/jetpack-io/typeid-go/blob/341e2b135e0609db272e6400f2f551487725824a/base32/base32.go#L14. 48 | # 49 | # @param bytes [Array] size 16 byte array 50 | # @return [String] 51 | def self.encode(bytes) 52 | raise Error, "invalid bytes size" unless bytes.size == DECODED_BYTE_ARRAY_LENGTH 53 | 54 | encoded = Array.new(ENCODED_STRING_LENGTH, 0) 55 | 56 | # 10 byte timestamp 57 | encoded[0] = ALPHABET[(bytes[0] & 224) >> 5] 58 | encoded[1] = ALPHABET[bytes[0] & 31] 59 | encoded[2] = ALPHABET[(bytes[1] & 248) >> 3] 60 | encoded[3] = ALPHABET[((bytes[1] & 7) << 2) | ((bytes[2] & 192) >> 6)] 61 | encoded[4] = ALPHABET[(bytes[2] & 62) >> 1] 62 | encoded[5] = ALPHABET[((bytes[2] & 1) << 4) | ((bytes[3] & 240) >> 4)] 63 | encoded[6] = ALPHABET[((bytes[3] & 15) << 1) | ((bytes[4] & 128) >> 7)] 64 | encoded[7] = ALPHABET[(bytes[4] & 124) >> 2] 65 | encoded[8] = ALPHABET[((bytes[4] & 3) << 3) | ((bytes[5] & 224) >> 5)] 66 | encoded[9] = ALPHABET[bytes[5] & 31] 67 | 68 | # 16 bytes of entropy 69 | encoded[10] = ALPHABET[(bytes[6] & 248) >> 3] 70 | encoded[11] = ALPHABET[((bytes[6] & 7) << 2) | ((bytes[7] & 192) >> 6)] 71 | encoded[12] = ALPHABET[(bytes[7] & 62) >> 1] 72 | encoded[13] = ALPHABET[((bytes[7] & 1) << 4) | ((bytes[8] & 240) >> 4)] 73 | encoded[14] = ALPHABET[((bytes[8] & 15) << 1) | ((bytes[9] & 128) >> 7)] 74 | encoded[15] = ALPHABET[(bytes[9] & 124) >> 2] 75 | encoded[16] = ALPHABET[((bytes[9] & 3) << 3) | ((bytes[10] & 224) >> 5)] 76 | encoded[17] = ALPHABET[bytes[10] & 31] 77 | encoded[18] = ALPHABET[(bytes[11] & 248) >> 3] 78 | encoded[19] = ALPHABET[((bytes[11] & 7) << 2) | ((bytes[12] & 192) >> 6)] 79 | encoded[20] = ALPHABET[(bytes[12] & 62) >> 1] 80 | encoded[21] = ALPHABET[((bytes[12] & 1) << 4) | ((bytes[13] & 240) >> 4)] 81 | encoded[22] = ALPHABET[((bytes[13] & 15) << 1) | ((bytes[14] & 128) >> 7)] 82 | encoded[23] = ALPHABET[(bytes[14] & 124) >> 2] 83 | encoded[24] = ALPHABET[((bytes[14] & 3) << 3) | ((bytes[15] & 224) >> 5)] 84 | encoded[25] = ALPHABET[bytes[15] & 31] 85 | 86 | encoded.join 87 | end 88 | 89 | # Decodes a size 26 +String+ into a size 16 byte +Array+. 90 | # Based on https://github.com/jetpack-io/typeid-go/blob/341e2b135e0609db272e6400f2f551487725824a/base32/base32.go#L82. 91 | # Each line needs an extra `& 0xFF` because the elements are +Integer+s, which don't truncate on left shifts. 92 | # 93 | # @param string [String] size 26 base32-encoded string 94 | # @return [Array] 95 | def self.decode(string) 96 | bytes = string.bytes 97 | 98 | raise Error, "invalid length" unless bytes.length == ENCODED_STRING_LENGTH 99 | raise Error, "invalid base32 character" if bytes.any? { |byte| DEC[byte] == 0xFF } 100 | 101 | output = Array.new(DECODED_BYTE_ARRAY_LENGTH, 0) 102 | 103 | # 6 bytes timestamp (48 bits) 104 | output[0] = ((DEC[bytes[0]] << 5) | DEC[bytes[1]]) & 0xFF 105 | output[1] = ((DEC[bytes[2]] << 3) | (DEC[bytes[3]] >> 2)) & 0xFF 106 | output[2] = ((DEC[bytes[3]] << 6) | (DEC[bytes[4]] << 1) | (DEC[bytes[5]] >> 4)) & 0xFF 107 | output[3] = ((DEC[bytes[5]] << 4) | (DEC[bytes[6]] >> 1)) & 0xFF 108 | output[4] = ((DEC[bytes[6]] << 7) | (DEC[bytes[7]] << 2) | (DEC[bytes[8]] >> 3)) & 0xFF 109 | output[5] = ((DEC[bytes[8]] << 5) | DEC[bytes[9]]) & 0xFF 110 | 111 | # 10 bytes of entropy (80 bits) 112 | output[6] = ((DEC[bytes[10]] << 3) | (DEC[bytes[11]] >> 2)) & 0xFF 113 | output[7] = ((DEC[bytes[11]] << 6) | (DEC[bytes[12]] << 1) | (DEC[bytes[13]] >> 4)) & 0xFF 114 | output[8] = ((DEC[bytes[13]] << 4) | (DEC[bytes[14]] >> 1)) & 0xFF 115 | output[9] = ((DEC[bytes[14]] << 7) | (DEC[bytes[15]] << 2) | (DEC[bytes[16]] >> 3)) & 0xFF 116 | output[10] = ((DEC[bytes[16]] << 5) | DEC[bytes[17]]) & 0xFF 117 | output[11] = ((DEC[bytes[18]] << 3) | DEC[bytes[19]] >> 2) & 0xFF 118 | output[12] = ((DEC[bytes[19]] << 6) | (DEC[bytes[20]] << 1) | (DEC[bytes[21]] >> 4)) & 0xFF 119 | output[13] = ((DEC[bytes[21]] << 4) | (DEC[bytes[22]] >> 1)) & 0xFF 120 | output[14] = ((DEC[bytes[22]] << 7) | (DEC[bytes[23]] << 2) | (DEC[bytes[24]] >> 3)) & 0xFF 121 | output[15] = ((DEC[bytes[24]] << 5) | DEC[bytes[25]]) & 0xFF 122 | 123 | output 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/typeid/version.rb: -------------------------------------------------------------------------------- 1 | class TypeID < String 2 | VERSION = "0.2.2".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/invalid.yml: -------------------------------------------------------------------------------- 1 | # This file contains test data that should be treated as *invalid* TypeIDs by 2 | # conforming implementations. 3 | # 4 | # Each example contains an invalid TypeID string. Implementations are expected 5 | # to throw an error when attempting to parse/validate these strings. 6 | # 7 | # Last updated: 2024-04-10 (for version 0.3.0 of the spec) 8 | 9 | - name: prefix-uppercase 10 | typeid: "PREFIX_00000000000000000000000000" 11 | description: "The prefix should be lowercase with no uppercase letters" 12 | 13 | - name: prefix-numeric 14 | typeid: "12345_00000000000000000000000000" 15 | description: "The prefix can't have numbers, it needs to be alphabetic" 16 | 17 | - name: prefix-period 18 | typeid: "pre.fix_00000000000000000000000000" 19 | description: "The prefix can't have symbols, it needs to be alphabetic" 20 | 21 | # Test removed in v0.3.0 – we now allow underscores in the prefix 22 | # - name: prefix-underscore 23 | # typeid: "pre_fix_00000000000000000000000000" 24 | # description: "The prefix can't have symbols, it needs to be alphabetic" 25 | 26 | - name: prefix-non-ascii 27 | typeid: "préfix_00000000000000000000000000" 28 | description: "The prefix can only have ascii letters" 29 | 30 | - name: prefix-spaces 31 | typeid: " prefix_00000000000000000000000000" 32 | description: "The prefix can't have any spaces" 33 | 34 | - name: prefix-64-chars 35 | # 123456789 123456789 123456789 123456789 123456789 123456789 1234 36 | typeid: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl_00000000000000000000000000" 37 | description: "The prefix can't be 64 characters, it needs to be 63 characters or less" 38 | 39 | - name: separator-empty-prefix 40 | typeid: "_00000000000000000000000000" 41 | description: "If the prefix is empty, the separator should not be there" 42 | 43 | - name: separator-empty 44 | typeid: "_" 45 | description: "A separator by itself should not be treated as the empty string" 46 | 47 | - name: suffix-short 48 | typeid: "prefix_1234567890123456789012345" 49 | description: "The suffix can't be 25 characters, it needs to be exactly 26 characters" 50 | 51 | - name: suffix-long 52 | typeid: "prefix_123456789012345678901234567" 53 | description: "The suffix can't be 27 characters, it needs to be exactly 26 characters" 54 | 55 | - name: suffix-spaces 56 | # This example has the right length, so that the failure is caused by the space 57 | # and not the suffix length 58 | typeid: "prefix_1234567890123456789012345 " 59 | description: "The suffix can't have any spaces" 60 | 61 | - name: suffix-uppercase 62 | # This example is picked because it would be valid in lowercase 63 | typeid: "prefix_0123456789ABCDEFGHJKMNPQRS" 64 | description: "The suffix should be lowercase with no uppercase letters" 65 | 66 | - name: suffix-hyphens 67 | # This example has the right length, so that the failure is caused by the hyphens 68 | # and not the suffix length 69 | typeid: "prefix_123456789-123456789-123456" 70 | description: "The suffix should be lowercase with no uppercase letters" 71 | 72 | - name: suffix-wrong-alphabet 73 | typeid: "prefix_ooooooiiiiiiuuuuuuulllllll" 74 | description: "The suffix should only have letters from the spec's alphabet" 75 | 76 | - name: suffix-ambiguous-crockford 77 | # This example would be valid if we were using the crockford disambiguation rules 78 | typeid: "prefix_i23456789ol23456789oi23456" 79 | description: "The suffix should not have any ambiguous characters from the crockford encoding" 80 | 81 | - name: suffix-hyphens-crockford 82 | # This example would be valid if we were using the crockford hyphenation rules 83 | typeid: "prefix_123456789-0123456789-0123456" 84 | description: "The suffix can't ignore hyphens as in the crockford encoding" 85 | 86 | - name: suffix-overflow 87 | # This is the first suffix that overflows into 129 bits 88 | typeid: "prefix_8zzzzzzzzzzzzzzzzzzzzzzzzz" 89 | description: "The suffix should encode at most 128-bits" 90 | 91 | # Tests below were added in v0.3.0 when we started allowing '_' within the 92 | # type prefix. 93 | - name: prefix-underscore-start 94 | typeid: "_prefix_00000000000000000000000000" 95 | description: "The prefix can't start with an underscore" 96 | 97 | - name: prefix-underscore-end 98 | typeid: "prefix__00000000000000000000000000" 99 | description: "The prefix can't end with an underscore" 100 | -------------------------------------------------------------------------------- /spec/invalid_spec.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require_relative "../lib/typeid.rb" 3 | 4 | filename = "invalid.yml" 5 | filepath = File.expand_path(filename, __dir__) 6 | examples = YAML.load_file(filepath) 7 | 8 | RSpec.context "when testing against #{filename}" do 9 | examples.each do |example| 10 | context "when example '#{example["name"]}'" do 11 | subject(:type_id) { TypeID.from_string(example["typeid"]) } 12 | 13 | it example["description"] do 14 | expect { type_id }.to raise_error TypeID::Error 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/lib/typeid/uuid/base32_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../../lib/typeid.rb" 2 | 3 | RSpec.describe TypeID::UUID::Base32 do 4 | let(:decoded) { [1, 136, 186, 199, 74, 250, 120, 170, 188, 59, 189, 30, 239, 40, 216, 129] } 5 | let(:encoded) { "01h2xcejqtf2nbrexx3vqjhp41" } 6 | 7 | describe ".encode" do 8 | subject(:encode) { TypeID::UUID::Base32.encode(decoded) } 9 | 10 | it { is_expected.to eq encoded } 11 | end 12 | 13 | describe ".decode" do 14 | subject(:decode) { TypeID::UUID::Base32.decode(encoded) } 15 | 16 | it { is_expected.to eq decoded } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/lib/typeid/uuid_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../../lib/typeid.rb" 2 | 3 | RSpec.describe TypeID::UUID do 4 | subject(:uuid) { TypeID::UUID.new(bytes) } 5 | 6 | let(:bytes) { [1, 136, 186, 199, 74, 250, 120, 170, 188, 59, 189, 30, 239, 40, 216, 129] } 7 | let(:base32) { "01h2xcejqtf2nbrexx3vqjhp41" } 8 | let(:string) { "0188bac7-4afa-78aa-bc3b-bd1eef28d881" } 9 | 10 | describe ".from_base32" do 11 | subject(:uuid) { TypeID::UUID.from_base32(base32).bytes } 12 | 13 | it { is_expected.to eq bytes } 14 | end 15 | 16 | describe ".from_string" do 17 | subject(:uuid) { TypeID::UUID.from_string(string).bytes } 18 | 19 | it { is_expected.to eq bytes } 20 | end 21 | 22 | describe "#string" do 23 | it { is_expected.to eq string } 24 | end 25 | 26 | describe "#base32" do 27 | subject(:base32) { uuid.base32 } 28 | 29 | it { is_expected.to eq base32 } 30 | end 31 | 32 | describe "#timestamp" do 33 | subject(:timestamp) { uuid.timestamp } 34 | 35 | it { is_expected.to eq 1686760803066 } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/lib/typeid_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../lib/typeid.rb" 2 | 3 | RSpec.describe TypeID do 4 | subject(:type_id) { TypeID.new(prefix, suffix: suffix) } 5 | 6 | let(:prefix) { "user" } 7 | let(:suffix) { "01h2xcejqtf2nbrexx3vqjhp41" } 8 | let(:string) { "#{prefix}_#{suffix}" } 9 | let(:uuid) { TypeID::UUID.new(bytes) } 10 | let(:bytes) { [1, 136, 186, 199, 74, 250, 120, 170, 188, 59, 189, 30, 239, 40, 216, 129] } 11 | 12 | describe ".from_string" do 13 | subject(:type_id) { TypeID.from_string(string) } 14 | 15 | it { is_expected.to eq string } 16 | end 17 | 18 | describe ".from_uuid" do 19 | subject(:type_id) { TypeID.from_uuid(prefix, uuid) } 20 | 21 | it { is_expected.to eq string } 22 | end 23 | 24 | describe ".from" do 25 | subject(:type_id) { TypeID.from(prefix, suffix) } 26 | 27 | it { is_expected.to eq string } 28 | end 29 | 30 | describe ".nil" do 31 | subject(:type_id) { TypeID.nil } 32 | 33 | it { is_expected.to eq "00000000000000000000000000" } 34 | end 35 | 36 | describe "#string" do 37 | it { is_expected.to eq "#{prefix}_#{suffix}" } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | 3 | SimpleCov.start do 4 | if ENV["CI"] 5 | require "simplecov-cobertura" 6 | formatter SimpleCov::Formatter::CoberturaFormatter 7 | end 8 | end 9 | 10 | # This file was generated by the `rspec --init` command. Conventionally, all 11 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 12 | # The generated `.rspec` file contains `--require spec_helper` which will cause 13 | # this file to always be loaded, without a need to explicitly require it in any 14 | # files. 15 | # 16 | # Given that it is always loaded, you are encouraged to keep this file as 17 | # light-weight as possible. Requiring heavyweight dependencies from this file 18 | # will add to the boot time of your test suite on EVERY test run, even for an 19 | # individual file that may not need all of that loaded. Instead, consider making 20 | # a separate helper file that requires the additional dependencies and performs 21 | # the additional setup, and require it from the spec files that actually need 22 | # it. 23 | # 24 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 25 | RSpec.configure do |config| 26 | # rspec-expectations config goes here. You can use an alternate 27 | # assertion/expectation library such as wrong or the stdlib/minitest 28 | # assertions if you prefer. 29 | config.expect_with :rspec do |expectations| 30 | # This option will default to `true` in RSpec 4. It makes the `description` 31 | # and `failure_message` of custom matchers include text for helper methods 32 | # defined using `chain`, e.g.: 33 | # be_bigger_than(2).and_smaller_than(4).description 34 | # # => "be bigger than 2 and smaller than 4" 35 | # ...rather than: 36 | # # => "be bigger than 2" 37 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 38 | end 39 | 40 | # rspec-mocks config goes here. You can use an alternate test double 41 | # library (such as bogus or mocha) by changing the `mock_with` option here. 42 | config.mock_with :rspec do |mocks| 43 | # Prevents you from mocking or stubbing a method that does not exist on 44 | # a real object. This is generally recommended, and will default to 45 | # `true` in RSpec 4. 46 | mocks.verify_partial_doubles = true 47 | end 48 | 49 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 50 | # have no way to turn it off -- the option exists only for backwards 51 | # compatibility in RSpec 3). It causes shared context metadata to be 52 | # inherited by the metadata hash of host groups and examples, rather than 53 | # triggering implicit auto-inclusion in groups with matching metadata. 54 | config.shared_context_metadata_behavior = :apply_to_host_groups 55 | 56 | # The settings below are suggested to provide a good initial experience 57 | # with RSpec, but feel free to customize to your heart's content. 58 | =begin 59 | # This allows you to limit a spec run to individual examples or groups 60 | # you care about by tagging them with `:focus` metadata. When nothing 61 | # is tagged with `:focus`, all examples get run. RSpec also provides 62 | # aliases for `it`, `describe`, and `context` that include `:focus` 63 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 64 | config.filter_run_when_matching :focus 65 | 66 | # Allows RSpec to persist some state between runs in order to support 67 | # the `--only-failures` and `--next-failure` CLI options. We recommend 68 | # you configure your source control system to ignore this file. 69 | config.example_status_persistence_file_path = "spec/examples.txt" 70 | 71 | # Limits the available syntax to the non-monkey patched syntax that is 72 | # recommended. For more details, see: 73 | # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ 74 | config.disable_monkey_patching! 75 | 76 | # This setting enables warnings. It's recommended, but in some cases may 77 | # be too noisy due to issues in dependencies. 78 | config.warnings = true 79 | 80 | # Many RSpec users commonly either run the entire suite or an individual 81 | # file, and it's useful to allow more verbose output when running an 82 | # individual spec file. 83 | if config.files_to_run.one? 84 | # Use the documentation formatter for detailed output, 85 | # unless a formatter has already been configured 86 | # (e.g. via a command-line flag). 87 | config.default_formatter = "doc" 88 | end 89 | 90 | # Print the 10 slowest examples and example groups at the 91 | # end of the spec run, to help surface which specs are running 92 | # particularly slow. 93 | config.profile_examples = 10 94 | 95 | # Run specs in random order to surface order dependencies. If you find an 96 | # order dependency and want to debug it, you can fix the order by providing 97 | # the seed, which is printed after each run. 98 | # --seed 1234 99 | config.order = :random 100 | 101 | # Seed global randomization in this process using the `--seed` CLI option. 102 | # Setting this allows you to use `--seed` to deterministically reproduce 103 | # test failures related to randomization by passing the same `--seed` value 104 | # as the one that triggered the failure. 105 | Kernel.srand config.seed 106 | =end 107 | end 108 | -------------------------------------------------------------------------------- /spec/valid.yml: -------------------------------------------------------------------------------- 1 | # This file contains test data that should parse as valid TypeIDs by conforming 2 | # implementations. 3 | # 4 | # Each example contains: 5 | # - The TypeID in its canonical string representation. 6 | # - The prefix 7 | # - The decoded UUID as a hex string 8 | # 9 | # Implementations should verify that they can encode/decode the data 10 | # in both directions: 11 | # 1. If the TypeID is decoded, it should result in the given prefix and UUID. 12 | # 2. If the UUID is encoded as a TypeID with the given prefix, it should 13 | # result in the given TypeID. 14 | # 15 | # In addition to using these examples, it's recommended that implementations 16 | # generate a thousands of random ids during testing, and verify that after 17 | # decoding and re-encoding the id, the result is the same as the original. 18 | # 19 | # In other words, the following property should always hold: 20 | # random_typeid == encode(decode(random_typeid)) 21 | # 22 | # Finally, while implementations should be able to decode the values below, 23 | # note that not all of them are UUIDv7s. When *generating* new random typeids, 24 | # implementations should always use UUIDv7s. 25 | # 26 | # Last updated: 2024-04-10 (for version 0.3.0 of the spec) 27 | 28 | - name: nil 29 | typeid: "00000000000000000000000000" 30 | prefix: "" 31 | uuid: "00000000-0000-0000-0000-000000000000" 32 | 33 | - name: one 34 | typeid: "00000000000000000000000001" 35 | prefix: "" 36 | uuid: "00000000-0000-0000-0000-000000000001" 37 | 38 | - name: ten 39 | typeid: "0000000000000000000000000a" 40 | prefix: "" 41 | uuid: "00000000-0000-0000-0000-00000000000a" 42 | 43 | - name: sixteen 44 | typeid: "0000000000000000000000000g" 45 | prefix: "" 46 | uuid: "00000000-0000-0000-0000-000000000010" 47 | 48 | - name: thirty-two 49 | typeid: "00000000000000000000000010" 50 | prefix: "" 51 | uuid: "00000000-0000-0000-0000-000000000020" 52 | 53 | - name: max-valid 54 | typeid: "7zzzzzzzzzzzzzzzzzzzzzzzzz" 55 | prefix: "" 56 | uuid: "ffffffff-ffff-ffff-ffff-ffffffffffff" 57 | 58 | - name: valid-alphabet 59 | typeid: "prefix_0123456789abcdefghjkmnpqrs" 60 | prefix: "prefix" 61 | uuid: "0110c853-1d09-52d8-d73e-1194e95b5f19" 62 | 63 | - name: valid-uuidv7 64 | typeid: "prefix_01h455vb4pex5vsknk084sn02q" 65 | prefix: "prefix" 66 | uuid: "01890a5d-ac96-774b-bcce-b302099a8057" 67 | 68 | # Tests below were added in v0.3.0 when we started allowing '_' within the 69 | # type prefix. 70 | - name: prefix-underscore 71 | typeid: "pre_fix_00000000000000000000000000" 72 | prefix: "pre_fix" 73 | uuid: "00000000-0000-0000-0000-000000000000" 74 | -------------------------------------------------------------------------------- /spec/valid_spec.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require_relative "../lib/typeid.rb" 3 | 4 | filename = "valid.yml" 5 | filepath = File.expand_path(filename, __dir__) 6 | examples = YAML.load_file(filepath) 7 | 8 | RSpec.context "when testing against #{filename}" do 9 | examples.each do |example| 10 | context "when example '#{example["name"]}'" do 11 | describe "decoding" do 12 | let(:type_id) { TypeID.from_string(example["typeid"]) } 13 | 14 | describe "prefix" do 15 | subject(:prefix) { type_id.prefix } 16 | 17 | it { is_expected.to eq example["prefix"] } 18 | end 19 | 20 | describe "uuid" do 21 | subject(:uuid) { type_id.uuid } 22 | 23 | it { is_expected.to eq example["uuid"] } 24 | end 25 | end 26 | 27 | describe "encoding" do 28 | subject(:type_id) { TypeID.from_uuid(example["prefix"], example["uuid"]) } 29 | 30 | it { is_expected.to eq example["typeid"] } 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /typeid.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "./lib/typeid/version.rb" 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = "typeid" 5 | gem.version = TypeID::VERSION 6 | gem.summary = "A type-safe, K-sortable, globally unique identifier inspired by Stripe IDs" 7 | gem.authors = ["Andrew Booth"] 8 | gem.homepage = "https://github.com/broothie/typeid-ruby" 9 | gem.license = "Apache-2.0" 10 | gem.metadata = { 11 | "source_code_uri" => "https://github.com/broothie/typeid-ruby", 12 | "github_repo" => "https://github.com/broothie/typeid-ruby", 13 | } 14 | 15 | gem.required_ruby_version = ">= 3.0.0" 16 | gem.files = Dir.glob("lib/**/*.rb") 17 | gem.add_runtime_dependency "uuid7", "~> 0.2.0" 18 | gem.add_development_dependency "pry", "~> 0.14.2" 19 | gem.add_development_dependency "rake", "~> 13.0" 20 | gem.add_development_dependency "rspec", "~> 3.12" 21 | gem.add_development_dependency "simplecov", "~> 0.22.0" 22 | gem.add_development_dependency "simplecov-cobertura", "~> 2.1" 23 | end 24 | --------------------------------------------------------------------------------