Skip to content

Commit

Permalink
Merge pull request #127 from RIPAGlobal/feature/request-attributes
Browse files Browse the repository at this point in the history
Allow requesting specific attributes (derived from #102)
  • Loading branch information
pond authored Jun 12, 2024
2 parents ea10c4e + 1e98b16 commit 1634db5
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ def find_record(record_id)
# representation, with a "show" location specified via #url_for.
#
def record_to_scim(record)
record.to_scim(location: url_for(action: :show, id: record.send(@id_column)))
record.to_scim(
location: url_for(action: :show, id: record.send(@id_column)),
include_attributes: params.fetch(:attributes, "").split(",")
)
end

# Save a record, dealing with validation exceptions by raising SCIM
Expand Down
94 changes: 78 additions & 16 deletions app/models/scimitar/resources/mixin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@ module Resources
# #...
# end
#
# The mixing-in class _must+ implement the read accessor identified by the
# The mixing-in class _must_ implement the read accessor identified by the
# value of the "list" key, returning any indexed, Enumerable collection
# (e.g. an Array or ActiveRecord::Relation instance). The optional key
# ":find_with" is defined with a Proc that's passed the SCIM entry at each
# ":find_with" is defined with a Proc that is passed the SCIM entry at each
# list position. It must use this to look up the equivalent entry for
# association via the write accessor described by the ":list" key. In the
# example above, "find_with"'s Proc might look at a SCIM entry value which
Expand Down Expand Up @@ -204,12 +204,12 @@ module Resources
# Define this method to return a Hash that maps field names you wish to
# support in SCIM filter queries to corresponding attributes in the in the
# mixing-in class. If +nil+ then filtering is not supported in the
# ResouceController subclass which declares that it maps to the mixing-in
# ResourceController subclass which declares that it maps to the mixing-in
# class. If not +nil+ but a SCIM filter enquiry is made for an unmapped
# attribute, an 'invalid filter' exception is raised.
#
# If using ActiveRecord support in Scimitar::Lists::QueryParser, the mapped
# entites are columns and that's expressed in the names of keys described
# entities are columns and that's expressed in the names of keys described
# below; if you have other approaches to searching, these might be virtual
# attributes or other such constructs rather than columns. That would be up
# to your non-ActiveRecord's implementation to decide.
Expand Down Expand Up @@ -262,8 +262,8 @@ module Resources
# both of the keys 'created' and 'lastModified', as Symbols. The values
# should be methods that the including method supports which return a
# creation or most-recently-updated time, respectively. The returned object
# mustsupport #iso8601 to convert to a String representation. Example for a
# typical ActiveRecord object with standard timestamps:
# must support #iso8601 to convert to a String representation. Example for
# a typical ActiveRecord object with standard timestamps:
#
# def self.scim_timestamps_map
# {
Expand Down Expand Up @@ -338,16 +338,32 @@ def scim_queryable_attributes
# Render self as a SCIM object using ::scim_attributes_map. Fields that
# are marked as <tt>returned: 'never'</tt> are excluded.
#
# +location+:: The location (HTTP(S) full URI) of this resource, in the
# domain of the object including this mixin - "your" IDs,
# not the remote SCIM client's external IDs. #url_for is a
# good way to generate this.
# +location+:: The location (HTTP(S) full URI) of this
# resource in the domain of the object including
# this mixin - "your" IDs, not the remote SCIM
# client's external IDs. #url_for is a good way
# to generate this.
#
def to_scim(location:)
# +include_attributes+:: The attributes that should be included in the
# response, in the form of a list of full
# attribute paths. Schema IDs are not supported.
# See RFC 7644 section 3.9 and section 3.10 for
# more. When a collection is given, +nil+ value
# items are also excluded from the response. If
# omitted or given an empty collection, all
# attributes are included.
#
def to_scim(location:, include_attributes: [])
map = self.class.scim_attributes_map()
resource_type = self.class.scim_resource_type()
timestamps_map = self.class.scim_timestamps_map() if self.class.respond_to?(:scim_timestamps_map)
attrs_hash = self.to_scim_backend(data_source: self, resource_type: resource_type, attrs_map_or_leaf_value: map)
attrs_hash = self.to_scim_backend(
data_source: self,
resource_type: resource_type,
attrs_map_or_leaf_value: map,
include_attributes: include_attributes
)

resource = resource_type.new(attrs_hash)
meta_attrs_hash = { location: location }

Expand Down Expand Up @@ -532,6 +548,16 @@ def from_scim_patch!(patch_hash:)
# +attrs_map_or_leaf_value+:: The attribute map. At the top level,
# this is from ::scim_attributes_map.
#
# +include_attributes+:: The attributes that should be included
# in the response, in the form of a list
# of full attribute paths. Schema IDs are
# not supported. See RFC 7644 section
# 3.9 and section 3.10 for more. When a
# collection is given, +nil+ value items
# are also excluded from the response. If
# omitted or given an empty collection,
# all attributes are included.
#
# Internal recursive calls also send:
#
# +attribute_path+:: Array of path components to the
Expand All @@ -543,8 +569,15 @@ def to_scim_backend(
data_source:,
resource_type:,
attrs_map_or_leaf_value:,
include_attributes:,
attribute_path: []
)
# NOTE EARLY EXIT
#
return unless scim_attribute_included?(
include_attributes: include_attributes,
attribute_path: attribute_path
)

# On assumption of a top-level attributes list, the 'return never'
# state is only checked on the recursive call from a Hash type. The
Expand All @@ -554,19 +587,23 @@ def to_scim_backend(
#
case attrs_map_or_leaf_value
when Hash # Expected at top-level of any map, or nested within
attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
result = attrs_map_or_leaf_value.each.with_object({}) do |(key, value), hash|
nested_attribute_path = attribute_path + [key]

if resource_type.find_attribute(*nested_attribute_path)&.returned != "never"
hash[key] = to_scim_backend(
data_source: data_source,
resource_type: resource_type,
attribute_path: nested_attribute_path,
attrs_map_or_leaf_value: value
attrs_map_or_leaf_value: value,
include_attributes: include_attributes
)
end
end

result.compact! if include_attributes.any?
result

when Array # Static or dynamic mapping against lists in data source
built_dynamic_list = false
mapped_array = attrs_map_or_leaf_value.map do |value|
Expand All @@ -580,7 +617,8 @@ def to_scim_backend(
data_source: data_source,
resource_type: resource_type,
attribute_path: attribute_path,
attrs_map_or_leaf_value: value[:using]
attrs_map_or_leaf_value: value[:using],
include_attributes: include_attributes
)
)
static_hash
Expand All @@ -593,7 +631,8 @@ def to_scim_backend(
data_source: list_entry,
resource_type: resource_type,
attribute_path: attribute_path,
attrs_map_or_leaf_value: value[:using]
attrs_map_or_leaf_value: value[:using],
include_attributes: include_attributes
)
end

Expand Down Expand Up @@ -1501,6 +1540,29 @@ def clear_data_for_removal!(altering_hash:, with_attr_map:)
return handled
end

# Related to to_scim_backend, this methods tells whether +attribute_path+
# should be included in the current +include_attributes+. This method
# implements the attributes request from RFC 7644, section 3.9 and 3.10.
#
# +include_attributes+:: The attributes that should be included
# in the response, in the form of a list of
# full attribute paths. See RFC 7644 section
# 3.9 and section 3.10. An empty collection
# will include all attributes.
#
# +attribute_path+:: Array of path components to the attribute,
# e.g. <tt>["name", "givenName"]</tt>.
#
def scim_attribute_included?(include_attributes:, attribute_path:)
return true unless attribute_path.any? && include_attributes.any?

full_path = attribute_path.join(".")
attribute_included = full_path.start_with?(*include_attributes)
will_include_nested = include_attributes.any? { |att| att.start_with?(full_path) }

attribute_included || will_include_nested
end

end # "included do"
end # "module Mixin"
end # "module Resources"
Expand Down
41 changes: 41 additions & 0 deletions spec/models/scimitar/resources/mixin_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,47 @@ def self.scim_queryable_attributes
# =========================================================================

context '#to_scim' do
context 'with list of requested attributes' do
it 'compiles instance attribute values into a SCIM representation, including only the requested attributes' do
uuid = SecureRandom.uuid

instance = MockUser.new
instance.primary_key = uuid
instance.scim_uid = 'AA02984'
instance.username = 'foo'
instance.password = 'correcthorsebatterystaple'
instance.first_name = 'Foo'
instance.last_name = 'Bar'
instance.work_email_address = '[email protected]'
instance.home_email_address = nil
instance.work_phone_number = '+642201234567'
instance.organization = 'SOMEORG'

g1 = MockGroup.create!(display_name: 'Group 1')
g2 = MockGroup.create!(display_name: 'Group 2')
g3 = MockGroup.create!(display_name: 'Group 3')

g1.mock_users << instance
g3.mock_users << instance

scim = instance.to_scim(location: "https://test.com/mock_users/#{uuid}", include_attributes: %w[id userName name groups.display groups.value organization])
json = scim.to_json()
hash = JSON.parse(json)

expect(hash).to eql({
'id' => uuid,
'userName' => 'foo',
'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
'meta' => {'location'=>"https://test.com/mock_users/#{uuid}", 'resourceType'=>'User'},
'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
'organization' => 'SOMEORG',
},
})
end
end # "context 'with list of requested attributes' do"

context 'with a UUID, renamed primary key column' do
it 'compiles instance attribute values into a SCIM representation, but omits do-not-return fields' do
uuid = SecureRandom.uuid
Expand Down
21 changes: 21 additions & 0 deletions spec/requests/active_record_backed_resources_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@
expect(usernames).to match_array(['2'])
end

it 'returns only the requested attributes' do
get '/Users', params: {
format: :scim,
attributes: "id,name"
}

expect(response.status ).to eql(200)
expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')

result = JSON.parse(response.body)

expect(result['totalResults']).to eql(3)
expect(result['Resources'].size).to eql(3)

keys = result['Resources'].map { |resource| resource.keys }.flatten.uniq
expect(keys).to match_array(%w[id meta name schemas urn:ietf:params:scim:schemas:extension:enterprise:2.0:User])
expect(result.dig('Resources', 0, 'id')).to eql @u1.primary_key.to_s
expect(result.dig('Resources', 0, 'name', 'givenName')).to eql 'Foo'
expect(result.dig('Resources', 0, 'name', 'familyName')).to eql 'Ark'
end

it 'applies a filter, with case-insensitive attribute matching (GitHub issue #37)' do
get '/Users', params: {
format: :scim,
Expand Down

0 comments on commit 1634db5

Please sign in to comment.