Skip to content

Commit

Permalink
Add support for deep marshaling of GIDs (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
hopsoft authored Oct 17, 2023
1 parent d82740b commit cbafe28
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 15 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,25 @@ copy = Email.new_from_portable_hash(gid)
signed_copy = Email.new_from_portable_hash(sgid)
```

### Deeply Nested GlobalIDs

UniversalID implicitly handles deeply nested objects that implement GlobalID.

```rb
campaign = Campaign.create(name: "Example Campaign", description: "Example Description", trigger: "Example Trigger")
portable = UniversalID::PortableHash.new({name: "Example", list: [1,2,3], object: {nested: true}, campaign: campaign})

gid_param = portable.to_gid_param #..... Z2lkOi8vVW5pdmVyc2FsSUQvVW5pdmVyc2FsSUQ6OlBvcnRhYmxlSGFzaC...
sgid_param = portable.to_sgid_param #... eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJZ0hFWjJsa09pOHZWVzVwZG...

UniversalID::PortableHash.parse_gid(gid_param).find
{name: "Example", list: [1,2,3], object: {nested: true}, campaign: <Campaign id: ...>}}


UniversalID::PortableHash.parse_gid(sgid_param).find
{"name"=>"Example", "list"=>[1, 2, 3], "object"=>{"nested"=>true, campaign: <Campaign id: ...>}}
```

### Running Tests, Benchmarks, and the Demo

```
Expand Down
7 changes: 7 additions & 0 deletions lib/universal_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
require "globalid"
require "active_model"
require "active_support/all"

module UniversalID
def self.deprecator
@deprecator ||= ActiveSupport::Deprecation.new("0.1", "UniversalID")
end
end

require_relative "universal_id/version"
require_relative "universal_id/errors"
require_relative "universal_id/config"
Expand Down
10 changes: 10 additions & 0 deletions lib/universal_id/portable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ module UniversalID::Portable
extend ActiveSupport::Concern
include GlobalID::Identification

GID_REGEX = /\Agid:\/\/.+\z/
GID_PARAM_REGEX = /\A[0-9a-zA-Z_+-\/]{20,}={0,2}.*\z/

class_methods do
def config
UniversalID.config
end

def possible_gid_string?(value)
return false unless value.is_a?(String)
GID_REGEX.match?(value) || GID_PARAM_REGEX.match?(value)
end

def parse_gid(gid, options = {})
return gid if gid.is_a?(GlobalID)
return nil unless possible_gid_string?(gid)
GlobalID.parse(gid, options) || SignedGlobalID.parse(gid, options)
end

Expand Down
55 changes: 44 additions & 11 deletions lib/universal_id/portable_hash.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,42 @@ def config

def find(id)
compressed_json = Base64.urlsafe_decode64(id)
JSON.parse Zlib::Inflate.inflate(compressed_json)
hydrate JSON.parse(Zlib::Inflate.inflate(compressed_json))
rescue => error
raise UniversalID::LocatorError.new(id, error)
end

def deep_transform(hash, options)
include_list = options[:only]
exclude_list = options[:except]
def dehydrate(hash, options)
include_list = options[:only] || []
exclude_list = options[:except] || []
hash.each_with_object({}) do |(key, value), memo|
key = key.to_s
next if include_list.any? && include_list.none?(key)
next if exclude_list.any?(key)
transform(value, options: options) { |val| memo[key] = val }
deep_dehydrate(value, options: options) { |val| memo[key] = val }
end
end

alias_method :deep_transform, :dehydrate
UniversalID.deprecator.deprecate_methods self, :deep_transform, deep_transform: "Use `dehydrate` instead."

def hydrate(hash)
hash.each_with_object({}) do |(key, value), memo|
deep_hydrate(value) { |val| memo[key] = val }
end
end

private

def transform(value, options:)
value = case value
when Hash then deep_transform(value, options)
when Array then value.map { |val| transform(val, options: options) }
else value
def deep_dehydrate(value, options:)
value = if implements_gid?(value)
implements_gid?(value) ? value.to_gid_param : value
else
case value
when Array then value.map { |val| deep_dehydrate(val, options: options) }
when Hash then dehydrate(value, options)
else value
end
end

if block_given?
Expand All @@ -41,14 +54,34 @@ def transform(value, options:)

value
end

def deep_hydrate(value)
value = if possible_gid_string?(value)
parse_gid(value) || value
else
case value
when Array then value.map { |val| deep_hydrate(val) }
when Hash then hydrate(value)
else value
end
end

value = value.find if value.is_a?(GlobalID)
yield value if block_given?
value
end

def implements_gid?(value)
value.respond_to? :to_gid_param
end
end

delegate :config, to: :"self.class"
attr_reader :options

def initialize(hash)
@options = merge_options!(extract_options!(hash))
merge! self.class.deep_transform(hash, options)
merge! self.class.dehydrate(hash, options)
end

def id
Expand Down
1 change: 1 addition & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "bundler"
require "pry-byebug"
require "pry-doc"
require "active_support"
require "active_support/test_case"
require "faker"

Expand Down
4 changes: 2 additions & 2 deletions test/universal_id/active_model_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def test_to_portable_hash_sgid_with_expiration
assert_equal @campaign.name, copy.name
assert_equal @campaign.to_portable_hash, copy.to_portable_hash

sleep 0.1
sleep 0.1.seconds

copy = Campaign.new_from_portable_hash(param)
assert copy.invalid?
Expand All @@ -100,7 +100,7 @@ def test_to_portable_hash_sgid_with_purpose_and_expiration
assert_equal @campaign.name, copy.name
assert_equal @campaign.to_portable_hash, copy.to_portable_hash

sleep 0.1
sleep 0.1.seconds

copy = Campaign.new_from_portable_hash(param, for: "Testing")
assert copy.invalid?
Expand Down
107 changes: 107 additions & 0 deletions test/universal_id/nested_global_ids_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

require_relative "../test_helper"

class UniversalID::NestedGlobalIDsTest < ActiveSupport::TestCase
def setup
@campaign = Campaign.find_or_create_by!(name: "Example Campaign", description: "Example Description", trigger: "Example Trigger")
@portable_hash = UniversalID::PortableHash.new(
test: true,
example: "value",
other: nil,
nested: {
keep: "keep",
remove: "remove"
},
campaign: @campaign,
portable_hash_options: {except: %w[remove]} # combines with config
)
@expected = {"test" => true, "example" => "value", "nested" => {"keep" => "keep"}, "campaign" => @campaign}
end

def teardown
@campaign.destroy
end

def test_to_gid
assert_equal @expected, @portable_hash.to_gid.find
end

def test_to_sgid
assert_equal @expected, @portable_hash.to_sgid.find
end

def test_find_by_portable_hash_id
assert_equal @expected, UniversalID::PortableHash.find(@portable_hash.id)
end

def test_parse_and_find_by_gid_param
assert_equal @expected, UniversalID::PortableHash.parse_gid(@portable_hash.to_gid_param).find
end

def test_parse_and_find_by_sgid_param
assert_equal @expected, UniversalID::PortableHash.parse_gid(@portable_hash.to_sgid_param).find
end

def test_parse_and_find_by_gid_deep
a = UniversalID::PortableHash.new(
test: true,
nested: @portable_hash
)

assert UniversalID::PortableHash.possible_gid_string?(a["nested"])

b = UniversalID::PortableHash.new(
test: true,
nested: a
)

assert UniversalID::PortableHash.possible_gid_string?(b["nested"])

expected = {
"test" => true,
"nested" => {
"test" => true,
"nested" => {
"test" => true,
"example" => "value",
"nested" => {"keep" => "keep"},
"campaign" => @campaign
}
}
}

assert_equal expected, UniversalID::PortableHash.parse_gid(b.to_gid_param).find
end

def test_parse_and_find_by_sgid_deep
a = UniversalID::PortableHash.new(
test: true,
nested: @portable_hash
)

assert UniversalID::PortableHash.possible_gid_string?(a["nested"])

b = UniversalID::PortableHash.new(
test: true,
nested: a
)

assert UniversalID::PortableHash.possible_gid_string?(b["nested"])

expected = {
"test" => true,
"nested" => {
"test" => true,
"nested" => {
"test" => true,
"example" => "value",
"nested" => {"keep" => "keep"},
"campaign" => @campaign
}
}
}

assert_equal expected, UniversalID::PortableHash.parse_gid(b.to_sgid_param).find
end
end
4 changes: 2 additions & 2 deletions universalid.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ Gem::Specification.new do |s|

s.require_paths = ["lib"]

s.add_dependency "activemodel", ">= 6.0", "< 7.1"
s.add_dependency "activesupport", ">= 6.0", "< 7.1"
s.add_dependency "activemodel", ">= 6.0"
s.add_dependency "activesupport", ">= 6.0"
s.add_dependency "globalid", ">= 1.1"

s.add_development_dependency "activerecord"
Expand Down

0 comments on commit cbafe28

Please sign in to comment.