├── .devcontainer ├── Dockerfile ├── base.Dockerfile └── devcontainer.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── gemfiles ├── rails_6.1.gemfile ├── rails_7.0.gemfile ├── rails_7.1.gemfile └── rails_main.gemfile ├── globalid.gemspec ├── lib ├── global_id.rb ├── global_id │ ├── fixture_set.rb │ ├── global_id.rb │ ├── identification.rb │ ├── locator.rb │ ├── railtie.rb │ ├── signed_global_id.rb │ ├── uri │ │ └── gid.rb │ └── verifier.rb └── globalid.rb └── test ├── cases ├── global_id_test.rb ├── global_identification_test.rb ├── global_locator_test.rb ├── pattern_matching_test.rb ├── railtie_test.rb ├── signed_global_id_test.rb ├── uri_gid_test.rb └── verifier_test.rb ├── helper.rb └── models ├── composite_primary_key_model.rb ├── person.rb └── person_model.rb /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} 4 | 5 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 6 | ARG NODE_VERSION="none" 7 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 8 | 9 | # [Optional] Uncomment this section to install additional OS packages. 10 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 11 | # && apt-get -y install --no-install-recommends 12 | 13 | # [Optional] Uncomment this line to install additional gems. 14 | # RUN gem install 15 | 16 | # [Optional] Uncomment this line to install global node packages. 17 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/base.Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM ruby:${VARIANT} 4 | 5 | # Copy library scripts to execute 6 | COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/ 7 | 8 | # [Option] Install zsh 9 | ARG INSTALL_ZSH="true" 10 | # [Option] Upgrade OS packages to their latest versions 11 | ARG UPGRADE_PACKAGES="true" 12 | # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. 13 | ARG USERNAME=vscode 14 | ARG USER_UID=1000 15 | ARG USER_GID=$USER_UID 16 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 17 | # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131 18 | && apt-get purge -y imagemagick imagemagick-6-common \ 19 | # Install common packages, non-root user, rvm, core build tools 20 | && bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ 21 | && bash /tmp/library-scripts/ruby-debian.sh "none" "${USERNAME}" "true" "true" \ 22 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* 23 | 24 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 25 | ARG NODE_VERSION="none" 26 | ENV NVM_DIR=/usr/local/share/nvm 27 | ENV NVM_SYMLINK_CURRENT=true \ 28 | PATH=${NVM_DIR}/current/bin:${PATH} 29 | RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \ 30 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 31 | 32 | # Remove library scripts for final image 33 | RUN rm -rf /tmp/library-scripts 34 | 35 | # [Optional] Uncomment this section to install additional OS packages. 36 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 37 | # && apt-get -y install --no-install-recommends 38 | 39 | # [Optional] Uncomment this line to install additional gems. 40 | # RUN gem install 41 | 42 | # [Optional] Uncomment this line to install global node packages. 43 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.205.2/containers/ruby 3 | { 4 | "name": "Ruby", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 9 | // Append -bullseye or -buster to pin to an OS version. 10 | // Use -bullseye variants on local on arm64/Apple Silicon. 11 | "VARIANT": "3-bullseye", 12 | // Options 13 | "NODE_VERSION": "lts/*" 14 | } 15 | }, 16 | 17 | // Configure tool-specific properties. 18 | "customizations": { 19 | "vscode": { 20 | // Add the IDs of extensions you want installed when the container is created. 21 | "extensions": [ 22 | "Shopify.ruby-lsp" 23 | ] 24 | } 25 | }, 26 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 27 | // "forwardPorts": [], 28 | 29 | // Use 'postCreateCommand' to run commands after the container is created. 30 | // "postCreateCommand": "ruby --version", 31 | 32 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 33 | "remoteUser": "vscode", 34 | // Features to add to the dev container. More info: https://containers.dev/features. 35 | "features": { 36 | "ghcr.io/devcontainers/features/github-cli:1": { 37 | "version": "latest" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | ruby: [ '2.7', '3.0', '3.1', '3.2', '3.3', 'head', 'truffleruby' ] 10 | rails: [ '6.1', '7.0', '7.1', 'main' ] 11 | exclude: 12 | - ruby: '2.7' 13 | rails: 'main' 14 | - ruby: '3.0' 15 | rails: 'main' 16 | 17 | env: 18 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}.gemfile 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Run tests 30 | run: bundle exec rake test 31 | continue-on-error: ${{ matrix.ruby == 'head' || matrix.ruby == 'truffleruby' || matrix.rails == 'main' }} 32 | timeout-minutes: 3 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.ruby-version 2 | /.ruby-gemset 3 | gemfiles/*.gemfile.lock 4 | pkg 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog is maintained under [Github Releases](https://github.com/rails/globalid/releases). 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to GlobalID 2 | ===================== 3 | 4 | [![CI Build Status](https://github.com/rails/globalid/actions/workflows/ci.yml/badge.svg)](https://github.com/rails/globalid/actions/workflows/ci.yml) 5 | 6 | GlobalID is work of [many contributors](https://github.com/rails/globalid/graphs/contributors). You're encouraged to submit [pull requests](https://github.com/rails/globalid/pulls), [propose features and discuss issues](https://github.com/rails/globalid/issues). 7 | 8 | #### Fork the Project 9 | 10 | Fork the [project on Github](https://github.com/rails/globalid) and check out your copy. 11 | 12 | ``` 13 | git clone https://github.com/contributor/globalid.git 14 | cd globalid 15 | git remote add upstream https://github.com/rails/globalid.git 16 | ``` 17 | 18 | #### Create a Topic Branch 19 | 20 | Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. 21 | 22 | ``` 23 | git checkout master 24 | git pull upstream master 25 | git checkout -b my-feature-branch 26 | ``` 27 | 28 | #### Bundle Install and Test 29 | 30 | Ensure that you can build the project and run tests. 31 | 32 | ``` 33 | bundle install 34 | bundle exec rake test 35 | ``` 36 | 37 | #### Write Tests 38 | 39 | Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add to [test](test). 40 | 41 | We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. 42 | 43 | #### Write Code 44 | 45 | Implement your feature or bug fix. 46 | 47 | Make sure that `bundle exec rake test` completes without errors. 48 | 49 | #### Write Documentation 50 | 51 | Document any external behavior in the [README](README.md). 52 | 53 | #### Commit Changes 54 | 55 | Make sure git knows your name and email address: 56 | 57 | ``` 58 | git config --global user.name "Your Name" 59 | git config --global user.email "contributor@example.com" 60 | ``` 61 | 62 | Writing good commit logs is important. A commit log should describe what changed and why. 63 | 64 | ``` 65 | git add ... 66 | git commit 67 | ``` 68 | 69 | #### Push 70 | 71 | ``` 72 | git push origin my-feature-branch 73 | ``` 74 | 75 | #### Make a Pull Request 76 | 77 | Go to https://github.com/contributor/globalid and select your feature branch. Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. 78 | 79 | #### Rebase 80 | 81 | If you've been working on a change for a while, rebase with upstream/master. 82 | 83 | ``` 84 | git fetch upstream 85 | git rebase upstream/master 86 | git push origin my-feature-branch -f 87 | ``` 88 | 89 | #### Check on Your Pull Request 90 | 91 | Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. 92 | 93 | #### Be Patient 94 | 95 | It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! 96 | 97 | #### Thank You 98 | 99 | Please do know that we really appreciate and value your time and work. We love you, really. 100 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'activemodel' 6 | gem 'railties' 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | globalid (1.2.1) 5 | activesupport (>= 6.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actionpack (7.0.7) 11 | actionview (= 7.0.7) 12 | activesupport (= 7.0.7) 13 | rack (~> 2.0, >= 2.2.4) 14 | rack-test (>= 0.6.3) 15 | rails-dom-testing (~> 2.0) 16 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 17 | actionview (7.0.7) 18 | activesupport (= 7.0.7) 19 | builder (~> 3.1) 20 | erubi (~> 1.4) 21 | rails-dom-testing (~> 2.0) 22 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 23 | activemodel (7.0.7) 24 | activesupport (= 7.0.7) 25 | activesupport (7.0.7) 26 | concurrent-ruby (~> 1.0, >= 1.0.2) 27 | i18n (>= 1.6, < 2) 28 | minitest (>= 5.1) 29 | tzinfo (~> 2.0) 30 | builder (3.2.4) 31 | concurrent-ruby (1.2.2) 32 | crass (1.0.6) 33 | erubi (1.12.0) 34 | i18n (1.14.1) 35 | concurrent-ruby (~> 1.0) 36 | loofah (2.21.3) 37 | crass (~> 1.0.2) 38 | nokogiri (>= 1.12.0) 39 | method_source (1.0.0) 40 | mini_portile2 (2.8.4) 41 | minitest (5.19.0) 42 | nokogiri (1.15.4) 43 | mini_portile2 (~> 2.8.2) 44 | racc (~> 1.4) 45 | nokogiri (1.15.4-aarch64-linux) 46 | racc (~> 1.4) 47 | racc (1.7.1) 48 | rack (2.2.8) 49 | rack-test (2.1.0) 50 | rack (>= 1.3) 51 | rails-dom-testing (2.2.0) 52 | activesupport (>= 5.0.0) 53 | minitest 54 | nokogiri (>= 1.6) 55 | rails-html-sanitizer (1.6.0) 56 | loofah (~> 2.21) 57 | nokogiri (~> 1.14) 58 | railties (7.0.7) 59 | actionpack (= 7.0.7) 60 | activesupport (= 7.0.7) 61 | method_source 62 | rake (>= 12.2) 63 | thor (~> 1.0) 64 | zeitwerk (~> 2.5) 65 | rake (13.0.6) 66 | thor (1.2.2) 67 | tzinfo (2.0.6) 68 | concurrent-ruby (~> 1.0) 69 | zeitwerk (2.6.11) 70 | 71 | PLATFORMS 72 | aarch64-linux 73 | ruby 74 | 75 | DEPENDENCIES 76 | activemodel 77 | globalid! 78 | railties 79 | rake 80 | 81 | BUNDLED WITH 82 | 2.4.2 83 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2023 David Heinemeier Hansson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Global ID - Reference models by URI 2 | 3 | A Global ID is an app wide URI that uniquely identifies a model instance: 4 | 5 | gid://YourApp/Some::Model/id 6 | 7 | This is helpful when you need a single identifier to reference different 8 | classes of objects. 9 | 10 | One example is job scheduling. We need to reference a model object rather than 11 | serialize the object itself. We can pass a Global ID that can be used to locate 12 | the model when it's time to perform the job. The job scheduler doesn't need to know 13 | the details of model naming and IDs, just that it has a global identifier that 14 | references a model. 15 | 16 | Another example is a drop-down list of options, consisting of both Users and Groups. 17 | Normally we'd need to come up with our own ad hoc scheme to reference them. With Global 18 | IDs, we have a universal identifier that works for objects of both classes. 19 | 20 | 21 | ## Usage 22 | 23 | Mix `GlobalID::Identification` into any model with a `#find(id)` class method. 24 | Support is automatically included in Active Record. 25 | 26 | ```ruby 27 | person_gid = Person.find(1).to_global_id 28 | # => # # "gid://app/Person/1" 35 | 36 | GlobalID::Locator.locate person_gid 37 | # => # 38 | ``` 39 | 40 | ### Signed Global IDs 41 | 42 | For added security GlobalIDs can also be signed to ensure that the data hasn't been tampered with. 43 | 44 | ```ruby 45 | person_sgid = Person.find(1).to_signed_global_id 46 | # => # 47 | 48 | person_sgid = Person.find(1).to_sgid 49 | # => # 50 | 51 | person_sgid.to_s 52 | # => "BAhJIh5naWQ6Ly9pZGluYWlkaS9Vc2VyLzM5NTk5BjoGRVQ=--81d7358dd5ee2ca33189bb404592df5e8d11420e" 53 | 54 | GlobalID::Locator.locate_signed person_sgid 55 | # => # 56 | ``` 57 | 58 | **Expiration** 59 | 60 | Signed Global IDs can expire sometime in the future. This is useful if there's a resource 61 | people shouldn't have indefinite access to, like a share link. 62 | 63 | ```ruby 64 | expiring_sgid = Document.find(5).to_sgid(expires_in: 2.hours, for: 'sharing') 65 | # => # 66 | 67 | # Within 2 hours... 68 | GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing') 69 | # => # 70 | 71 | # More than 2 hours later... 72 | GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing') 73 | # => nil 74 | ``` 75 | 76 | **In Rails, an auto-expiry of 1 month is set by default.** You can alter that deal 77 | in an initializer with: 78 | 79 | ```ruby 80 | # config/initializers/global_id.rb 81 | Rails.application.config.global_id.expires_in = 3.months 82 | ``` 83 | 84 | You can assign a default SGID lifetime like so: 85 | 86 | ```ruby 87 | SignedGlobalID.expires_in = 1.month 88 | ``` 89 | 90 | This way, any generated SGID will use that relative expiry. 91 | 92 | It's worth noting that _expiring SGIDs are not idempotent_ because they encode the current timestamp; repeated calls to `to_sgid` will produce different results. For example, in Rails 93 | 94 | ```ruby 95 | Document.find(5).to_sgid.to_s == Document.find(5).to_sgid.to_s 96 | # => false 97 | ``` 98 | 99 | You need to explicitly pass `expires_in: nil` to generate a permanent SGID that will not expire, 100 | 101 | ```ruby 102 | # Passing a false value to either expiry option turns off expiration entirely. 103 | never_expiring_sgid = Document.find(5).to_sgid(expires_in: nil) 104 | # => # 105 | 106 | # Any time later... 107 | GlobalID::Locator.locate_signed never_expiring_sgid 108 | # => # 109 | ``` 110 | 111 | It's also possible to pass a specific expiry time 112 | 113 | ```ruby 114 | explicit_expiring_sgid = SecretAgentMessage.find(5).to_sgid(expires_at: Time.now.advance(hours: 1)) 115 | # => # 116 | 117 | # 1 hour later... 118 | GlobalID::Locator.locate_signed explicit_expiring_sgid.to_s 119 | # => nil 120 | ``` 121 | Note that an explicit `:expires_at` takes precedence over a relative `:expires_in`. 122 | 123 | **Purpose** 124 | 125 | You can even bump the security up some more by explaining what purpose a Signed Global ID is for. 126 | In this way evildoers can't reuse a sign-up form's SGID on the login page. For example. 127 | 128 | ```ruby 129 | signup_person_sgid = Person.find(1).to_sgid(for: 'signup_form') 130 | # => # # 134 | ``` 135 | 136 | ### Locating many Global IDs 137 | 138 | When needing to locate many Global IDs use `GlobalID::Locator.locate_many` or `GlobalID::Locator.locate_many_signed` for Signed Global IDs to allow loading 139 | Global IDs more efficiently. 140 | 141 | For instance, the default locator passes every `model_id` per `model_name` thus 142 | using `model_name.where(id: model_ids)` versus `GlobalID::Locator.locate`'s `model_name.find(id)`. 143 | 144 | In the case of looking up Global IDs from a database, it's only necessary to query 145 | once per `model_name` as shown here: 146 | 147 | ```ruby 148 | gids = users.concat(people).sort_by(&:id).map(&:to_global_id) 149 | # => [#>, 150 | #>, 151 | #>, 152 | #>, 153 | #>, 154 | #>] 155 | 156 | GlobalID::Locator.locate_many gids 157 | # SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3) [["id", 1], ["id", 2], ["id", 3]] 158 | # SELECT "students".* FROM "students" WHERE "students"."id" IN ($1, $2, $3) [["id", 1], ["id", 2], ["id", 3]] 159 | # => [#, #, #, #, #, #] 160 | ``` 161 | 162 | Note the order is maintained in the returned results. 163 | 164 | ### Options 165 | 166 | Either `GlobalID::Locator.locate` or `GlobalID::Locator.locate_many` supports a hash of options as second parameter. The supported options are: 167 | 168 | * :includes - A Symbol, Array, Hash or combination of them 169 | The same structure you would pass into a `includes` method of Active Record. 170 | See [Active Record eager loading associations](https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations) 171 | If present, `locate` or `locate_many` will eager load all the relationships specified here. 172 | Note: It only works if all the gids models have those relationships. 173 | * :only - A class, module or Array of classes and/or modules that are 174 | allowed to be located. Passing one or more classes limits instances of returned 175 | classes to those classes or their subclasses. Passing one or more modules in limits 176 | instances of returned classes to those including that module. If no classes or 177 | modules match, `nil` is returned. 178 | * :ignore_missing (Only for `locate_many`) - By default, `locate_many` will call `#find` on the model to locate the 179 | ids extracted from the GIDs. In Active Record (and other data stores following the same pattern), 180 | `#find` will raise an exception if a named ID can't be found. When you set this option to true, 181 | we will use `#where(id: ids)` instead, which does not raise on missing records. 182 | 183 | ### Custom App Locator 184 | 185 | A custom locator can be set for an app by calling `GlobalID::Locator.use` and providing an app locator to use for that app. 186 | A custom app locator is useful when different apps collaborate and reference each others' Global IDs. 187 | When finding a Global ID's model, the locator to use is based on the app name provided in the Global ID url. 188 | 189 | A custom locator can either be a block or a class. 190 | 191 | Using a block: 192 | 193 | ```ruby 194 | GlobalID::Locator.use :foo do |gid, options| 195 | FooRemote.const_get(gid.model_name).find(gid.model_id) 196 | end 197 | ``` 198 | 199 | Using a class: 200 | 201 | ```ruby 202 | GlobalID::Locator.use :bar, BarLocator.new 203 | class BarLocator 204 | def locate(gid, options = {}) 205 | @search_client.search name: gid.model_name, id: gid.model_id 206 | end 207 | end 208 | ``` 209 | 210 | After defining locators as above, URIs like "gid://foo/Person/1" and "gid://bar/Person/1" will now use the foo block locator and `BarLocator` respectively. 211 | Other apps will still keep using the default locator. 212 | 213 | ## Contributing to GlobalID 214 | 215 | GlobalID is work of many contributors. You're encouraged to submit pull requests, propose 216 | features and discuss issues. 217 | 218 | See [CONTRIBUTING](CONTRIBUTING.md). 219 | 220 | ## License 221 | GlobalID is released under the [MIT License](http://www.opensource.org/licenses/MIT). 222 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | task :default => :test 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'test' 8 | t.test_files = FileList['test/cases/**/*_test.rb'] 9 | t.verbose = true 10 | t.warning = true 11 | end 12 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activemodel", "~> 6.1.0" 4 | gem "railties", "~> 6.1.0" 5 | 6 | gemspec path: "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activemodel", "~> 7.0.0" 4 | gem "railties", "~> 7.0.0" 5 | 6 | gemspec path: "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activemodel", "~> 7.1.0" 4 | gem "railties", "~> 7.1.0" 5 | 6 | gemspec path: "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_main.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activemodel", github: "rails/rails", branch: "main" 4 | gem "railties", github: "rails/rails", branch: "main" 5 | 6 | gemspec path: "../" 7 | -------------------------------------------------------------------------------- /globalid.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.platform = Gem::Platform::RUBY 3 | s.name = 'globalid' 4 | s.version = '1.2.1' 5 | s.summary = 'Refer to any model with a URI: gid://app/class/id' 6 | s.description = 'URIs for your models makes it easy to pass references around.' 7 | 8 | s.required_ruby_version = '>= 2.7.0' 9 | 10 | s.license = 'MIT' 11 | 12 | s.author = 'David Heinemeier Hansson' 13 | s.email = 'david@loudthinking.com' 14 | s.homepage = 'http://www.rubyonrails.org' 15 | 16 | s.files = Dir['MIT-LICENSE', 'README.md', 'lib/**/*'] 17 | s.require_path = 'lib' 18 | 19 | s.add_runtime_dependency 'activesupport', '>= 6.1' 20 | 21 | s.add_development_dependency 'rake' 22 | 23 | s.metadata = { 24 | "rubygems_mfa_required" => "true", 25 | } 26 | end 27 | -------------------------------------------------------------------------------- /lib/global_id.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'global_id/global_id' 3 | 4 | autoload :SignedGlobalID, 'global_id/signed_global_id' 5 | 6 | class GlobalID 7 | extend ActiveSupport::Autoload 8 | 9 | eager_autoload do 10 | autoload :Locator 11 | autoload :Identification 12 | autoload :Verifier 13 | end 14 | 15 | def self.eager_load! 16 | super 17 | require 'global_id/signed_global_id' 18 | end 19 | 20 | def self.deprecator # :nodoc: 21 | @deprecator ||= ActiveSupport::Deprecation.new("2.1", "GlobalID") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/global_id/fixture_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GlobalID 4 | module FixtureSet 5 | def global_id(fixture_set_name, label, column_type: :integer, **options) 6 | create_global_id(fixture_set_name, label, column_type: column_type, klass: GlobalID, **options) 7 | end 8 | 9 | def signed_global_id(fixture_set_name, label, column_type: :integer, **options) 10 | create_global_id(fixture_set_name, label, column_type: column_type, klass: SignedGlobalID, **options) 11 | end 12 | 13 | private 14 | def create_global_id(fixture_set_name, label, klass:, column_type: :integer, **options) 15 | identifier = identify(label, column_type) 16 | model_name = default_fixture_model_name(fixture_set_name) 17 | uri = URI::GID.build([GlobalID.app, model_name, identifier, {}]) 18 | klass.new(uri, **options) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/global_id/global_id.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/string/inflections' # For #model_class constantize 2 | require 'active_support/core_ext/array/access' 3 | require 'active_support/core_ext/object/try' # For #find 4 | require 'active_support/core_ext/module/delegation' 5 | require 'global_id/uri/gid' 6 | 7 | class GlobalID 8 | class << self 9 | attr_reader :app 10 | 11 | def create(model, options = {}) 12 | if app = options.fetch(:app) { GlobalID.app } 13 | params = options.except(:app, :verifier, :for) 14 | new URI::GID.create(app, model, params), options 15 | else 16 | raise ArgumentError, 'An app is required to create a GlobalID. ' \ 17 | 'Pass the :app option or set the default GlobalID.app.' 18 | end 19 | end 20 | 21 | def find(gid, options = {}) 22 | parse(gid, options).try(:find, options) 23 | end 24 | 25 | def parse(gid, options = {}) 26 | gid.is_a?(self) ? gid : new(gid, options) 27 | rescue URI::Error 28 | parse_encoded_gid(gid, options) 29 | end 30 | 31 | def app=(app) 32 | @app = URI::GID.validate_app(app) 33 | end 34 | 35 | private 36 | def parse_encoded_gid(gid, options) 37 | new(Base64.urlsafe_decode64(gid), options) rescue nil 38 | end 39 | end 40 | 41 | attr_reader :uri 42 | delegate :app, :model_name, :model_id, :params, :to_s, :deconstruct_keys, to: :uri 43 | 44 | def initialize(gid, options = {}) 45 | @uri = gid.is_a?(URI::GID) ? gid : URI::GID.parse(gid) 46 | end 47 | 48 | def find(options = {}) 49 | Locator.locate self, options 50 | end 51 | 52 | def model_class 53 | @model_class ||= begin 54 | model = model_name.constantize 55 | 56 | if model <= GlobalID 57 | raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class." 58 | end 59 | model 60 | end 61 | end 62 | 63 | def ==(other) 64 | other.is_a?(GlobalID) && @uri == other.uri 65 | end 66 | alias_method :eql?, :== 67 | 68 | def hash 69 | self.class.hash | @uri.hash 70 | end 71 | 72 | def to_param 73 | Base64.urlsafe_encode64(to_s, padding: false) 74 | end 75 | 76 | def as_json(*) 77 | to_s 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/global_id/identification.rb: -------------------------------------------------------------------------------- 1 | class GlobalID 2 | # Mix `GlobalID::Identification` into any model with a `#find(id)` class 3 | # method. Support is automatically included in Active Record. 4 | # 5 | # class Person 6 | # include ActiveModel::Model 7 | # include GlobalID::Identification 8 | # 9 | # attr_accessor :id 10 | # 11 | # def self.find(id) 12 | # new id: id 13 | # end 14 | # 15 | # def ==(other) 16 | # id == other.try(:id) 17 | # end 18 | # end 19 | # 20 | # person_gid = Person.find(1).to_global_id 21 | # # => # # "gid://app/Person/1" 26 | # GlobalID::Locator.locate person_gid 27 | # # => # 28 | module Identification 29 | 30 | # Returns the Global ID of the model. 31 | # 32 | # model = Person.new id: 1 33 | # global_id = model.to_global_id 34 | # global_id.modal_class # => Person 35 | # global_id.modal_id # => "1" 36 | # global_id.to_param # => "Z2lkOi8vYm9yZGZvbGlvL1BlcnNvbi8x" 37 | def to_global_id(options = {}) 38 | GlobalID.create(self, options) 39 | end 40 | alias to_gid to_global_id 41 | 42 | # Returns the Global ID parameter of the model. 43 | # 44 | # model = Person.new id: 1 45 | # model.to_gid_param # => ""Z2lkOi8vYm9yZGZvbGlvL1BlcnNvbi8x" 46 | def to_gid_param(options = {}) 47 | to_global_id(options).to_param 48 | end 49 | 50 | # Returns the Signed Global ID of the model. 51 | # Signed Global IDs ensure that the data hasn't been tampered with. 52 | # 53 | # model = Person.new id: 1 54 | # signed_global_id = model.to_signed_global_id 55 | # signed_global_id.modal_class # => Person 56 | # signed_global_id.modal_id # => "1" 57 | # signed_global_id.to_param # => "BAh7CEkiCGdpZAY6BkVUSSIiZ2..." 58 | # 59 | # ==== Expiration 60 | # 61 | # Signed Global IDs can expire some time in the future. This is useful if 62 | # there's a resource people shouldn't have indefinite access to, like a 63 | # share link. 64 | # 65 | # expiring_sgid = Document.find(5).to_sgid(expires_in: 2.hours, for: 'sharing') 66 | # # => # 67 | # # Within 2 hours... 68 | # GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing') 69 | # # => # 70 | # # More than 2 hours later... 71 | # GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing') 72 | # # => nil 73 | # 74 | # In Rails, an auto-expiry of 1 month is set by default. 75 | # 76 | # You need to explicitly pass `expires_in: nil` to generate a permanent 77 | # SGID that will not expire, 78 | # 79 | # never_expiring_sgid = Document.find(5).to_sgid(expires_in: nil) 80 | # # => # 81 | # 82 | # # Any time later... 83 | # GlobalID::Locator.locate_signed never_expiring_sgid 84 | # # => # 85 | # 86 | # It's also possible to pass a specific expiry time 87 | # 88 | # explicit_expiring_sgid = SecretAgentMessage.find(5).to_sgid(expires_at: Time.now.advance(hours: 1)) 89 | # # => # 90 | # 91 | # # 1 hour later... 92 | # GlobalID::Locator.locate_signed explicit_expiring_sgid.to_s 93 | # # => nil 94 | # 95 | # Note that an explicit `:expires_at` takes precedence over a relative `:expires_in`. 96 | # 97 | # ==== Purpose 98 | # 99 | # You can even bump the security up some more by explaining what purpose a 100 | # Signed Global ID is for. In this way evildoers can't reuse a sign-up 101 | # form's SGID on the login page. For example. 102 | # 103 | # signup_person_sgid = Person.find(1).to_sgid(for: 'signup_form') 104 | # # => # # 107 | def to_signed_global_id(options = {}) 108 | SignedGlobalID.create(self, options) 109 | end 110 | alias to_sgid to_signed_global_id 111 | 112 | # Returns the Signed Global ID parameter. 113 | # 114 | # model = Person.new id: 1 115 | # model.to_sgid_param # => "BAh7CEkiCGdpZAY6BkVUSSIiZ2..." 116 | def to_sgid_param(options = {}) 117 | to_signed_global_id(options).to_param 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/global_id/locator.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/enumerable' # For Enumerable#index_by 2 | 3 | class GlobalID 4 | module Locator 5 | class InvalidModelIdError < StandardError; end 6 | 7 | class << self 8 | # Takes either a GlobalID or a string that can be turned into a GlobalID 9 | # 10 | # Options: 11 | # * :includes - A Symbol, Array, Hash or combination of them. 12 | # The same structure you would pass into a +includes+ method of Active Record. 13 | # If present, locate will load all the relationships specified here. 14 | # See https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations. 15 | # * :only - A class, module or Array of classes and/or modules that are 16 | # allowed to be located. Passing one or more classes limits instances of returned 17 | # classes to those classes or their subclasses. Passing one or more modules in limits 18 | # instances of returned classes to those including that module. If no classes or 19 | # modules match, +nil+ is returned. 20 | def locate(gid, options = {}) 21 | gid = GlobalID.parse(gid) 22 | 23 | return unless gid && find_allowed?(gid.model_class, options[:only]) 24 | 25 | locator = locator_for(gid) 26 | 27 | if locator.method(:locate).arity == 1 28 | GlobalID.deprecator.warn "It seems your locator is defining the `locate` method only with one argument. Please make sure your locator is receiving the options argument as well, like `locate(gid, options = {})`." 29 | locator.locate(gid) 30 | else 31 | locator.locate(gid, options.except(:only)) 32 | end 33 | end 34 | 35 | # Takes an array of GlobalIDs or strings that can be turned into a GlobalIDs. 36 | # All GlobalIDs must belong to the same app, as they will be located using 37 | # the same locator using its locate_many method. 38 | # 39 | # By default the GlobalIDs will be located using Model.find(array_of_ids), so the 40 | # models must respond to that finder signature. 41 | # 42 | # This approach will efficiently call only one #find (or #where(id: id), when using ignore_missing) 43 | # per model class, but still interpolate the results to match the order in which the gids were passed. 44 | # 45 | # Options: 46 | # * :includes - A Symbol, Array, Hash or combination of them 47 | # The same structure you would pass into a includes method of Active Record. 48 | # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations 49 | # If present, locate_many will load all the relationships specified here. 50 | # Note: It only works if all the gids models have that relationships. 51 | # * :only - A class, module or Array of classes and/or modules that are 52 | # allowed to be located. Passing one or more classes limits instances of returned 53 | # classes to those classes or their subclasses. Passing one or more modules in limits 54 | # instances of returned classes to those including that module. If no classes or 55 | # modules match, +nil+ is returned. 56 | # * :ignore_missing - By default, locate_many will call #find on the model to locate the 57 | # ids extracted from the GIDs. In Active Record (and other data stores following the same pattern), 58 | # #find will raise an exception if a named ID can't be found. When you set this option to true, 59 | # we will use #where(id: ids) instead, which does not raise on missing records. 60 | def locate_many(gids, options = {}) 61 | if (allowed_gids = parse_allowed(gids, options[:only])).any? 62 | locator = locator_for(allowed_gids.first) 63 | locator.locate_many(allowed_gids, options) 64 | else 65 | [] 66 | end 67 | end 68 | 69 | # Takes either a SignedGlobalID or a string that can be turned into a SignedGlobalID 70 | # 71 | # Options: 72 | # * :includes - A Symbol, Array, Hash or combination of them 73 | # The same structure you would pass into a includes method of Active Record. 74 | # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations 75 | # If present, locate_signed will load all the relationships specified here. 76 | # * :only - A class, module or Array of classes and/or modules that are 77 | # allowed to be located. Passing one or more classes limits instances of returned 78 | # classes to those classes or their subclasses. Passing one or more modules in limits 79 | # instances of returned classes to those including that module. If no classes or 80 | # modules match, +nil+ is returned. 81 | def locate_signed(sgid, options = {}) 82 | SignedGlobalID.find sgid, options 83 | end 84 | 85 | # Takes an array of SignedGlobalIDs or strings that can be turned into a SignedGlobalIDs. 86 | # The SignedGlobalIDs are located using Model.find(array_of_ids), so the models must respond to 87 | # that finder signature. 88 | # 89 | # This approach will efficiently call only one #find per model class, but still interpolate 90 | # the results to match the order in which the gids were passed. 91 | # 92 | # Options: 93 | # * :includes - A Symbol, Array, Hash or combination of them 94 | # The same structure you would pass into a includes method of Active Record. 95 | # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations 96 | # If present, locate_many_signed will load all the relationships specified here. 97 | # Note: It only works if all the gids models have that relationships. 98 | # * :only - A class, module or Array of classes and/or modules that are 99 | # allowed to be located. Passing one or more classes limits instances of returned 100 | # classes to those classes or their subclasses. Passing one or more modules in limits 101 | # instances of returned classes to those including that module. If no classes or 102 | # modules match, +nil+ is returned. 103 | def locate_many_signed(sgids, options = {}) 104 | locate_many sgids.collect { |sgid| SignedGlobalID.parse(sgid, options.slice(:for)) }.compact, options 105 | end 106 | 107 | # Tie a locator to an app. 108 | # Useful when different apps collaborate and reference each others' Global IDs. 109 | # 110 | # The locator can be either a block or a class. 111 | # 112 | # Using a block: 113 | # 114 | # GlobalID::Locator.use :foo do |gid, options| 115 | # FooRemote.const_get(gid.model_name).find(gid.model_id) 116 | # end 117 | # 118 | # Using a class: 119 | # 120 | # GlobalID::Locator.use :bar, BarLocator.new 121 | # 122 | # class BarLocator 123 | # def locate(gid, options = {}) 124 | # @search_client.search name: gid.model_name, id: gid.model_id 125 | # end 126 | # end 127 | def use(app, locator = nil, &locator_block) 128 | raise ArgumentError, 'No locator provided. Pass a block or an object that responds to #locate.' unless locator || block_given? 129 | 130 | URI::GID.validate_app(app) 131 | 132 | @locators[normalize_app(app)] = locator || BlockLocator.new(locator_block) 133 | end 134 | 135 | private 136 | def locator_for(gid) 137 | @locators.fetch(normalize_app(gid.app)) { DEFAULT_LOCATOR } 138 | end 139 | 140 | def find_allowed?(model_class, only = nil) 141 | only ? Array(only).any? { |c| model_class <= c } : true 142 | end 143 | 144 | def parse_allowed(gids, only = nil) 145 | gids.collect { |gid| GlobalID.parse(gid) }.compact.select { |gid| find_allowed?(gid.model_class, only) } 146 | end 147 | 148 | def normalize_app(app) 149 | app.to_s.downcase 150 | end 151 | end 152 | 153 | private 154 | @locators = {} 155 | 156 | class BaseLocator 157 | def locate(gid, options = {}) 158 | return unless model_id_is_valid?(gid) 159 | model_class = gid.model_class 160 | model_class = model_class.includes(options[:includes]) if options[:includes] 161 | 162 | model_class.find gid.model_id 163 | end 164 | 165 | def locate_many(gids, options = {}) 166 | ids_by_model = Hash.new { |hash, key| hash[key] = [] } 167 | 168 | gids.each do |gid| 169 | next unless model_id_is_valid?(gid) 170 | ids_by_model[gid.model_class] << gid.model_id 171 | end 172 | 173 | records_by_model_name_and_id = {} 174 | 175 | ids_by_model.each do |model, ids| 176 | records = find_records(model, ids, ignore_missing: options[:ignore_missing], includes: options[:includes]) 177 | 178 | records_by_id = records.index_by do |record| 179 | record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s 180 | end 181 | 182 | records_by_model_name_and_id[model.name] = records_by_id 183 | end 184 | 185 | gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] } 186 | end 187 | 188 | private 189 | def find_records(model_class, ids, options) 190 | model_class = model_class.includes(options[:includes]) if options[:includes] 191 | 192 | if options[:ignore_missing] 193 | model_class.where(primary_key(model_class) => ids) 194 | else 195 | model_class.find(ids) 196 | end 197 | end 198 | 199 | def model_id_is_valid?(gid) 200 | Array(gid.model_id).size == Array(primary_key(gid.model_class)).size 201 | end 202 | 203 | def primary_key(model_class) 204 | model_class.respond_to?(:primary_key) ? model_class.primary_key : :id 205 | end 206 | end 207 | 208 | class UnscopedLocator < BaseLocator 209 | def locate(gid, options = {}) 210 | unscoped(gid.model_class) { super } 211 | end 212 | 213 | private 214 | def find_records(model_class, ids, options) 215 | unscoped(model_class) { super } 216 | end 217 | 218 | def unscoped(model_class) 219 | if model_class.respond_to?(:unscoped) 220 | model_class.unscoped { yield } 221 | else 222 | yield 223 | end 224 | end 225 | end 226 | DEFAULT_LOCATOR = UnscopedLocator.new 227 | 228 | class BlockLocator 229 | def initialize(block) 230 | @locator = block 231 | end 232 | 233 | def locate(gid, options = {}) 234 | @locator.call(gid, options) 235 | end 236 | 237 | def locate_many(gids, options = {}) 238 | gids.map { |gid| locate(gid, options) } 239 | end 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /lib/global_id/railtie.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'rails/railtie' 3 | rescue LoadError 4 | else 5 | require 'global_id' 6 | require 'active_support/core_ext/string/inflections' 7 | require 'active_support/core_ext/integer/time' 8 | 9 | class GlobalID 10 | # = GlobalID Railtie 11 | # Set up the signed GlobalID verifier and include Active Record support. 12 | class Railtie < Rails::Railtie # :nodoc: 13 | config.global_id = ActiveSupport::OrderedOptions.new 14 | config.eager_load_namespaces << GlobalID 15 | 16 | initializer 'global_id' do |app| 17 | default_expires_in = 1.month 18 | default_app_name = app.railtie_name.remove('_application').dasherize 19 | 20 | GlobalID.app = app.config.global_id.app ||= default_app_name 21 | SignedGlobalID.expires_in = app.config.global_id.fetch(:expires_in, default_expires_in) 22 | 23 | config.after_initialize do 24 | GlobalID.app = app.config.global_id.app ||= default_app_name 25 | SignedGlobalID.expires_in = app.config.global_id.fetch(:expires_in, default_expires_in) 26 | 27 | app.config.global_id.verifier ||= begin 28 | GlobalID::Verifier.new(app.key_generator.generate_key('signed_global_ids')) 29 | rescue ArgumentError 30 | nil 31 | end 32 | SignedGlobalID.verifier = app.config.global_id.verifier 33 | end 34 | 35 | ActiveSupport.on_load(:active_record) do 36 | require 'global_id/identification' 37 | send :include, GlobalID::Identification 38 | end 39 | 40 | ActiveSupport.on_load(:active_record_fixture_set) do 41 | require 'global_id/fixture_set' 42 | send :extend, GlobalID::FixtureSet 43 | end 44 | end 45 | 46 | initializer "web_console.deprecator" do |app| 47 | app.deprecators[:global_id] = GlobalID.deprecator if app.respond_to?(:deprecators) 48 | end 49 | end 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/global_id/signed_global_id.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/message_verifier' 2 | require 'time' 3 | 4 | class SignedGlobalID < GlobalID 5 | class ExpiredMessage < StandardError; end 6 | 7 | class << self 8 | attr_accessor :verifier, :expires_in 9 | 10 | def parse(sgid, options = {}) 11 | super verify(sgid.to_s, options), options 12 | end 13 | 14 | # Grab the verifier from options and fall back to SignedGlobalID.verifier. 15 | # Raise ArgumentError if neither is available. 16 | def pick_verifier(options) 17 | options.fetch :verifier do 18 | verifier || raise(ArgumentError, 'Pass a `verifier:` option with an `ActiveSupport::MessageVerifier` instance, or set a default SignedGlobalID.verifier.') 19 | end 20 | end 21 | 22 | DEFAULT_PURPOSE = "default" 23 | 24 | def pick_purpose(options) 25 | options.fetch :for, DEFAULT_PURPOSE 26 | end 27 | 28 | private 29 | def verify(sgid, options) 30 | verify_with_verifier_validated_metadata(sgid, options) || 31 | verify_with_legacy_self_validated_metadata(sgid, options) 32 | end 33 | 34 | def verify_with_verifier_validated_metadata(sgid, options) 35 | pick_verifier(options).verify(sgid, purpose: pick_purpose(options)) 36 | rescue ActiveSupport::MessageVerifier::InvalidSignature 37 | nil 38 | end 39 | 40 | def verify_with_legacy_self_validated_metadata(sgid, options) 41 | metadata = pick_verifier(options).verify(sgid) 42 | 43 | raise_if_expired(metadata['expires_at']) 44 | 45 | metadata['gid'] if pick_purpose(options)&.to_s == metadata['purpose']&.to_s 46 | rescue ActiveSupport::MessageVerifier::InvalidSignature, ExpiredMessage 47 | nil 48 | end 49 | 50 | def raise_if_expired(expires_at) 51 | if expires_at && Time.now.utc > Time.iso8601(expires_at) 52 | raise ExpiredMessage, 'This signed global id has expired.' 53 | end 54 | end 55 | end 56 | 57 | attr_reader :verifier, :purpose, :expires_at 58 | 59 | def initialize(gid, options = {}) 60 | super 61 | @verifier = self.class.pick_verifier(options) 62 | @purpose = self.class.pick_purpose(options) 63 | @expires_at = pick_expiration(options) 64 | end 65 | 66 | def to_s 67 | @sgid ||= @verifier.generate(@uri.to_s, purpose: purpose, expires_at: expires_at) 68 | end 69 | alias to_param to_s 70 | 71 | def ==(other) 72 | super && @purpose == other.purpose 73 | end 74 | 75 | def inspect # :nodoc: 76 | "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" 77 | end 78 | 79 | private 80 | def pick_expiration(options) 81 | return options[:expires_at] if options.key?(:expires_at) 82 | 83 | if expires_in = options.fetch(:expires_in) { self.class.expires_in } 84 | expires_in.from_now 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/global_id/uri/gid.rb: -------------------------------------------------------------------------------- 1 | require 'uri/generic' 2 | require 'active_support/core_ext/module/aliasing' 3 | require 'active_support/core_ext/object/blank' 4 | require 'active_support/core_ext/hash/indifferent_access' 5 | 6 | module URI 7 | class GID < Generic 8 | # URI::GID encodes an app unique reference to a specific model as an URI. 9 | # It has the components: app name, model class name, model id and params. 10 | # All components except params are required. 11 | # 12 | # The URI format looks like "gid://app/model_name/model_id". 13 | # 14 | # Simple metadata can be stored in params. Useful if your app has multiple databases, 15 | # for instance, and you need to find out which one to look up the model in. 16 | # 17 | # Params will be encoded as query parameters like so 18 | # "gid://app/model_name/model_id?key=value&another_key=another_value". 19 | # 20 | # Params won't be typecast, they're always strings. 21 | # For convenience params can be accessed using both strings and symbol keys. 22 | # 23 | # Multi value params aren't supported. Any params encoding multiple values under 24 | # the same key will return only the last value. For example, when decoding 25 | # params like "key=first_value&key=last_value" key will only be last_value. 26 | # 27 | # Read the documentation for +parse+, +create+ and +build+ for more. 28 | alias :app :host 29 | attr_reader :model_name, :model_id, :params 30 | 31 | # Raised when creating a Global ID for a model without an id 32 | class MissingModelIdError < URI::InvalidComponentError; end 33 | class InvalidModelIdError < URI::InvalidComponentError; end 34 | 35 | # Maximum size of a model id segment 36 | COMPOSITE_MODEL_ID_MAX_SIZE = 20 37 | COMPOSITE_MODEL_ID_DELIMITER = "/" 38 | 39 | class << self 40 | # Validates +app+'s as URI hostnames containing only alphanumeric characters 41 | # and hyphens. An ArgumentError is raised if +app+ is invalid. 42 | # 43 | # URI::GID.validate_app('bcx') # => 'bcx' 44 | # URI::GID.validate_app('foo-bar') # => 'foo-bar' 45 | # 46 | # URI::GID.validate_app(nil) # => ArgumentError 47 | # URI::GID.validate_app('foo/bar') # => ArgumentError 48 | def validate_app(app) 49 | parse("gid://#{app}/Model/1").app 50 | rescue URI::Error 51 | raise ArgumentError, 'Invalid app name. ' \ 52 | 'App names must be valid URI hostnames: alphanumeric and hyphen characters only.' 53 | end 54 | 55 | # Create a new URI::GID by parsing a gid string with argument check. 56 | # 57 | # URI::GID.parse 'gid://bcx/Person/1?key=value' 58 | # 59 | # This differs from URI() and URI.parse which do not check arguments. 60 | # 61 | # URI('gid://bcx') # => URI::GID instance 62 | # URI.parse('gid://bcx') # => URI::GID instance 63 | # URI::GID.parse('gid://bcx/') # => raises URI::InvalidComponentError 64 | def parse(uri) 65 | generic_components = URI.split(uri) << nil << true # nil parser, true arg_check 66 | new(*generic_components) 67 | end 68 | 69 | # Shorthand to build a URI::GID from an app, a model and optional params. 70 | # 71 | # URI::GID.create('bcx', Person.find(5), database: 'superhumans') 72 | def create(app, model, params = nil) 73 | build app: app, model_name: model.class.name, model_id: model.id, params: params 74 | end 75 | 76 | # Create a new URI::GID from components with argument check. 77 | # 78 | # The allowed components are app, model_name, model_id and params, which can be 79 | # either a hash or an array. 80 | # 81 | # Using a hash: 82 | # 83 | # URI::GID.build(app: 'bcx', model_name: 'Person', model_id: '1', params: { key: 'value' }) 84 | # 85 | # Using an array, the arguments must be in order [app, model_name, model_id, params]: 86 | # 87 | # URI::GID.build(['bcx', 'Person', '1', key: 'value']) 88 | def build(args) 89 | parts = Util.make_components_hash(self, args) 90 | parts[:host] = parts[:app] 91 | model_id_segment = Array(parts[:model_id]).map { |p| CGI.escape(p.to_s) }.join(COMPOSITE_MODEL_ID_DELIMITER) 92 | parts[:path] = "/#{parts[:model_name]}/#{model_id_segment}" 93 | 94 | if parts[:params] && !parts[:params].empty? 95 | parts[:query] = URI.encode_www_form(parts[:params]) 96 | end 97 | 98 | super parts 99 | end 100 | end 101 | 102 | def to_s 103 | # Implement #to_s to avoid no implicit conversion of nil into string when path is nil 104 | "gid://#{app}#{path}#{'?' + query if query}" 105 | end 106 | 107 | def deconstruct_keys(_keys) 108 | {app: app, model_name: model_name, model_id: model_id, params: params} 109 | end 110 | 111 | protected 112 | def set_path(path) 113 | set_model_components(path) unless defined?(@model_name) && @model_id 114 | super 115 | end 116 | 117 | # Ruby 2.2 uses #query= instead of #set_query 118 | def query=(query) 119 | set_params parse_query_params(query) 120 | super 121 | end 122 | 123 | # Ruby 2.1 or less uses #set_query to assign the query 124 | def set_query(query) 125 | set_params parse_query_params(query) 126 | super 127 | end 128 | 129 | def set_params(params) 130 | @params = params 131 | end 132 | 133 | private 134 | COMPONENT = [ :scheme, :app, :model_name, :model_id, :params ].freeze 135 | 136 | def check_host(host) 137 | validate_component(host) 138 | super 139 | end 140 | 141 | def check_path(path) 142 | validate_component(path) 143 | set_model_components(path, true) 144 | end 145 | 146 | def check_scheme(scheme) 147 | if scheme == 'gid' 148 | true 149 | else 150 | raise URI::BadURIError, "Not a gid:// URI scheme: #{inspect}" 151 | end 152 | end 153 | 154 | def set_model_components(path, validate = false) 155 | _, model_name, model_id = path.split('/', 3) 156 | 157 | validate_component(model_name) && validate_model_id_section(model_id, model_name) if validate 158 | @model_name = model_name 159 | 160 | if model_id 161 | model_id_parts = model_id 162 | .split(COMPOSITE_MODEL_ID_DELIMITER, COMPOSITE_MODEL_ID_MAX_SIZE) 163 | .reject(&:blank?) 164 | 165 | model_id_parts.map! do |id| 166 | validate_model_id(id) 167 | CGI.unescape(id) 168 | end 169 | 170 | @model_id = model_id_parts.length == 1 ? model_id_parts.first : model_id_parts 171 | end 172 | end 173 | 174 | def validate_component(component) 175 | return component unless component.blank? 176 | 177 | raise URI::InvalidComponentError, 178 | "Expected a URI like gid://app/Person/1234: #{inspect}" 179 | end 180 | 181 | def validate_model_id_section(model_id, model_name) 182 | return model_id unless model_id.blank? 183 | 184 | raise MissingModelIdError, "Unable to create a Global ID for " \ 185 | "#{model_name} without a model id." 186 | end 187 | 188 | def validate_model_id(model_id_part) 189 | return unless model_id_part.include?('/') 190 | 191 | raise InvalidModelIdError, "Unable to create a Global ID for " \ 192 | "#{model_name} with a malformed model id." 193 | end 194 | 195 | def parse_query_params(query) 196 | Hash[URI.decode_www_form(query)].with_indifferent_access if query 197 | end 198 | end 199 | 200 | if respond_to?(:register_scheme) 201 | register_scheme('GID', GID) 202 | else 203 | @@schemes['GID'] = GID 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /lib/global_id/verifier.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/message_verifier' 2 | 3 | class GlobalID 4 | class Verifier < ActiveSupport::MessageVerifier 5 | private 6 | def encode(data, **) 7 | ::Base64.urlsafe_encode64(data) 8 | end 9 | 10 | def decode(data, **) 11 | ::Base64.urlsafe_decode64(data) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/globalid.rb: -------------------------------------------------------------------------------- 1 | require 'global_id' 2 | require 'global_id/railtie' 3 | -------------------------------------------------------------------------------- /test/cases/global_id_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class GlobalIDTest < ActiveSupport::TestCase 4 | test 'value equality' do 5 | assert_equal GlobalID.new('gid://app/Person/5'), GlobalID.new('gid://app/Person/5') 6 | end 7 | 8 | test 'invalid app name' do 9 | assert_raises ArgumentError do 10 | GlobalID.app = '' 11 | end 12 | 13 | assert_raises ArgumentError do 14 | GlobalID.app = 'blog_app' 15 | end 16 | 17 | assert_raises ArgumentError do 18 | GlobalID.app = nil 19 | end 20 | end 21 | end 22 | 23 | class GlobalIDParamEncodedTest < ActiveSupport::TestCase 24 | setup do 25 | model = Person.new('id') 26 | @gid = GlobalID.create(model) 27 | end 28 | 29 | test 'parsing' do 30 | assert_equal GlobalID.parse(@gid.to_param), @gid 31 | end 32 | 33 | test 'finding' do 34 | found = GlobalID.find(@gid.to_param) 35 | assert_kind_of @gid.model_class, found 36 | assert_equal @gid.model_id, found.id 37 | end 38 | end 39 | 40 | class GlobalIDCreationTest < ActiveSupport::TestCase 41 | setup do 42 | @uuid = '7ef9b614-353c-43a1-a203-ab2307851990' 43 | @person_gid = GlobalID.create(Person.new(5)) 44 | @person_uuid_gid = GlobalID.create(PersonUuid.new(@uuid)) 45 | @person_namespaced_gid = GlobalID.create(Person::Child.new(4)) 46 | @person_model_gid = GlobalID.create(PersonModel.new(id: 1)) 47 | @cpk_model_gid = GlobalID.create(CompositePrimaryKeyModel.new(id: ["tenant-key-value", "id-value"])) 48 | end 49 | 50 | test 'find' do 51 | assert_equal Person.find(@person_gid.model_id), @person_gid.find 52 | assert_equal Person.find(@person_uuid_gid.model_id), @person_uuid_gid.find 53 | assert_equal Person::Child.find(@person_namespaced_gid.model_id), @person_namespaced_gid.find 54 | assert_equal PersonModel.find(@person_model_gid.model_id), @person_model_gid.find 55 | assert_equal CompositePrimaryKeyModel.find(@cpk_model_gid.model_id), @cpk_model_gid.find 56 | end 57 | 58 | test 'find with class' do 59 | assert_equal Person.find(@person_gid.model_id), @person_gid.find(only: Person) 60 | assert_equal Person.find(@person_uuid_gid.model_id), @person_uuid_gid.find(only: Person) 61 | assert_equal PersonModel.find(@person_model_gid.model_id), @person_model_gid.find(only: PersonModel) 62 | assert_equal CompositePrimaryKeyModel.find(@cpk_model_gid.model_id), @cpk_model_gid.find(only: CompositePrimaryKeyModel) 63 | end 64 | 65 | test 'find with class no match' do 66 | assert_nil @person_gid.find(only: Hash) 67 | assert_nil @person_uuid_gid.find(only: Array) 68 | assert_nil @person_namespaced_gid.find(only: String) 69 | assert_nil @person_model_gid.find(only: Float) 70 | assert_nil @cpk_model_gid.find(only: Hash) 71 | end 72 | 73 | test 'find with subclass' do 74 | assert_equal Person::Child.find(@person_namespaced_gid.model_id), 75 | @person_namespaced_gid.find(only: Person) 76 | end 77 | 78 | test 'find with subclass no match' do 79 | assert_nil @person_namespaced_gid.find(only: String) 80 | end 81 | 82 | test 'find with module' do 83 | assert_equal Person.find(@person_gid.model_id), @person_gid.find(only: GlobalID::Identification) 84 | assert_equal Person.find(@person_uuid_gid.model_id), 85 | @person_uuid_gid.find(only: GlobalID::Identification) 86 | assert_equal PersonModel.find(@person_model_gid.model_id), 87 | @person_model_gid.find(only: ActiveModel::Model) 88 | assert_equal Person::Child.find(@person_namespaced_gid.model_id), 89 | @person_namespaced_gid.find(only: GlobalID::Identification) 90 | end 91 | 92 | test 'find with module no match' do 93 | assert_nil @person_gid.find(only: Enumerable) 94 | assert_nil @person_uuid_gid.find(only: Forwardable) 95 | assert_nil @person_namespaced_gid.find(only: Base64) 96 | assert_nil @person_model_gid.find(only: Enumerable) 97 | end 98 | 99 | test 'find with multiple class' do 100 | assert_equal Person.find(@person_gid.model_id), 101 | @person_gid.find(only: [0.class, Person]) 102 | assert_equal Person.find(@person_uuid_gid.model_id), 103 | @person_uuid_gid.find(only: [0.class, Person]) 104 | assert_equal PersonModel.find(@person_model_gid.model_id), 105 | @person_model_gid.find(only: [Float, PersonModel]) 106 | assert_equal Person::Child.find(@person_namespaced_gid.model_id), 107 | @person_namespaced_gid.find(only: [Person, Person::Child]) 108 | end 109 | 110 | test 'find with multiple class no match' do 111 | assert_nil @person_gid.find(only: [0.class, Numeric]) 112 | assert_nil @person_uuid_gid.find(only: [0.class, String]) 113 | assert_nil @person_model_gid.find(only: [Array, Hash]) 114 | assert_nil @person_namespaced_gid.find(only: [String, Set]) 115 | end 116 | 117 | test 'find with multiple module' do 118 | assert_equal Person.find(@person_gid.model_id), 119 | @person_gid.find(only: [Enumerable, GlobalID::Identification]) 120 | bignum_class = RUBY_VERSION >= '2.4' ? Integer : Bignum 121 | assert_equal Person.find(@person_uuid_gid.model_id), 122 | @person_uuid_gid.find(only: [bignum_class, 123 | GlobalID::Identification]) 124 | assert_equal PersonModel.find(@person_model_gid.model_id), 125 | @person_model_gid.find(only: [String, ActiveModel::Model]) 126 | assert_equal Person::Child.find(@person_namespaced_gid.model_id), 127 | @person_namespaced_gid.find(only: [Integer, GlobalID::Identification]) 128 | end 129 | 130 | test 'find with multiple module no match' do 131 | assert_nil @person_gid.find(only: [Enumerable, Base64]) 132 | assert_nil @person_uuid_gid.find(only: [Enumerable, Forwardable]) 133 | assert_nil @person_model_gid.find(only: [Base64, Enumerable]) 134 | assert_nil @person_namespaced_gid.find(only: [Enumerable, Forwardable]) 135 | end 136 | 137 | test 'as string' do 138 | assert_equal 'gid://bcx/Person/5', @person_gid.to_s 139 | assert_equal "gid://bcx/PersonUuid/#{@uuid}", @person_uuid_gid.to_s 140 | assert_equal 'gid://bcx/Person::Child/4', @person_namespaced_gid.to_s 141 | assert_equal 'gid://bcx/PersonModel/1', @person_model_gid.to_s 142 | assert_equal 'gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value', @cpk_model_gid.to_s 143 | end 144 | 145 | test 'as param' do 146 | assert_equal 'Z2lkOi8vYmN4L1BlcnNvbi81', @person_gid.to_param 147 | assert_equal @person_gid, GlobalID.parse('Z2lkOi8vYmN4L1BlcnNvbi81') 148 | 149 | assert_equal 'Z2lkOi8vYmN4L1BlcnNvblV1aWQvN2VmOWI2MTQtMzUzYy00M2ExLWEyMDMtYWIyMzA3ODUxOTkw', @person_uuid_gid.to_param 150 | assert_equal @person_uuid_gid, GlobalID.parse('Z2lkOi8vYmN4L1BlcnNvblV1aWQvN2VmOWI2MTQtMzUzYy00M2ExLWEyMDMtYWIyMzA3ODUxOTkw') 151 | 152 | assert_equal 'Z2lkOi8vYmN4L1BlcnNvbjo6Q2hpbGQvNA', @person_namespaced_gid.to_param 153 | assert_equal @person_namespaced_gid, GlobalID.parse('Z2lkOi8vYmN4L1BlcnNvbjo6Q2hpbGQvNA') 154 | 155 | assert_equal 'Z2lkOi8vYmN4L1BlcnNvbk1vZGVsLzE', @person_model_gid.to_param 156 | assert_equal @person_model_gid, GlobalID.parse('Z2lkOi8vYmN4L1BlcnNvbk1vZGVsLzE') 157 | 158 | expected_encoded = 'Z2lkOi8vYmN4L0NvbXBvc2l0ZVByaW1hcnlLZXlNb2RlbC90ZW5hbnQta2V5LXZhbHVlL2lkLXZhbHVl' 159 | assert_equal expected_encoded, @cpk_model_gid.to_param 160 | assert_equal @cpk_model_gid, GlobalID.parse(expected_encoded) 161 | end 162 | 163 | test 'as URI' do 164 | assert_equal URI('gid://bcx/Person/5'), @person_gid.uri 165 | assert_equal URI("gid://bcx/PersonUuid/#{@uuid}"), @person_uuid_gid.uri 166 | assert_equal URI('gid://bcx/Person::Child/4'), @person_namespaced_gid.uri 167 | assert_equal URI('gid://bcx/PersonModel/1'), @person_model_gid.uri 168 | assert_equal URI('gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value'), @cpk_model_gid.uri 169 | end 170 | 171 | test 'as JSON' do 172 | assert_equal 'gid://bcx/Person/5', @person_gid.as_json 173 | assert_equal '"gid://bcx/Person/5"', @person_gid.to_json 174 | 175 | assert_equal "gid://bcx/PersonUuid/#{@uuid}", @person_uuid_gid.as_json 176 | assert_equal "\"gid://bcx/PersonUuid/#{@uuid}\"", @person_uuid_gid.to_json 177 | 178 | assert_equal 'gid://bcx/Person::Child/4', @person_namespaced_gid.as_json 179 | assert_equal '"gid://bcx/Person::Child/4"', @person_namespaced_gid.to_json 180 | 181 | assert_equal 'gid://bcx/PersonModel/1', @person_model_gid.as_json 182 | assert_equal '"gid://bcx/PersonModel/1"', @person_model_gid.to_json 183 | 184 | assert_equal 'gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value', @cpk_model_gid.as_json 185 | assert_equal '"gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value"', @cpk_model_gid.to_json 186 | end 187 | 188 | test 'model id' do 189 | assert_equal '5', @person_gid.model_id 190 | assert_equal @uuid, @person_uuid_gid.model_id 191 | assert_equal '4', @person_namespaced_gid.model_id 192 | assert_equal '1', @person_model_gid.model_id 193 | assert_equal ['tenant-key-value', 'id-value'], @cpk_model_gid.model_id 194 | end 195 | 196 | test 'model name' do 197 | assert_equal 'Person', @person_gid.model_name 198 | assert_equal 'PersonUuid', @person_uuid_gid.model_name 199 | assert_equal 'Person::Child', @person_namespaced_gid.model_name 200 | assert_equal 'PersonModel', @person_model_gid.model_name 201 | assert_equal 'CompositePrimaryKeyModel', @cpk_model_gid.model_name 202 | end 203 | 204 | test 'model class' do 205 | assert_equal Person, @person_gid.model_class 206 | assert_equal PersonUuid, @person_uuid_gid.model_class 207 | assert_equal Person::Child, @person_namespaced_gid.model_class 208 | assert_equal PersonModel, @person_model_gid.model_class 209 | assert_equal CompositePrimaryKeyModel, @cpk_model_gid.model_class 210 | assert_raise ArgumentError do 211 | GlobalID.find 'gid://bcx/SignedGlobalID/5' 212 | end 213 | end 214 | 215 | test ':app option' do 216 | person_gid = GlobalID.create(Person.new(5)) 217 | assert_equal 'gid://bcx/Person/5', person_gid.to_s 218 | 219 | person_gid = GlobalID.create(Person.new(5), app: "foo") 220 | assert_equal 'gid://foo/Person/5', person_gid.to_s 221 | 222 | assert_raise ArgumentError do 223 | person_gid = GlobalID.create(Person.new(5), app: nil) 224 | end 225 | end 226 | 227 | test 'equality' do 228 | p1 = Person.new(5) 229 | p2 = Person.new(5) 230 | p3 = Person.new(10) 231 | assert_equal p1, p2 232 | assert_not_equal p2, p3 233 | 234 | gid1 = GlobalID.create(p1) 235 | gid2 = GlobalID.create(p2) 236 | gid3 = GlobalID.create(p3) 237 | assert_equal gid1, gid2 238 | assert_not_equal gid2, gid3 239 | 240 | # hash and eql? to match for two GlobalID's pointing to the same object 241 | assert_equal [gid1], [gid1, gid2].uniq 242 | assert_equal [gid1, gid3], [gid1, gid2, gid3].uniq 243 | 244 | # verify that the GlobalID's hash is different to the underlaying URI 245 | assert_not_equal gid1.hash, gid1.uri.hash 246 | 247 | # verify that URI and GlobalID do not pass the uniq test 248 | assert_equal [gid1, gid1.uri], [gid1, gid1.uri].uniq 249 | end 250 | end 251 | 252 | class GlobalIDCustomParamsTest < ActiveSupport::TestCase 253 | test 'create custom params' do 254 | gid = GlobalID.create(Person.new(5), hello: 'world') 255 | assert_equal 'world', gid.params[:hello] 256 | end 257 | 258 | test 'parse custom params' do 259 | gid = GlobalID.parse 'gid://bcx/Person/5?hello=world' 260 | assert_equal 'world', gid.params[:hello] 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /test/cases/global_identification_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class GlobalIdentificationTest < ActiveSupport::TestCase 4 | setup do 5 | @model = PersonModel.new id: 1 6 | end 7 | 8 | test 'creates a Global ID from self' do 9 | assert_equal GlobalID.create(@model), @model.to_global_id 10 | assert_equal GlobalID.create(@model), @model.to_gid 11 | end 12 | 13 | test 'creates a Global ID with custom params' do 14 | assert_equal GlobalID.create(@model, some: 'param'), @model.to_global_id(some: 'param') 15 | assert_equal GlobalID.create(@model, some: 'param'), @model.to_gid(some: 'param') 16 | end 17 | 18 | test 'creates a signed Global ID from self' do 19 | assert_equal SignedGlobalID.create(@model), @model.to_signed_global_id 20 | assert_equal SignedGlobalID.create(@model), @model.to_sgid 21 | end 22 | 23 | test 'creates a signed Global ID with purpose ' do 24 | assert_equal SignedGlobalID.create(@model, for: 'login'), @model.to_signed_global_id(for: 'login') 25 | assert_equal SignedGlobalID.create(@model, for: 'login'), @model.to_sgid(for: 'login') 26 | end 27 | 28 | test 'creates a signed Global ID with custom params' do 29 | assert_equal SignedGlobalID.create(@model, some: 'param'), @model.to_signed_global_id(some: 'param') 30 | assert_equal SignedGlobalID.create(@model, some: 'param'), @model.to_sgid(some: 'param') 31 | end 32 | 33 | test 'dup should clear memoized to_global_id' do 34 | global_id = @model.to_global_id 35 | dup_model = @model.dup 36 | dup_model.id = @model.id + 1 37 | dup_global_id = dup_model.to_global_id 38 | assert_not_equal global_id, dup_global_id 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/cases/global_locator_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class GlobalLocatorTest < ActiveSupport::TestCase 4 | setup do 5 | model = Person.new('id') 6 | @gid = model.to_gid 7 | @sgid = model.to_sgid 8 | @cpk_model = CompositePrimaryKeyModel.new(id: ["tenant-key-value", "id-value"]) 9 | @uuid_pk_model = PersonUuid.new('7ef9b614-353c-43a1-a203-ab2307851990') 10 | @cpk_gid = @cpk_model.to_gid 11 | @cpk_sgid = @cpk_model.to_sgid 12 | end 13 | 14 | test 'by GID' do 15 | found = GlobalID::Locator.locate(@gid) 16 | assert_kind_of @gid.model_class, found 17 | assert_equal @gid.model_id, found.id 18 | end 19 | 20 | test 'composite primary key model by GID' do 21 | found = GlobalID::Locator.locate(@cpk_gid) 22 | assert_kind_of @cpk_gid.model_class, found 23 | assert_equal ["tenant-key-value", "id-value"], found.id 24 | end 25 | 26 | test 'by GID with only: restriction with match' do 27 | found = GlobalID::Locator.locate(@gid, only: Person) 28 | assert_kind_of @gid.model_class, found 29 | assert_equal @gid.model_id, found.id 30 | end 31 | 32 | test 'by GID with only: restriction with match subclass' do 33 | instance = Person::Child.new 34 | gid = instance.to_gid 35 | found = GlobalID::Locator.locate(gid, only: Person) 36 | assert_kind_of gid.model_class, found 37 | assert_equal gid.model_id, found.id 38 | end 39 | 40 | test 'by GID with only: restriction with no match' do 41 | found = GlobalID::Locator.locate(@gid, only: String) 42 | assert_nil found 43 | end 44 | 45 | test 'by GID with only: restriction by multiple types' do 46 | found = GlobalID::Locator.locate(@gid, only: [String, Person]) 47 | assert_kind_of @gid.model_class, found 48 | assert_equal @gid.model_id, found.id 49 | end 50 | 51 | test 'by GID with only: restriction by module' do 52 | found = GlobalID::Locator.locate(@gid, only: GlobalID::Identification) 53 | assert_kind_of @gid.model_class, found 54 | assert_equal @gid.model_id, found.id 55 | end 56 | 57 | test 'by GID with only: restriction by module no match' do 58 | found = GlobalID::Locator.locate(@gid, only: Forwardable) 59 | assert_nil found 60 | end 61 | 62 | test 'by GID with only: restriction by multiple types w/module' do 63 | found = GlobalID::Locator.locate(@gid, only: [String, GlobalID::Identification]) 64 | assert_kind_of @gid.model_class, found 65 | assert_equal @gid.model_id, found.id 66 | end 67 | 68 | test 'by GID with eager loading' do 69 | assert_equal Person::Child.new('1', Person.new('1')), 70 | GlobalID::Locator.locate( 71 | Person::Child.new('1', Person.new('1')).to_gid, 72 | includes: :parent 73 | ) 74 | end 75 | 76 | test 'by GID trying to eager load an unexisting relationship' do 77 | assert_raises StandardError do 78 | GlobalID::Locator.locate( 79 | Person::Child.new('1', Person.new('1')).to_gid, 80 | includes: :some_non_existent_relationship 81 | ) 82 | end 83 | end 84 | 85 | test 'by many GIDs of one class' do 86 | assert_equal [ Person.new('1'), Person.new('2') ], 87 | GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person.new('2').to_gid ]) 88 | end 89 | 90 | test 'by many GIDs of a UUID pk class' do 91 | expected = [ @uuid_pk_model, @uuid_pk_model ] 92 | assert_equal expected, GlobalID::Locator.locate_many(expected.map(&:to_gid)) 93 | end 94 | 95 | test 'by many GIDs of a UUID pk class with ignore missing' do 96 | gids_to_locate = [ @uuid_pk_model, PersonUuid.new(Person::HARDCODED_ID_FOR_MISSING_PERSON), @uuid_pk_model ] 97 | expected = [ @uuid_pk_model, @uuid_pk_model ] 98 | assert_equal expected, GlobalID::Locator.locate_many(gids_to_locate.map(&:to_gid), ignore_missing: true) 99 | end 100 | 101 | test '#locate_many by composite primary key GIDs of the same class' do 102 | records = [ @cpk_model, CompositePrimaryKeyModel.new(id: ["tenant-key-value2", "id-value2"]) ] 103 | located = GlobalID::Locator.locate_many(records.map(&:to_gid)) 104 | assert_equal records, located 105 | end 106 | 107 | test '#locate_many by composite primary key GIDs of different classes' do 108 | records = [ @cpk_model, Person.new('1') ] 109 | located = GlobalID::Locator.locate_many(records.map(&:to_gid)) 110 | assert_equal records, located 111 | end 112 | 113 | test 'by many GIDs of mixed classes' do 114 | assert_equal [ Person.new('1'), Person::Child.new('1'), Person.new('2') ], 115 | GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person::Child.new('1').to_gid, Person.new('2').to_gid ]) 116 | end 117 | 118 | test 'by many GIDs with only: restriction to match subclass' do 119 | assert_equal [ Person::Child.new('1') ], 120 | GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person::Child.new('1').to_gid, Person.new('2').to_gid ], only: Person::Child) 121 | end 122 | 123 | test 'by many GIDs with eager loading' do 124 | assert_equal [ Person::Child.new('1', Person.new('1')), Person::Child.new('2', Person.new('2')) ], 125 | GlobalID::Locator.locate_many( 126 | [ Person::Child.new('1', Person.new('1')).to_gid, Person::Child.new('2', Person.new('2')).to_gid ], 127 | includes: :parent 128 | ) 129 | end 130 | 131 | test 'by many GIDs trying to eager load an unexisting relationship' do 132 | assert_raises StandardError do 133 | GlobalID::Locator.locate_many( 134 | [ Person::Child.new('1', Person.new('1')).to_gid, Person::Child.new('2', Person.new('2')).to_gid ], 135 | includes: :some_non_existent_relationship 136 | ) 137 | end 138 | end 139 | 140 | test 'by SGID' do 141 | found = GlobalID::Locator.locate_signed(@sgid) 142 | assert_kind_of @sgid.model_class, found 143 | assert_equal @sgid.model_id, found.id 144 | end 145 | 146 | test 'by SGID of a composite primary key model' do 147 | found = GlobalID::Locator.locate_signed(@cpk_sgid) 148 | assert_kind_of @cpk_sgid.model_class, found 149 | assert_equal @cpk_sgid.model_id, found.id 150 | end 151 | 152 | test 'by SGID with only: restriction with match' do 153 | found = GlobalID::Locator.locate_signed(@sgid, only: Person) 154 | assert_kind_of @sgid.model_class, found 155 | assert_equal @sgid.model_id, found.id 156 | end 157 | 158 | test 'by SGID with only: restriction with match subclass' do 159 | instance = Person::Child.new 160 | sgid = instance.to_sgid 161 | found = GlobalID::Locator.locate_signed(sgid, only: Person) 162 | assert_kind_of sgid.model_class, found 163 | assert_equal sgid.model_id, found.id 164 | end 165 | 166 | test 'by SGID with only: restriction with no match' do 167 | found = GlobalID::Locator.locate_signed(@sgid, only: String) 168 | assert_nil found 169 | end 170 | 171 | test 'by SGID with only: restriction by multiple types' do 172 | found = GlobalID::Locator.locate_signed(@sgid, only: [String, Person]) 173 | assert_kind_of @sgid.model_class, found 174 | assert_equal @sgid.model_id, found.id 175 | end 176 | 177 | test 'by SGID with only: restriction by module' do 178 | found = GlobalID::Locator.locate_signed(@sgid, only: GlobalID::Identification) 179 | assert_kind_of @sgid.model_class, found 180 | assert_equal @sgid.model_id, found.id 181 | end 182 | 183 | test 'by SGID with only: restriction by module no match' do 184 | found = GlobalID::Locator.locate_signed(@sgid, only: Enumerable) 185 | assert_nil found 186 | end 187 | 188 | test 'by SGID with only: restriction by multiple types w/module' do 189 | found = GlobalID::Locator.locate_signed(@sgid, only: [String, GlobalID::Identification]) 190 | assert_kind_of @sgid.model_class, found 191 | assert_equal @sgid.model_id, found.id 192 | end 193 | 194 | test 'by many SGIDs of one class' do 195 | assert_equal [ Person.new('1'), Person.new('2') ], 196 | GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid, Person.new('2').to_sgid ]) 197 | end 198 | 199 | test 'by many SGIDs of the same composite primary key class' do 200 | records = [ @cpk_model, CompositePrimaryKeyModel.new(id: ["tenant-key-value2", "id-value2"]) ] 201 | located = GlobalID::Locator.locate_many_signed(records.map(&:to_sgid)) 202 | assert_equal records, located 203 | end 204 | 205 | test 'by many SGIDs of mixed classes' do 206 | assert_equal [ Person.new('1'), Person::Child.new('1'), Person.new('2') ], 207 | GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid, Person::Child.new('1').to_sgid, Person.new('2').to_sgid ]) 208 | end 209 | 210 | test 'by many SGIDs of composite primary key model mixed with other models' do 211 | records = [ @cpk_model, Person.new('1') ] 212 | located = GlobalID::Locator.locate_many_signed(records.map(&:to_sgid)) 213 | assert_equal records, located 214 | end 215 | 216 | test 'by many SGIDs with only: restriction to match subclass' do 217 | assert_equal [ Person::Child.new('1') ], 218 | GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid, Person::Child.new('1').to_sgid, Person.new('2').to_sgid ], only: Person::Child) 219 | end 220 | 221 | test 'by GID string' do 222 | found = GlobalID::Locator.locate(@gid.to_s) 223 | assert_kind_of @gid.model_class, found 224 | assert_equal @gid.model_id, found.id 225 | end 226 | 227 | test 'by SGID string' do 228 | found = GlobalID::Locator.locate_signed(@sgid.to_s) 229 | assert_kind_of @sgid.model_class, found 230 | assert_equal @sgid.model_id, found.id 231 | end 232 | 233 | test 'by many SGID strings with for: restriction to match purpose' do 234 | assert_equal [ Person::Child.new('2') ], 235 | GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid(for: 'adoption').to_s, Person::Child.new('1').to_sgid.to_s, Person::Child.new('2').to_sgid(for: 'adoption').to_s ], for: 'adoption', only: Person::Child) 236 | end 237 | 238 | test 'by to_param encoding' do 239 | found = GlobalID::Locator.locate(@gid.to_param) 240 | assert_kind_of @gid.model_class, found 241 | assert_equal @gid.model_id, found.id 242 | end 243 | 244 | test 'by to_param encoding for a composite primary key model' do 245 | found = GlobalID::Locator.locate(@cpk_gid.to_param) 246 | assert_kind_of @cpk_gid.model_class, found 247 | assert_equal @cpk_gid.model_id, found.id 248 | end 249 | 250 | test 'by non-GID returns nil' do 251 | assert_nil GlobalID::Locator.locate 'This is not a GID' 252 | end 253 | 254 | test 'by non-SGID returns nil' do 255 | assert_nil GlobalID::Locator.locate_signed 'This is not a SGID' 256 | end 257 | 258 | test 'by invalid GID URI returns nil' do 259 | assert_nil GlobalID::Locator.locate 'http://app/Person/1' 260 | assert_nil GlobalID::Locator.locate 'gid://Person/1' 261 | assert_nil GlobalID::Locator.locate 'gid://app/Person' 262 | assert_nil GlobalID::Locator.locate 'gid://app/Person/1/2' 263 | end 264 | 265 | test 'locating by a GID URI with a mismatching model_id returns nil' do 266 | assert_nil GlobalID::Locator.locate 'gid://app/Person/1/2' 267 | assert_nil GlobalID::Locator.locate 'gid://app/CompositePrimaryKeyModel/tenant-key-value/id-value/something_else' 268 | assert_nil GlobalID::Locator.locate 'gid://app/CompositePrimaryKeyModel/tenant-key-value/' 269 | assert_nil GlobalID::Locator.locate 'gid://app/CompositePrimaryKeyModel/tenant-key-value' 270 | end 271 | 272 | test 'use locator with block' do 273 | GlobalID::Locator.use :foo do |gid| 274 | :foo 275 | end 276 | 277 | with_app 'foo' do 278 | assert_equal :foo, GlobalID::Locator.locate('gid://foo/Person/1') 279 | end 280 | end 281 | 282 | test 'use locator with class' do 283 | class BarLocator 284 | def locate(gid, options = {}); :bar; end 285 | def locate_many(gids, options = {}); gids.map(&:model_id); end 286 | end 287 | 288 | GlobalID::Locator.use :bar, BarLocator.new 289 | 290 | with_app 'bar' do 291 | assert_equal :bar, GlobalID::Locator.locate('gid://bar/Person/1') 292 | assert_equal ['1', '2'], GlobalID::Locator.locate_many(['gid://bar/Person/1', 'gid://bar/Person/2']) 293 | end 294 | end 295 | 296 | test 'use locator with class and single argument' do 297 | class DeprecatedBarLocator 298 | def locate(gid); :deprecated; end 299 | def locate_many(gids, options = {}); gids.map(&:model_id); end 300 | end 301 | 302 | GlobalID::Locator.use :deprecated, DeprecatedBarLocator.new 303 | 304 | with_app 'deprecated' do 305 | assert_deprecated(nil, GlobalID.deprecator) do 306 | assert_equal :deprecated, GlobalID::Locator.locate('gid://deprecated/Person/1') 307 | end 308 | assert_equal ['1', '2'], GlobalID::Locator.locate_many(['gid://deprecated/Person/1', 'gid://deprecated/Person/2']) 309 | end 310 | end 311 | 312 | test 'app locator is case insensitive' do 313 | GlobalID::Locator.use :insensitive do |gid| 314 | :insensitive 315 | end 316 | 317 | with_app 'insensitive' do 318 | assert_equal :insensitive, GlobalID::Locator.locate('gid://InSeNsItIvE/Person/1') 319 | end 320 | end 321 | 322 | test 'locator name cannot have underscore' do 323 | assert_raises ArgumentError do 324 | GlobalID::Locator.use('under_score') { |gid| 'will never be found' } 325 | end 326 | end 327 | 328 | test "by valid purpose returns right model" do 329 | instance = Person.new 330 | login_sgid = instance.to_signed_global_id(for: 'login') 331 | 332 | found = GlobalID::Locator.locate_signed(login_sgid.to_s, for: 'login') 333 | assert_kind_of login_sgid.model_class, found 334 | assert_equal login_sgid.model_id, found.id 335 | end 336 | 337 | test "by valid purpose with SGID returns right model" do 338 | instance = Person.new 339 | login_sgid = instance.to_signed_global_id(for: 'login') 340 | 341 | found = GlobalID::Locator.locate_signed(login_sgid, for: 'login') 342 | assert_kind_of login_sgid.model_class, found 343 | assert_equal login_sgid.model_id, found.id 344 | end 345 | 346 | test "by invalid purpose returns nil" do 347 | instance = Person.new 348 | login_sgid = instance.to_signed_global_id(for: 'login') 349 | 350 | assert_nil GlobalID::Locator.locate_signed(login_sgid.to_s, for: 'like_button') 351 | end 352 | 353 | test "by invalid purpose with SGID returns nil" do 354 | instance = Person.new 355 | login_sgid = instance.to_signed_global_id(for: 'login') 356 | 357 | assert_nil GlobalID::Locator.locate_signed(login_sgid, for: 'like_button') 358 | end 359 | 360 | test "by many with one record missing leading to a raise" do 361 | assert_raises RuntimeError do 362 | GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person.new(Person::HARDCODED_ID_FOR_MISSING_PERSON).to_gid ]) 363 | end 364 | end 365 | 366 | test "by many with one record missing not leading to a raise when ignoring missing" do 367 | assert_nothing_raised do 368 | GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person.new(Person::HARDCODED_ID_FOR_MISSING_PERSON).to_gid ], ignore_missing: true) 369 | end 370 | end 371 | 372 | test 'by GID without a primary key method' do 373 | model = PersonWithoutPrimaryKey.new('id') 374 | gid = model.to_gid 375 | model2 = PersonWithoutPrimaryKey.new('id2') 376 | gid2 = model.to_gid 377 | 378 | found = GlobalID::Locator.locate(gid) 379 | assert_kind_of model.class, found 380 | assert_equal 'id', found.id 381 | 382 | found = GlobalID::Locator.locate_many([gid, gid2]) 383 | assert_equal 2, found.length 384 | 385 | found = GlobalID::Locator.locate_many([gid, gid2], ignore_missing: true) 386 | assert_equal 2, found.length 387 | end 388 | 389 | private 390 | def with_app(app) 391 | old_app, GlobalID.app = GlobalID.app, app 392 | yield 393 | ensure 394 | GlobalID.app = old_app 395 | end 396 | end 397 | 398 | class ScopedRecordLocatingTest < ActiveSupport::TestCase 399 | setup do 400 | @gid = Person::Scoped.new('1').to_gid 401 | end 402 | 403 | test "by GID with scoped record" do 404 | found = GlobalID::Locator.locate(@gid) 405 | assert_kind_of @gid.model_class, found 406 | assert_equal @gid.model_id, found.id 407 | end 408 | 409 | test "by many with scoped records" do 410 | assert_equal [ Person::Scoped.new('1'), Person::Scoped.new('2') ], 411 | GlobalID::Locator.locate_many([ Person::Scoped.new('1').to_gid, Person::Scoped.new('2').to_gid ]) 412 | end 413 | 414 | test "by many with scoped and unscoped records" do 415 | assert_equal [ Person::Scoped.new('1'), Person.new('2') ], 416 | GlobalID::Locator.locate_many([ Person::Scoped.new('1').to_gid, Person.new('2').to_gid ]) 417 | end 418 | end 419 | -------------------------------------------------------------------------------- /test/cases/pattern_matching_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class URI::PatternMatchingTest < ActiveSupport::TestCase 4 | setup do 5 | @gid = URI::GID.parse('gid://bcx/Person/5?hello=worlds¶m=value') 6 | end 7 | 8 | test 'URI::GID pattern matching' do 9 | case @gid 10 | in app: 'bcx', model_name: 'Person', model_id: '5', params: { hello: _ => world, param: _ => _ } 11 | assert_equal world, 'worlds' 12 | else 13 | raise 14 | end 15 | end 16 | end 17 | 18 | class GlobalIDPatternMatchingTest < ActiveSupport::TestCase 19 | setup do 20 | @gid = GlobalID.parse('gid://bcx/Person/5?hello=worlds¶m=value') 21 | end 22 | 23 | test 'GlobalID pattern matching' do 24 | case @gid 25 | in app: 'bcx', model_name: 'Person', model_id: '5', params: { hello: _ => world, param: _ => _ } 26 | assert_equal world, 'worlds' 27 | else 28 | raise 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/cases/railtie_test.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | require 'global_id/railtie' 3 | require 'active_support/testing/isolation' 4 | 5 | 6 | module BlogApp 7 | class Application < Rails::Application; end 8 | end 9 | 10 | class RailtieTest < ActiveSupport::TestCase 11 | include ActiveSupport::Testing::Isolation 12 | 13 | def setup 14 | Rails.env = 'development' 15 | @app = BlogApp::Application.new 16 | @app.config.eager_load = false 17 | @app.config.logger = Logger.new(nil) 18 | @app.config.secret_key_base = ('x' * 30) 19 | @app.config.active_support.cache_format_version = Rails::VERSION::STRING.to_f 20 | end 21 | 22 | test 'GlobalID.app for Blog::Application defaults to blog' do 23 | @app.initialize! 24 | assert_equal 'blog-app', GlobalID.app 25 | end 26 | 27 | test 'GlobalID.app can be set with config.global_id.app =' do 28 | @app.config.global_id.app = 'foo' 29 | @app.initialize! 30 | assert_equal 'foo', GlobalID.app 31 | end 32 | 33 | test 'SignedGlobalID.expires_in can be explicitly set to nil with config.global_id.expires_in' do 34 | @app.config.global_id.expires_in = nil 35 | @app.initialize! 36 | assert_nil SignedGlobalID.expires_in 37 | end 38 | 39 | test 'config.global_id can be used to set configurations after the railtie has been loaded' do 40 | @app.config.eager_load = true 41 | @app.config.before_eager_load do 42 | @app.config.global_id.app = 'foobar' 43 | @app.config.global_id.expires_in = 1.year 44 | end 45 | 46 | @app.initialize! 47 | assert_equal 'foobar', GlobalID.app 48 | assert_equal 1.year, SignedGlobalID.expires_in 49 | end 50 | 51 | test 'config.global_id can be used to explicitly set SignedGlobalID.expires_in to nil after the railtie has been loaded' do 52 | @app.config.eager_load = true 53 | @app.config.before_eager_load do 54 | @app.config.global_id.expires_in = nil 55 | end 56 | 57 | @app.initialize! 58 | assert_nil SignedGlobalID.expires_in 59 | end 60 | 61 | 62 | test 'SignedGlobalID.verifier defaults to Blog::Application.message_verifier(:signed_global_ids) when secret_key_base is present' do 63 | @app.initialize! 64 | message = {id: 42} 65 | signed_message = SignedGlobalID.verifier.generate(message) 66 | assert_equal @app.message_verifier(:signed_global_ids).generate(message), signed_message 67 | end 68 | 69 | test 'SignedGlobalID.verifier can be set with config.global_id.verifier =' do 70 | custom_verifier = @app.config.global_id.verifier = ActiveSupport::MessageVerifier.new('muchSECRETsoHIDDEN', serializer: SERIALIZER) 71 | @app.initialize! 72 | message = {id: 42} 73 | signed_message = SignedGlobalID.verifier.generate(message) 74 | assert_equal custom_verifier.generate(message), signed_message 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /test/cases/signed_global_id_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'minitest/mock' # for stubbing Time.now as #travel doesn't have subsecond precision. 3 | 4 | class SignedGlobalIDTest < ActiveSupport::TestCase 5 | setup do 6 | @person_sgid = SignedGlobalID.create(Person.new(5)) 7 | end 8 | 9 | test 'as string' do 10 | assert_equal 'eyJfcmFpbHMiOnsibWVzc2FnZSI6IkltZHBaRG92TDJKamVDOVFaWEp6YjI0dk5TST0iLCJleHAiOm51bGwsInB1ciI6ImRlZmF1bHQifX0=--aca9c546b5cb896c06140f59732edf87ae7e2536', @person_sgid.to_s 11 | end 12 | 13 | test 'model id' do 14 | assert_equal "5", @person_sgid.model_id 15 | end 16 | 17 | test 'model class' do 18 | assert_equal Person, @person_sgid.model_class 19 | end 20 | 21 | test 'value equality' do 22 | assert_equal SignedGlobalID.create(Person.new(5)), SignedGlobalID.create(Person.new(5)) 23 | end 24 | 25 | test 'value equality with an unsigned id' do 26 | assert_equal GlobalID.create(Person.new(5)), SignedGlobalID.create(Person.new(5)) 27 | end 28 | 29 | test 'to param' do 30 | assert_equal @person_sgid.to_s, @person_sgid.to_param 31 | end 32 | 33 | test 'inspect' do 34 | assert_match(/\A#\z/, @person_sgid.inspect) 35 | end 36 | end 37 | 38 | class SignedGlobalIDPurposeTest < ActiveSupport::TestCase 39 | setup do 40 | @login_sgid = SignedGlobalID.create(Person.new(5), for: 'login') 41 | end 42 | 43 | test 'sign with purpose when :for is provided' do 44 | assert_equal "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkltZHBaRG92TDJKamVDOVFaWEp6YjI0dk5TST0iLCJleHAiOm51bGwsInB1ciI6ImxvZ2luIn19--c39de01a211a37d62b4773d1da7bff94ba2ec176", @login_sgid.to_s 45 | assert_not_equal @login_sgid, SignedGlobalID.create(Person.new(5), for: 'like-button') 46 | end 47 | 48 | test 'sign with default purpose when no :for is provided' do 49 | sgid = SignedGlobalID.create(Person.new(5)) 50 | default_sgid = SignedGlobalID.create(Person.new(5), for: "default") 51 | 52 | assert_equal "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkltZHBaRG92TDJKamVDOVFaWEp6YjI0dk5TST0iLCJleHAiOm51bGwsInB1ciI6ImRlZmF1bHQifX0=--aca9c546b5cb896c06140f59732edf87ae7e2536", sgid.to_s 53 | assert_equal sgid, default_sgid 54 | end 55 | 56 | test 'create accepts a :for' do 57 | expected = SignedGlobalID.create(Person.new(5), for: "login") 58 | assert_equal @login_sgid, expected 59 | end 60 | 61 | test 'new accepts a :for' do 62 | expected = SignedGlobalID.new(Person.new(5).to_gid.uri, for: 'login') 63 | assert_equal @login_sgid, expected 64 | end 65 | 66 | test 'parse returns nil when purpose mismatch' do 67 | sgid = @login_sgid.to_s 68 | assert_nil SignedGlobalID.parse sgid 69 | assert_nil SignedGlobalID.parse sgid, for: 'like_button' 70 | end 71 | 72 | test 'parse is backwards compatible with the self validated metadata' do 73 | legacy_sgid = "eyJnaWQiOiJnaWQ6Ly9iY3gvUGVyc29uLzUiLCJwdXJwb3NlIjoibG9naW4iLCJleHBpcmVzX2F0IjpudWxsfQ==--4b9630f3a1fb3d7d6584d95d4fac96433ec2deef" 74 | parsed_sgid = SignedGlobalID.parse(legacy_sgid, for: :login) 75 | assert_equal @login_sgid.uri, parsed_sgid.uri 76 | assert_equal @login_sgid.purpose, parsed_sgid.purpose.to_s 77 | end 78 | 79 | test 'equal only with same purpose' do 80 | expected = SignedGlobalID.create(Person.new(5), for: 'login') 81 | like_sgid = SignedGlobalID.create(Person.new(5), for: 'like_button') 82 | no_purpose_sgid = SignedGlobalID.create(Person.new(5)) 83 | 84 | assert_equal @login_sgid, expected 85 | assert_not_equal @login_sgid, like_sgid 86 | assert_not_equal @login_sgid, no_purpose_sgid 87 | end 88 | end 89 | 90 | class SignedGlobalIDExpirationTest < ActiveSupport::TestCase 91 | setup do 92 | @uri = Person.new(5).to_gid.uri 93 | end 94 | 95 | test 'expires_in defaults to class level expiration' do 96 | with_expiration_in 1.hour do 97 | encoded_sgid = SignedGlobalID.new(@uri).to_s 98 | 99 | travel 59.minutes 100 | assert_not_nil SignedGlobalID.parse(encoded_sgid) 101 | 102 | travel 2.minutes 103 | assert_nil SignedGlobalID.parse(encoded_sgid) 104 | end 105 | end 106 | 107 | test 'passing in expires_in overrides class level expiration' do 108 | with_expiration_in 1.hour do 109 | encoded_sgid = SignedGlobalID.new(@uri, expires_in: 2.hours).to_s 110 | 111 | travel 1.hour 112 | assert_not_nil SignedGlobalID.parse(encoded_sgid) 113 | 114 | travel 1.hour + 3.seconds 115 | assert_nil SignedGlobalID.parse(encoded_sgid) 116 | end 117 | end 118 | 119 | test 'passing expires_in less than a second is not expired' do 120 | encoded_sgid = SignedGlobalID.new(@uri, expires_in: 1.second).to_s 121 | present = Time.now 122 | 123 | Time.stub :now, present + 0.5.second do 124 | assert_not_nil SignedGlobalID.parse(encoded_sgid) 125 | end 126 | 127 | Time.stub :now, present + 2.seconds do 128 | assert_nil SignedGlobalID.parse(encoded_sgid) 129 | end 130 | end 131 | 132 | test 'passing expires_in nil turns off expiration checking' do 133 | with_expiration_in 1.hour do 134 | encoded_sgid = SignedGlobalID.new(@uri, expires_in: nil).to_s 135 | 136 | travel 1.hour 137 | assert_not_nil SignedGlobalID.parse(encoded_sgid) 138 | 139 | travel 1.hour 140 | assert_not_nil SignedGlobalID.parse(encoded_sgid) 141 | end 142 | end 143 | 144 | test 'passing expires_at sets expiration date' do 145 | date = Date.today.end_of_day 146 | sgid = SignedGlobalID.new(@uri, expires_at: date) 147 | 148 | assert_equal date, sgid.expires_at 149 | 150 | travel 1.day 151 | assert_nil SignedGlobalID.parse(sgid.to_s) 152 | end 153 | 154 | test 'passing nil expires_at turns off expiration checking' do 155 | with_expiration_in 1.hour do 156 | encoded_sgid = SignedGlobalID.new(@uri, expires_at: nil).to_s 157 | 158 | travel 4.hours 159 | assert_not_nil SignedGlobalID.parse(encoded_sgid) 160 | end 161 | end 162 | 163 | test 'passing expires_at overrides class level expires_in' do 164 | with_expiration_in 1.hour do 165 | date = Date.tomorrow.end_of_day 166 | sgid = SignedGlobalID.new(@uri, expires_at: date) 167 | 168 | assert_equal date, sgid.expires_at 169 | 170 | travel 2.hours 171 | assert_not_nil SignedGlobalID.parse(sgid.to_s) 172 | end 173 | end 174 | 175 | test 'favor expires_at over expires_in' do 176 | sgid = SignedGlobalID.new(@uri, expires_at: Date.tomorrow.end_of_day, expires_in: 1.hour) 177 | 178 | travel 1.hour 179 | assert_not_nil SignedGlobalID.parse(sgid.to_s) 180 | end 181 | 182 | private 183 | def with_expiration_in(expires_in) 184 | old_expires, SignedGlobalID.expires_in = SignedGlobalID.expires_in, expires_in 185 | yield 186 | ensure 187 | SignedGlobalID.expires_in = old_expires 188 | end 189 | end 190 | 191 | class SignedGlobalIDCustomParamsTest < ActiveSupport::TestCase 192 | test 'create custom params' do 193 | sgid = SignedGlobalID.create(Person.new(5), hello: 'world') 194 | assert_equal 'world', sgid.params[:hello] 195 | end 196 | 197 | test 'parse custom params' do 198 | sgid = SignedGlobalID.parse('eyJnaWQiOiJnaWQ6Ly9iY3gvUGVyc29uLzU/aGVsbG89d29ybGQiLCJwdXJwb3NlIjoiZGVmYXVsdCIsImV4cGlyZXNfYXQiOm51bGx9--7c042f09483dec470fa1088b76d9fd946eb30ffa') 199 | assert_equal 'world', sgid.params[:hello] 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /test/cases/uri_gid_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class URI::GIDTest < ActiveSupport::TestCase 4 | setup do 5 | @gid_string = 'gid://bcx/Person/5' 6 | @gid = URI::GID.parse(@gid_string) 7 | @cpk_gid_string = 'gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value' 8 | @cpk_gid = URI::GID.parse(@cpk_gid_string) 9 | end 10 | 11 | test 'parsed' do 12 | assert_equal @gid.app, 'bcx' 13 | assert_equal @gid.model_name, 'Person' 14 | assert_equal @gid.model_id, '5' 15 | assert_equal ["tenant-key-value", "id-value"], @cpk_gid.model_id 16 | end 17 | 18 | test 'parsed for non existing model class' do 19 | flat_id_gid = URI::GID.parse("gid://bcx/NonExistingModel/1") 20 | assert_equal("1", flat_id_gid.model_id) 21 | assert_equal("NonExistingModel", flat_id_gid.model_name) 22 | 23 | composite_id_gid = URI::GID.parse("gid://bcx/NonExistingModel/tenant-key-value/id-value") 24 | assert_equal(["tenant-key-value", "id-value"], composite_id_gid.model_id) 25 | assert_equal("NonExistingModel", composite_id_gid.model_name) 26 | end 27 | 28 | test 'new returns invalid gid when not checking' do 29 | assert URI::GID.new(*URI.split('gid:///')) 30 | end 31 | 32 | test 'create' do 33 | model = Person.new('5') 34 | assert_equal @gid_string, URI::GID.create('bcx', model).to_s 35 | end 36 | 37 | test 'create from a composite primary key model' do 38 | model = CompositePrimaryKeyModel.new(id: ["tenant-key-value", "id-value"]) 39 | assert_equal @cpk_gid_string, URI::GID.create('bcx', model).to_s 40 | end 41 | 42 | test 'build' do 43 | array = URI::GID.build(['bcx', 'Person', '5', nil]) 44 | assert array 45 | 46 | hash = URI::GID.build(app: 'bcx', model_name: 'Person', model_id: '5', params: nil) 47 | assert hash 48 | 49 | assert_equal array, hash 50 | end 51 | 52 | test 'build with a composite primary key' do 53 | array = URI::GID.build(['bcx', 'CompositePrimaryKeyModel', ["tenant-key-value", "id-value"], nil]) 54 | assert array 55 | 56 | hash = URI::GID.build( 57 | app: 'bcx', 58 | model_name: 'CompositePrimaryKeyModel', 59 | model_id: ["tenant-key-value", "id-value"], 60 | params: nil 61 | ) 62 | assert hash 63 | 64 | assert_equal array, hash 65 | assert_equal("gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value", array.to_s) 66 | end 67 | 68 | test 'build with wrong ordered array creates a wrong ordered gid' do 69 | assert_not_equal @gid_string, URI::GID.build(['Person', '5', 'bcx', nil]).to_s 70 | end 71 | 72 | test 'as String' do 73 | assert_equal @gid_string, @gid.to_s 74 | end 75 | 76 | test 'equal' do 77 | assert_equal @gid, URI::GID.parse(@gid_string) 78 | assert_not_equal @gid, URI::GID.parse('gid://bcxxx/Persona/1') 79 | end 80 | end 81 | 82 | class URI::GIDModelIDEncodingTest < ActiveSupport::TestCase 83 | test 'alphanumeric' do 84 | model = Person.new('John123') 85 | assert_equal 'gid://app/Person/John123', URI::GID.create('app', model).to_s 86 | end 87 | 88 | test 'non-alphanumeric' do 89 | model = Person.new('John Doe-Smith/Jones') 90 | assert_equal 'gid://app/Person/John+Doe-Smith%2FJones', URI::GID.create('app', model).to_s 91 | end 92 | 93 | test 'every part of composite primary key is encoded' do 94 | model = CompositePrimaryKeyModel.new(id: ["tenant key", "id value"]) 95 | assert_equal 'gid://app/CompositePrimaryKeyModel/tenant+key/id+value', URI::GID.create('app', model).to_s 96 | end 97 | end 98 | 99 | class URI::GIDModelIDDecodingTest < ActiveSupport::TestCase 100 | test 'alphanumeric' do 101 | assert_equal 'John123', URI::GID.parse('gid://app/Person/John123').model_id 102 | end 103 | 104 | test 'non-alphanumeric' do 105 | assert_equal 'John Doe-Smith/Jones', URI::GID.parse('gid://app/Person/John+Doe-Smith%2FJones').model_id 106 | end 107 | 108 | test 'every part of composite primary key is decoded' do 109 | gid = 'gid://app/CompositePrimaryKeyModel/tenant+key+value/id+value' 110 | assert_equal ['tenant key value', 'id value'], URI::GID.parse(gid).model_id 111 | end 112 | end 113 | 114 | class URI::GIDValidationTest < ActiveSupport::TestCase 115 | test 'missing app' do 116 | assert_invalid_component 'gid:///Person/1' 117 | end 118 | 119 | test 'missing path' do 120 | assert_invalid_component 'gid://bcx/' 121 | end 122 | 123 | test 'missing model id' do 124 | err = assert_raise(URI::GID::MissingModelIdError) { URI::GID.parse('gid://bcx/Person') } 125 | assert_match(/Unable to create a Global ID for Person/, err.message) 126 | end 127 | 128 | test 'missing model composite id' do 129 | err = assert_raise(URI::GID::MissingModelIdError) { URI::GID.parse('gid://bcx/CompositePrimaryKeyModel') } 130 | assert_match(/Unable to create a Global ID for CompositePrimaryKeyModel/, err.message) 131 | end 132 | 133 | test 'empty' do 134 | assert_invalid_component 'gid:///' 135 | end 136 | 137 | test 'invalid schemes' do 138 | assert_bad_uri 'http://bcx/Person/5' 139 | assert_bad_uri 'gyd://bcx/Person/5' 140 | assert_bad_uri '//bcx/Person/5' 141 | end 142 | 143 | private 144 | def assert_invalid_component(uri) 145 | assert_raise(URI::InvalidComponentError) { URI::GID.parse(uri) } 146 | end 147 | 148 | def assert_bad_uri(uri) 149 | assert_raise(URI::BadURIError) { URI::GID.parse(uri) } 150 | end 151 | end 152 | 153 | class URI::GIDAppValidationTest < ActiveSupport::TestCase 154 | test 'nil or blank apps are invalid' do 155 | assert_invalid_app nil 156 | assert_invalid_app '' 157 | end 158 | 159 | test 'apps containing non alphanumeric characters are invalid' do 160 | assert_invalid_app 'foo&bar' 161 | assert_invalid_app 'foo:bar' 162 | assert_invalid_app 'foo_bar' 163 | end 164 | 165 | test 'app with hyphen is allowed' do 166 | assert_equal 'foo-bar', URI::GID.validate_app('foo-bar') 167 | end 168 | 169 | private 170 | def assert_invalid_app(value) 171 | assert_raise(ArgumentError) { URI::GID.validate_app(value) } 172 | end 173 | end 174 | 175 | class URI::GIDParamsTest < ActiveSupport::TestCase 176 | setup do 177 | @gid = URI::GID.create('bcx', Person.find(5), hello: 'world') 178 | end 179 | 180 | test 'indifferent key access' do 181 | assert_equal 'world', @gid.params[:hello] 182 | assert_equal 'world', @gid.params['hello'] 183 | end 184 | 185 | test 'integer option' do 186 | gid = URI::GID.build(['bcx', 'Person', '5', integer: 20]) 187 | assert_equal '20', gid.params[:integer] 188 | end 189 | 190 | test 'multi value params returns last value' do 191 | gid = URI::GID.build(['bcx', 'Person', '5', multi: %w(one two)]) 192 | exp = { 'multi' => 'two' } 193 | assert_equal exp, gid.params 194 | end 195 | 196 | test 'as String' do 197 | assert_equal 'gid://bcx/Person/5?hello=world', @gid.to_s 198 | end 199 | 200 | test 'immutable params' do 201 | @gid.params[:param] = 'value' 202 | assert_not_equal 'gid://bcx/Person/5?hello=world¶m=value', @gid.to_s 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /test/cases/verifier_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class VerifierTest < ActiveSupport::TestCase 4 | setup do 5 | @verifier = GlobalID::Verifier.new('muchSECRETsoHIDDEN') 6 | end 7 | 8 | # Marshal.dump serializes the hash used in this test to a different string in older versions of Ruby. 9 | if RUBY_VERSION > "1.9.3" 10 | test "generates URL-safe messages" do 11 | assert_equal "BAh7B0kiCGdpZAY6BkVUSSInZ2lkOi8vYmN4L1BlcnNvbi8xMTUxODY_ZXhwaXJlc19pbgY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--fa4b8c7a28d213288fdd9b6764a5dd119a18a6fe", 12 | @verifier.generate({ "gid" => "gid://bcx/Person/115186?expires_in", "expires_at" => nil }) 13 | end 14 | else 15 | test "generates URL-safe messages" do 16 | assert_equal "BAh7B0kiCGdpZAY6BkVGSSInZ2lkOi8vYmN4L1BlcnNvbi8xMTUxODY_ZXhwaXJlc19pbgY7AEZJIg9leHBpcmVzX2F0BjsARjA=--b52bf45c68710c5c80e04e44fb122be11f9f2c49", 17 | @verifier.generate({ "gid" => "gid://bcx/Person/115186?expires_in", "expires_at" => nil }) 18 | end 19 | end 20 | 21 | test "verifies URL-safe messages" do 22 | assert_equal({ "gid" => "gid://bcx/Person/115186?expires_in", "expires_at" => nil }, 23 | @verifier.verify("BAh7B0kiCGdpZAY6BkVUSSInZ2lkOi8vYmN4L1BlcnNvbi8xMTUxODY_ZXhwaXJlc19pbgY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--fa4b8c7a28d213288fdd9b6764a5dd119a18a6fe")) 24 | end 25 | 26 | test "verifies non-URL-safe messages" do 27 | assert_equal({ "gid" => "gid://bcx/Person/115186?expires_in", "expires_at" => nil }, 28 | @verifier.verify("BAh7B0kiCGdpZAY6BkVUSSInZ2lkOi8vYmN4L1BlcnNvbi8xMTUxODY/ZXhwaXJlc19pbgY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ae5e44055262447fdbf5d5d39d5120cfa7d5fbe6")) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'forwardable' 3 | require 'active_support' 4 | require 'active_support/testing/autorun' 5 | 6 | require 'global_id' 7 | require 'models/person' 8 | require 'models/person_model' 9 | require 'models/composite_primary_key_model' 10 | 11 | require 'json' 12 | 13 | GlobalID.app = 'bcx' 14 | 15 | # Default serializers is Marshal, whose format changed 1.9 -> 2.0, 16 | # so use a trivial serializer for our tests. 17 | SERIALIZER = JSON 18 | 19 | VERIFIER = ActiveSupport::MessageVerifier.new('muchSECRETsoHIDDEN', serializer: SERIALIZER) 20 | SignedGlobalID.verifier = VERIFIER 21 | -------------------------------------------------------------------------------- /test/models/composite_primary_key_model.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | 3 | class CompositePrimaryKeyModel 4 | include ActiveModel::Model 5 | include GlobalID::Identification 6 | 7 | attr_accessor :id 8 | 9 | def self.primary_key 10 | [:tenant_key, :id] 11 | end 12 | 13 | def self.find(id_or_ids) 14 | raise "id is not composite" unless id_or_ids.is_a?(Array) 15 | multi_record_fetch = id_or_ids.first.is_a?(Array) 16 | if multi_record_fetch 17 | id_or_ids.map do |id| 18 | raise "id doesn't match primary key #{primary_key}" if id.size != primary_key.size 19 | new id: id 20 | end 21 | else 22 | raise "id doesn't match primary key #{primary_key}" if id_or_ids.size != primary_key.size 23 | new id: id_or_ids 24 | end 25 | end 26 | 27 | def where(conditions) 28 | keys = conditions.keys 29 | raise "only primary key condition was expected" if keys.size != 1 30 | pk = keys.first 31 | raise "key is not the `#{primary_key}` primary key" if pk != primary_key 32 | 33 | conditions[pk].map do |id| 34 | raise "id doesn't match primary key #{primary_key}" if id.size != primary_key.size 35 | new id: id 36 | end 37 | end 38 | 39 | def ==(other) 40 | id == other.try(:id) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/models/person.rb: -------------------------------------------------------------------------------- 1 | class Person 2 | include GlobalID::Identification 3 | 4 | HARDCODED_ID_FOR_MISSING_PERSON = '1000' 5 | 6 | attr_reader :id 7 | 8 | def self.primary_key 9 | :id 10 | end 11 | 12 | def self.find(id_or_ids) 13 | if id_or_ids.is_a? Array 14 | ids = id_or_ids 15 | ids.collect { |id| find(id) } 16 | else 17 | id = id_or_ids 18 | 19 | if id == HARDCODED_ID_FOR_MISSING_PERSON 20 | raise 'Person missing' 21 | else 22 | new(id) 23 | end 24 | end 25 | end 26 | 27 | def self.where(conditions) 28 | (conditions[primary_key] - [HARDCODED_ID_FOR_MISSING_PERSON]).collect { |id| new(id) } 29 | end 30 | 31 | def initialize(id = 1) 32 | @id = id 33 | end 34 | 35 | def ==(other) 36 | other.is_a?(self.class) && id == other.try(:id) 37 | end 38 | end 39 | 40 | class PersonUuid < Person 41 | def self.primary_key 42 | :uuid 43 | end 44 | end 45 | 46 | class Person::Scoped < Person 47 | def initialize(*) 48 | super 49 | @find_allowed = false 50 | end 51 | 52 | def self.unscoped 53 | @find_allowed = true 54 | yield 55 | end 56 | 57 | def self.find(*) 58 | super if @find_allowed 59 | end 60 | end 61 | 62 | class Person::ChildWithParent < Person 63 | def self.find(id_or_ids) 64 | if id_or_ids.is_a? Array 65 | ids = id_or_ids 66 | ids.collect { |id| find(id) } 67 | else 68 | id = id_or_ids 69 | 70 | if id == HARDCODED_ID_FOR_MISSING_PERSON 71 | raise 'Person missing' 72 | else 73 | Person::Child.new(id, Person.new(id)) 74 | end 75 | end 76 | end 77 | 78 | def self.where(conditions) 79 | (conditions[:id] - [HARDCODED_ID_FOR_MISSING_PERSON]).collect do |id| 80 | Person::Child.new(id, Person.new(id)) 81 | end 82 | end 83 | end 84 | 85 | class Person::Child < Person 86 | attr_accessor :parent 87 | 88 | def initialize(id = 1, parent = nil) 89 | @id = id 90 | @parent = parent 91 | end 92 | 93 | def self.includes(relationships) 94 | return Person::ChildWithParent if relationships == :parent 95 | return self if relationships.nil? 96 | 97 | raise StandardError, 'Relationship does not exist' 98 | end 99 | 100 | def ==(other) 101 | other.is_a?(self.class) && id == other.try(:id) && parent == other.parent 102 | end 103 | end 104 | 105 | class PersonWithoutPrimaryKey 106 | include GlobalID::Identification 107 | 108 | attr_reader :id 109 | 110 | def self.find(id_or_ids) 111 | if id_or_ids.is_a? Array 112 | ids = id_or_ids 113 | ids.collect { |id| find(id) } 114 | else 115 | id = id_or_ids 116 | new(id) 117 | end 118 | end 119 | 120 | def self.where(conditions) 121 | (conditions[:id]).collect { |id| new(id) } 122 | end 123 | 124 | def initialize(id = 1) 125 | @id = id 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/models/person_model.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | 3 | class PersonModel 4 | include ActiveModel::Model 5 | include GlobalID::Identification 6 | 7 | attr_accessor :id 8 | 9 | def self.primary_key 10 | :id 11 | end 12 | 13 | def self.find(id) 14 | new id: id 15 | end 16 | 17 | def ==(other) 18 | id == other.try(:id) 19 | end 20 | end 21 | --------------------------------------------------------------------------------