Skip to content

Commit

Permalink
Implement URI::GID
Browse files Browse the repository at this point in the history
Codify the encoding of an app unique reference to a specific model by building
on the foundation of URI::Generic.

Closes #18
  • Loading branch information
sogamoso authored and kaspth committed Sep 25, 2014
1 parent a129223 commit 608c2b3
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 75 deletions.
1 change: 1 addition & 0 deletions lib/global_id.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'global_id/global_id'

autoload :SignedGlobalID, 'global_id/signed_global_id'

class GlobalID
Expand Down
47 changes: 12 additions & 35 deletions lib/global_id/global_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@
require 'active_support/core_ext/string/inflections' # For #model_class constantize
require 'active_support/core_ext/array/access'
require 'active_support/core_ext/object/try' # For #find
require 'uri'
require 'active_support/core_ext/module/delegation'
require 'global_id/uri/gid'

class GlobalID
class << self
attr_reader :app

def create(model, options = {})
app = options.fetch :app, GlobalID.app
raise ArgumentError, "An app is required to create a GlobalID. Pass the :app option or set the default GlobalID.app." unless app
new URI("gid://#{app}/#{model.class.name}/#{model.id}"), options
if app = options.fetch(:app) { GlobalID.app }
new URI::GID.create(app, model), options
else
raise ArgumentError, 'An app is required to create a GlobalID. ' \
'Pass the :app option or set the default GlobalID.app.'
end
end

def find(gid, options = {})
Expand All @@ -25,14 +29,7 @@ def parse(gid, options = {})
end

def app=(app)
@app = validate_app(app)
end

def validate_app(app)
URI.parse('gid:///').hostname = app
rescue URI::InvalidComponentError
raise ArgumentError, 'Invalid app name. ' \
'App names must be valid URI hostnames: alphanumeric and hyphen characters only.'
@app = URI::GID.validate_app(app)
end

private
Expand All @@ -47,10 +44,11 @@ def repad_gid(gid)
end
end

attr_reader :uri, :app, :model_name, :model_id
attr_reader :uri
delegate :app, :model_name, :model_id, :to_s, to: :uri

def initialize(gid, options = {})
extract_uri_components gid
@uri = gid.is_a?(URI::GID) ? gid : URI::GID.parse(gid)
end

def find(options = {})
Expand All @@ -65,29 +63,8 @@ def ==(other)
other.is_a?(GlobalID) && @uri == other.uri
end

def to_s
@uri.to_s
end

def to_param
# remove the = padding character for a prettier param -- it'll be added back in parse_encoded_gid
Base64.urlsafe_encode64(to_s).sub(/=+$/, '')
end

private
PATH_REGEXP = %r(\A/([^/]+)/([^/]+)\z)

# Pending a URI::GID to handle validation
def extract_uri_components(gid)
@uri = gid.is_a?(URI) ? gid : URI.parse(gid)
raise URI::BadURIError, "Not a gid:// URI scheme: #{@uri.inspect}" unless @uri.scheme == 'gid'

if @uri.path =~ PATH_REGEXP
@app = @uri.host
@model_name = $1
@model_id = $2
else
raise URI::InvalidURIError, "Expected a URI like gid://app/Person/1234: #{@uri.inspect}"
end
end
end
2 changes: 1 addition & 1 deletion lib/global_id/locator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def locate_signed(sgid, options = {})
def use(app, locator = nil, &locator_block)
raise ArgumentError, 'No locator provided. Pass a block or an object that responds to #locate.' unless locator || block_given?

GlobalID.validate_app(app)
URI::GID.validate_app(app)

@locators[normalize_app(app)] = locator || BlockLocator.new(locator_block)
end
Expand Down
127 changes: 127 additions & 0 deletions lib/global_id/uri/gid.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
require 'uri/generic'
require 'active_support/core_ext/module/aliasing'
require 'active_support/core_ext/object/blank'

module URI
class GID < Generic
# URI::GID encodes an app unique reference to a specific model as an URI.
# It has three components: the app name, the model's class name and the
# model's id.
# The URI format looks like "gid://app/model_name/model_id".
#
# Read the documentation for +parse+, +create+ and +build+ for more.
alias :app :host
attr_reader :model_name, :model_id

class << self
# Validates +app+'s as URI hostnames containing only alphanumeric characters
# and hyphens. An ArgumentError is raised if +app+ is invalid.
#
# URI::GID.validate_app('bcx') # => 'bcx'
# URI::GID.validate_app('foo-bar') # => 'foo-bar'
#
# URI::GID.validate_app(nil) # => ArgumentError
# URI::GID.validate_app('foo/bar') # => ArgumentError
def validate_app(app)
parse("gid://#{app}/Model/1").app
rescue URI::Error
raise ArgumentError, 'Invalid app name. ' \
'App names must be valid URI hostnames: alphanumeric and hyphen characters only.'
end

# Create a new URI::GID by parsing a gid string with argument check.
#
# URI::GID.parse 'gid://bcx/Person/1'
#
# This differs from URI() and URI.parse which do not check arguments.
#
# URI('gid://bcx') # => URI::GID instance
# URI.parse('gid://bcx') # => URI::GID instance
# URI::GID.parse('gid://bcx/') # => raises URI::InvalidComponentError
def parse(uri)
generic_components = URI.split(uri) << nil << true # nil parser, true arg_check
new *generic_components
end

# Shorthand to build a URI::GID from and app and a model.
#
# URI::GID.create('bcx', Person.find(5))
def create(app, model)
build app: app, model_name: model.class.name, model_id: model.id
end

# Create a new URI::GID from components with argument check.
#
# The allowed components are app, model_name and model_id, which can be
# either a hash or an array.
#
# Using a hash:
#
# URI::GID.build(app: 'bcx', model_name: 'Person', model_id: '1')
#
# Using an array, the arguments must be in order [app, model_name, model_id]:
#
# URI::GID.build(['bcx', 'Person', '1'])
def build(args)
parts = Util.make_components_hash(self, args)
parts[:host] = parts[:app]
parts[:path] = "/#{parts[:model_name]}/#{parts[:model_id]}"

super parts
end
end

def to_s
# Implement #to_s to avoid no implicit conversion of nil into string when path is nil
"gid://#{app}/#{model_name}/#{model_id}"
end

protected
def set_path(path)
set_model_components(path) unless @model_name && @model_id
super
end

private
COMPONENT = [ :scheme, :app, :model_name, :model_id ].freeze

# Extracts model_name and model_id from the URI path.
PATH_REGEXP = %r(\A/([^/]+)/?([^/]+)?\z)

def check_host(host)
validate_component(host)
super
end

def check_path(path)
validate_component(path)
set_model_components(path, true)
end

def check_scheme(scheme)
if scheme == 'gid'
super
else
raise URI::BadURIError, "Not a gid:// URI scheme: #{inspect}"
end
end

def set_model_components(path, validate = false)
_, model_name, model_id = path.match(PATH_REGEXP).to_a

validate_component(model_name) && validate_component(model_id) if validate

@model_name = model_name
@model_id = model_id
end

def validate_component(component)
return component unless component.blank?

raise URI::InvalidComponentError,
"Expected a URI like gid://app/Person/1234: #{inspect}"
end
end

@@schemes['GID'] = GID
end
42 changes: 3 additions & 39 deletions test/cases/global_id_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,17 @@ class GlobalIDTest < ActiveSupport::TestCase
assert_equal GlobalID.new('gid://app/model/id'), GlobalID.new('gid://app/model/id')
end

test 'empty hostname' do
test 'invalid app name' do
assert_raises ArgumentError do
GlobalID.app = ''
end
end

test 'invalid hostname' do
assert_raises ArgumentError do
GlobalID.app = 'blog_app'
end
end
end

class URIValidationTest < ActiveSupport::TestCase
test 'scheme' do
assert_raise URI::BadURIError do
GlobalID.new('gyd://app/Person/1')
end
end

test 'app' do
assert_raise URI::InvalidURIError do
GlobalID.new('gid://Person/1')
end
end

test 'path' do
assert_raise URI::InvalidURIError do
GlobalID.new('gid://app/Person')
end

assert_raise URI::InvalidURIError do
GlobalID.new('gid://app/Person/1/2')
assert_raises ArgumentError do
GlobalID.app = nil
end
end
end
Expand Down Expand Up @@ -207,19 +185,5 @@ class GlobalIDCreationTest < ActiveSupport::TestCase
assert_raise ArgumentError do
person_gid = GlobalID.create(Person.new(5), app: nil)
end

begin
origin_app = GlobalID.app
GlobalID.app = nil

assert_raise ArgumentError do
GlobalID.create(Person.new(5))
end

person_gid = GlobalID.create(Person.new(5), app: "foo")
assert_equal 'gid://foo/Person/5', person_gid.to_s
ensure
GlobalID.app = origin_app
end
end
end
105 changes: 105 additions & 0 deletions test/cases/uri_gid_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
require 'helper'

class URI::GIDTest < ActiveSupport::TestCase
setup do
@gid_string = 'gid://bcx/Person/5'
@gid = URI::GID.parse(@gid_string)
end

test 'parsed' do
assert_equal @gid.app, 'bcx'
assert_equal @gid.model_name, 'Person'
assert_equal @gid.model_id, '5'
end

test 'new returns invalid gid when not checking' do
assert URI::GID.new(*URI.split('gid:///'))
end

test 'create' do
model = Person.new('5')
assert_equal @gid_string, URI::GID.create('bcx', model).to_s
end

test 'build' do
array = URI::GID.build(['bcx', 'Person', '5'])
assert array

hash = URI::GID.build(app: 'bcx', model_name: 'Person', model_id: '5')
assert hash

assert_equal array, hash
end

test 'build with wrong ordered array creates a wrong ordered gid' do
assert_not_equal @gid_string, URI::GID.build(['Person', '5', 'bcx']).to_s
end

test 'as String' do
assert_equal @gid_string, @gid.to_s
end

test 'equal' do
assert_equal @gid, URI::GID.parse(@gid_string)
assert_not_equal @gid, URI::GID.parse('gid://bcxxx/Persona/1')
end
end

class URI::GIDValidationTest < ActiveSupport::TestCase
test 'missing app' do
assert_invalid_component 'gid:///Person/1'
end

test 'missing path' do
assert_invalid_component 'gid://bcx/'
end

test 'missing model id' do
assert_invalid_component 'gid://bcx/Person'
end

test 'too many model ids' do
assert_invalid_component 'gid://bcx/Person/1/2'
end

test 'empty' do
assert_invalid_component 'gid:///'
end

test 'invalid schemes' do
assert_bad_uri 'http://bcx/Person/5'
assert_bad_uri 'gyd://bcx/Person/5'
assert_bad_uri '//bcx/Person/5'
end

private
def assert_invalid_component(uri)
assert_raise(URI::InvalidComponentError) { URI::GID.parse(uri) }
end

def assert_bad_uri(uri)
assert_raise(URI::BadURIError) { URI::GID.parse(uri) }
end
end

class URI::GIDAppValidationTest < ActiveSupport::TestCase
test 'nil or blank apps are invalid' do
assert_invalid_app nil
assert_invalid_app ''
end

test 'apps containing non alphanumeric characters are invalid' do
assert_invalid_app 'foo/bar'
assert_invalid_app 'foo:bar'
assert_invalid_app 'foo_bar'
end

test 'app with hyphen is allowed' do
assert_equal 'foo-bar', URI::GID.validate_app('foo-bar')
end

private
def assert_invalid_app(value)
assert_raise(ArgumentError) { URI::GID.validate_app(value) }
end
end

0 comments on commit 608c2b3

Please sign in to comment.