Comparing changes

v0.2.1 v0.2.2
24 commits 22 files changed

Commits

90899ba add schemas mo 2019-01-08 00:03:16
a1db51d update README mo 2019-01-07 21:04:02
ad258db add ci mo 2019-01-07 18:14:36
2200e42 bump version. mo 2019-01-07 18:11:53
926d43e move type coercion to type class mokha 2019-01-07 17:57:31
0a7df9d emit json for complex attributes mokha 2019-01-07 17:30:02
ed792bf start to support complex attributes mokha 2019-01-07 16:28:22
fcd59af fix linter errors mokha 2019-01-07 16:07:58
ed06faf allow setup to run from mac os mokha 2019-01-07 16:03:21
3f2a396 allow reference types mo 2019-01-06 20:03:35
2ddf015 normalize value based on type mo 2019-01-06 19:57:48
ae464b5 fix lint errors mo 2019-01-06 03:04:47
9abbd67 extract Attribute class mo 2019-01-06 03:01:52
f207c6d start to design resource mo 2019-01-06 02:32:12
bin/console
@@ -2,7 +2,7 @@
 # frozen_string_literal: true
 
 require 'bundler/setup'
-require 'saml/kit'
+require 'scim/kit'
 
 # You can add fixtures and/or initialization code here to make experimenting
 # with your gem easier. You can also use a different console, if you like.
bin/setup
@@ -3,4 +3,4 @@ set -euo pipefail
 IFS=$'\n\t'
 set -vx
 
-bundle check || bundle install --jobs $(nproc)
+bundle check || bundle install --jobs "$(sysctl -n hw.ncpu || nproc)"
lib/scim/kit/v2/templates/attribute.json.jbuilder
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+json.key_format! camelize: :lower
+if type.complex? && !type.multi_valued
+  json.set! type.name do
+    dynamic_attributes.values.each do |attribute|
+      render attribute, json: json
+    end
+  end
+else
+  json.set! type.name, _value
+end
lib/scim/kit/v2/templates/meta.json.jbuilder
@@ -5,4 +5,4 @@ json.location location
 json.resource_type resource_type
 json.created created.iso8601 if created
 json.last_modified last_modified.iso8601 if last_modified
-json.version version.to_i if version
+json.version version if version
lib/scim/kit/v2/templates/resource.json.jbuilder
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+json.key_format! camelize: :lower
+json.schemas schemas.map(&:id)
+json.id id
+json.external_id external_id
+json.meta do
+  render meta, json: json
+end
+schemas.each do |schema|
+  if schema.core?
+    schema.attributes.each do |type|
+      render dynamic_attributes[type.name], json: json
+    end
+  else
+    json.set! schema.id do
+      schema.attributes.each do |type|
+        render dynamic_attributes[type.name], json: json
+      end
+    end
+  end
+end
lib/scim/kit/v2/attributable.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module V2
+      # Represents a dynamic attribute that corresponds to a SCIM type
+      module Attributable
+        attr_reader :dynamic_attributes
+
+        def define_attributes_for(types)
+          @dynamic_attributes = {}.with_indifferent_access
+          types.each do |type|
+            dynamic_attributes[type.name] = Attribute.new(type: type)
+            extend(create_module_for(type))
+          end
+        end
+
+        private
+
+        def attribute_for(name)
+          dynamic_attributes[name]
+        end
+
+        def read_attribute(name)
+          attribute = attribute_for(name)
+          return attribute._value if attribute.type.multi_valued
+
+          attribute.type.complex? ? attribute : attribute._value
+        end
+
+        def write_attribute(name, value)
+          attribute_for(name)._value = value
+        end
+
+        def create_module_for(type)
+          name = type.name.to_sym
+          Module.new do
+            define_method(name) do |*_args|
+              read_attribute(name)
+            end
+
+            define_method("#{name}=") do |*args|
+              write_attribute(name, args[0])
+            end
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/v2/attribute.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module V2
+      # Represents a SCIM Attribute
+      class Attribute
+        include Attributable
+        include Templatable
+        attr_reader :type
+        attr_reader :_value
+
+        def initialize(type:, value: nil)
+          @type = type
+          @_value = value
+          define_attributes_for(type.attributes)
+        end
+
+        def _value=(new_value)
+          @_value = type.coerce(new_value)
+
+          if type.canonical_values &&
+             !type.canonical_values.empty? &&
+             !type.canonical_values.include?(new_value)
+            raise ArgumentError, new_value
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/v2/attribute_type.rb
@@ -16,6 +16,13 @@ module Scim
           reference: 'reference',
           complex: 'complex'
         }.freeze
+        COERCION = {
+          string: ->(x) { x.to_s },
+          decimal: ->(x) { x.to_f },
+          integer: ->(x) { x.to_i },
+          datetime: ->(x) { x.is_a?(::String) ? DateTime.parse(x) : x },
+          binary: ->(x) { Base64.strict_encode64(x) }
+        }.freeze
         attr_accessor :canonical_values
         attr_accessor :case_exact
         attr_accessor :description
@@ -28,8 +35,8 @@ module Scim
         attr_reader :uniqueness
 
         def initialize(name:, type: :string)
-          @name = name
-          @type = type
+          @name = name.to_s.underscore
+          @type = type.to_sym
           @description = ''
           @multi_valued = false
           @required = false
@@ -37,7 +44,7 @@ module Scim
           @mutability = Mutability::READ_WRITE
           @returned = Returned::DEFAULT
           @uniqueness = Uniqueness::NONE
-          raise ArgumentError, :type unless DATATYPES[type.to_sym]
+          raise ArgumentError, :type unless DATATYPES[@type]
         end
 
         def mutability=(value)
@@ -53,9 +60,9 @@ module Scim
         end
 
         def add_attribute(name:, type: :string)
-          @type = :complex
           attribute = AttributeType.new(name: name, type: type)
           yield attribute if block_given?
+          @type = :complex
           attributes << attribute
         end
 
@@ -64,8 +71,6 @@ module Scim
           @reference_types = value
         end
 
-        private
-
         def attributes
           @attributes ||= []
         end
@@ -74,6 +79,18 @@ module Scim
           type_is?(:complex)
         end
 
+        def coerce(value)
+          if type_is?(:boolean) && ![true, false].include?(value)
+            raise ArgumentError, value
+          end
+          return value if multi_valued
+
+          coercion = COERCION[type]
+          coercion ? coercion.call(value) : value
+        end
+
+        private
+
         def string?
           type_is?(:string)
         end
lib/scim/kit/v2/meta.rb
@@ -14,7 +14,8 @@ module Scim
         def initialize(resource_type, location)
           @resource_type = resource_type
           @location = location
-          @version = @created = @last_modified = Time.now
+          @created = @last_modified = Time.now
+          @version = @created.to_i
         end
       end
     end
lib/scim/kit/v2/resource.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module V2
+      # Represents a SCIM Resource
+      class Resource
+        include Attributable
+        include Templatable
+
+        attr_accessor :id, :external_id
+        attr_reader :meta
+        attr_reader :schemas
+
+        def initialize(schemas:, location:)
+          @meta = Meta.new(schemas[0].name, location)
+          @schemas = schemas
+          schemas.each do |schema|
+            define_attributes_for(schema.attributes)
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/v2/schema.rb
@@ -20,7 +20,11 @@ module Scim
         def add_attribute(name:, type: :string)
           attribute = AttributeType.new(name: name, type: type)
           yield attribute if block_given?
-          @attributes << attribute
+          attributes << attribute
+        end
+
+        def core?
+          id.include?(Schemas::CORE)
         end
 
         def self.build(*args)
lib/scim/kit/dynamic_attributes.rb
@@ -2,7 +2,7 @@
 
 module Scim
   module Kit
-    # Allows dynamic assignment of attributes.
+    # Allows dynamic creation of attributes.
     module DynamicAttributes
       def method_missing(method, *args)
         return super unless respond_to_missing?(method)
lib/scim/kit/templatable.rb
@@ -13,7 +13,7 @@ module Scim
       end
 
       def to_h
-        JSON.parse(to_json, symbolize_names: true)
+        JSON.parse(to_json, symbolize_names: true).with_indifferent_access
       end
 
       def render(model, options)
lib/scim/kit/version.rb
@@ -2,6 +2,6 @@
 
 module Scim
   module Kit
-    VERSION = '0.2.1'
+    VERSION = '0.2.2'
   end
 end
lib/scim/kit.rb
@@ -2,17 +2,21 @@
 
 require 'tilt'
 require 'tilt/jbuilder'
+require 'active_support/core_ext/hash/indifferent_access'
 
 require 'scim/kit/dynamic_attributes'
 require 'scim/kit/templatable'
 require 'scim/kit/template'
 require 'scim/kit/version'
 
+require 'scim/kit/v2/attributable'
+require 'scim/kit/v2/attribute'
 require 'scim/kit/v2/attribute_type'
 require 'scim/kit/v2/authentication_scheme'
 require 'scim/kit/v2/messages'
 require 'scim/kit/v2/meta'
 require 'scim/kit/v2/mutability'
+require 'scim/kit/v2/resource'
 require 'scim/kit/v2/resource_type'
 require 'scim/kit/v2/returned'
 require 'scim/kit/v2/schema'
spec/fixtures/avatar.png
Binary file
spec/scim/kit/v2/attribute_spec.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::V2::Attribute do
+  subject { described_class.new(type: type) }
+
+  context 'with strings' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'userName', type: :string) }
+
+    context 'when valid' do
+      let(:user_name) { FFaker::Internet.user_name }
+
+      before { subject._value = user_name }
+
+      specify { expect(subject._value).to eql(user_name) }
+      specify { expect(subject.as_json[:userName]).to eql(user_name) }
+    end
+
+    context 'when multiple values are allowed' do
+      before do
+        type.multi_valued = true
+        subject._value = %w[superman batman]
+      end
+
+      specify { expect(subject._value).to match_array(%w[superman batman]) }
+    end
+
+    context 'when integer' do
+      let(:number) { rand(100) }
+
+      before { subject._value = number }
+
+      specify { expect(subject._value).to eql(number.to_s) }
+    end
+
+    context 'when datetime' do
+      let(:datetime) { DateTime.now }
+
+      before { subject._value = datetime }
+
+      specify { expect(subject._value).to eql(datetime.to_s) }
+    end
+
+    context 'when not matching a canonical value' do
+      before { type.canonical_values = %w[batman robin] }
+
+      specify { expect { subject._value = 'spider man' }.to raise_error(ArgumentError) }
+    end
+
+    context 'when canonical value is given' do
+      before do
+        type.canonical_values = %w[batman robin]
+        subject._value = 'batman'
+      end
+
+      specify { expect(subject._value).to eql('batman') }
+    end
+  end
+
+  context 'with boolean' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'hungry', type: :boolean) }
+
+    context 'when true' do
+      before { subject._value = true }
+
+      specify { expect(subject._value).to be(true) }
+      specify { expect(subject.as_json[:hungry]).to be(true) }
+    end
+
+    context 'when false' do
+      before { subject._value = false }
+
+      specify { expect(subject._value).to be(false) }
+      specify { expect(subject.as_json[:hungry]).to be(false) }
+    end
+
+    context 'when string' do
+      specify { expect { subject._value = 'hello' }.to raise_error(ArgumentError) }
+    end
+  end
+
+  context 'with decimal' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'measurement', type: :decimal) }
+
+    context 'when given float' do
+      before { subject._value = Math::PI }
+
+      specify { expect(subject._value).to eql(Math::PI) }
+      specify { expect(subject.as_json[:measurement]).to be(Math::PI) }
+    end
+
+    context 'when given an integer' do
+      before { subject._value = 42 }
+
+      specify { expect(subject._value).to eql(42.to_f) }
+      specify { expect(subject.as_json[:measurement]).to be(42.to_f) }
+    end
+  end
+
+  context 'with integer' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'age', type: :integer) }
+
+    context 'when given integer' do
+      before { subject._value = 34 }
+
+      specify { expect(subject._value).to be(34) }
+      specify { expect(subject.as_json[:age]).to be(34) }
+    end
+
+    context 'when given float' do
+      before { subject._value = Math::PI }
+
+      specify { expect(subject._value).to eql(Math::PI.to_i) }
+    end
+  end
+
+  context 'with datetime' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'birthdate', type: :datetime) }
+    let(:datetime) { DateTime.new(2019, 0o1, 0o6, 12, 35, 0o0) }
+
+    context 'when given a date time' do
+      before { subject._value = datetime }
+
+      specify { expect(subject._value).to eql(datetime) }
+      specify { expect(subject.as_json[:birthdate]).to eql(datetime.iso8601) }
+    end
+
+    context 'when given a string' do
+      before { subject._value = datetime.to_s }
+
+      specify { expect(subject._value).to eql(datetime) }
+    end
+  end
+
+  context 'with binary' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'photo', type: :binary) }
+    let(:photo) { IO.read('./spec/fixtures/avatar.png', mode: 'rb') }
+
+    context 'when given a .png' do
+      before { subject._value = photo }
+
+      specify { expect(subject._value).to eql(Base64.strict_encode64(photo)) }
+      specify { expect(subject.as_json[:photo]).to eql(Base64.strict_encode64(photo)) }
+    end
+  end
+
+  context 'with reference' do
+    let(:type) { Scim::Kit::V2::AttributeType.new(name: 'group', type: :reference) }
+    let(:uri) { FFaker::Internet.uri('https') }
+
+    before { subject._value = uri }
+
+    specify { expect(subject._value).to eql(uri) }
+    specify { expect(subject.as_json[:group]).to eql(uri) }
+  end
+
+  context 'with complex type' do
+    let(:type) do
+      x = Scim::Kit::V2::AttributeType.new(name: 'name', type: :complex)
+      x.add_attribute(name: 'familyName')
+      x.add_attribute(name: 'givenName')
+      x
+    end
+
+    before do
+      subject.family_name = 'Garrett'
+      subject.given_name = 'Tsuyoshi'
+    end
+
+    specify { expect(subject.family_name).to eql('Garrett') }
+    specify { expect(subject.given_name).to eql('Tsuyoshi') }
+    specify { expect(subject.as_json[:name][:familyName]).to eql('Garrett') }
+    specify { expect(subject.as_json[:name][:givenName]).to eql('Tsuyoshi') }
+  end
+
+  context 'with multi valued complex type' do
+    let(:type) do
+      x = Scim::Kit::V2::AttributeType.new(name: 'emails', type: :complex)
+      x.multi_valued = true
+      x.add_attribute(name: 'value')
+      x.add_attribute(name: 'primary', type: :boolean)
+      x
+    end
+    let(:email) { FFaker::Internet.email }
+    let(:other_email) { FFaker::Internet.email }
+
+    before do
+      subject._value = [
+        { value: email, primary: true },
+        { value: other_email, primary: false }
+      ]
+    end
+
+    specify { expect(subject._value).to match_array([{ value: email, primary: true }, { value: other_email, primary: false }]) }
+    specify { expect(subject.as_json[:emails]).to match_array([{ value: email, primary: true }, { value: other_email, primary: false }]) }
+  end
+end
spec/scim/kit/v2/attribute_type_spec.rb
@@ -15,9 +15,9 @@ RSpec.describe Scim::Kit::V2::AttributeType do
     describe 'defaults' do
       subject { described_class.new(name: 'displayName') }
 
-      specify { expect(subject.name).to eql('displayName') }
+      specify { expect(subject.name).to eql('display_name') }
       specify { expect(subject.type).to be(:string) }
-      specify { expect(subject.to_h[:name]).to eql('displayName') }
+      specify { expect(subject.to_h[:name]).to eql('display_name') }
       specify { expect(subject.to_h[:type]).to eql('string') }
       specify { expect(subject.to_h[:multiValued]).to be(false) }
       specify { expect(subject.to_h[:description]).to eql('') }
spec/scim/kit/v2/resource_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::V2::Resource do
+  subject { described_class.new(schemas: schemas, location: resource_location) }
+
+  let(:schemas) { [schema] }
+  let(:schema) { Scim::Kit::V2::Schema.new(id: Scim::Kit::V2::Schemas::USER, name: 'User', location: FFaker::Internet.uri('https')) }
+  let(:resource_location) { FFaker::Internet.uri('https') }
+
+  context 'with common attributes' do
+    let(:id) { SecureRandom.uuid }
+    let(:external_id) { SecureRandom.uuid }
+    let(:created_at) { Time.now }
+    let(:updated_at) { Time.now }
+    let(:version) { SecureRandom.uuid }
+
+    before do
+      subject.id = id
+      subject.external_id = external_id
+      subject.meta.created = created_at
+      subject.meta.last_modified = updated_at
+      subject.meta.version = version
+    end
+
+    specify { expect(subject.id).to eql(id) }
+    specify { expect(subject.external_id).to eql(external_id) }
+    specify { expect(subject.meta.resource_type).to eql('User') }
+    specify { expect(subject.meta.location).to eql(resource_location) }
+    specify { expect(subject.meta.created).to eql(created_at) }
+    specify { expect(subject.meta.last_modified).to eql(updated_at) }
+    specify { expect(subject.meta.version).to eql(version) }
+
+    describe '#as_json' do
+      specify { expect(subject.as_json[:schemas]).to match_array([schema.id]) }
+      specify { expect(subject.as_json[:id]).to eql(id) }
+      specify { expect(subject.as_json[:externalId]).to eql(external_id) }
+      specify { expect(subject.as_json[:meta][:resourceType]).to eql('User') }
+      specify { expect(subject.as_json[:meta][:location]).to eql(resource_location) }
+      specify { expect(subject.as_json[:meta][:created]).to eql(created_at.iso8601) }
+      specify { expect(subject.as_json[:meta][:lastModified]).to eql(updated_at.iso8601) }
+      specify { expect(subject.as_json[:meta][:version]).to eql(version) }
+    end
+  end
+
+  context 'with custom string attribute' do
+    let(:user_name) { FFaker::Internet.user_name }
+
+    before do
+      schema.add_attribute(name: 'userName')
+      subject.user_name = user_name
+    end
+
+    specify { expect(subject.user_name).to eql(user_name) }
+  end
+
+  context 'with attribute named "type"' do
+    before do
+      schema.add_attribute(name: 'type')
+      subject.type = 'User'
+    end
+
+    specify { expect(subject.type).to eql('User') }
+    specify { expect(subject.as_json[:type]).to eql('User') }
+    specify { expect(subject.send(:attribute_for, :type).type).to be_instance_of(Scim::Kit::V2::AttributeType) }
+  end
+
+  context 'with a complex attribute' do
+    before do
+      schema.add_attribute(name: 'name') do |x|
+        x.add_attribute(name: 'familyName')
+        x.add_attribute(name: 'givenName')
+      end
+      subject.name.family_name = 'Garrett'
+      subject.name.given_name = 'Tsuyoshi'
+    end
+
+    specify { expect(subject.name.family_name).to eql('Garrett') }
+    specify { expect(subject.name.given_name).to eql('Tsuyoshi') }
+
+    describe '#as_json' do
+      specify { expect(subject.as_json[:name][:familyName]).to eql('Garrett') }
+      specify { expect(subject.as_json[:name][:givenName]).to eql('Tsuyoshi') }
+    end
+  end
+
+  context 'with a complex multi valued attribute' do
+    let(:email) { FFaker::Internet.email }
+    let(:other_email) { FFaker::Internet.email }
+
+    before do
+      schema.add_attribute(name: 'emails', type: :complex) do |x|
+        x.multi_valued = true
+        x.add_attribute(name: 'value')
+        x.add_attribute(name: 'primary', type: :boolean)
+      end
+      subject.emails = [
+        { value: email, primary: true },
+        { value: other_email, primary: false }
+      ]
+    end
+
+    specify { expect(subject.emails).to match_array([{ value: email, primary: true }, { value: other_email, primary: false }]) }
+    specify { expect(subject.as_json[:emails]).to match_array([{ value: email, primary: true }, { value: other_email, primary: false }]) }
+  end
+
+  context 'with multiple schemas' do
+    let(:schemas) { [schema, extension] }
+    let(:extension) { Scim::Kit::V2::Schema.new(id: extension_id, name: 'Extension', location: FFaker::Internet.uri('https')) }
+    let(:extension_id) { 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' }
+
+    before do
+      extension.add_attribute(name: :department)
+      subject.department = 'voltron'
+    end
+
+    specify { expect(subject.department).to eql('voltron') }
+    specify { expect(subject.as_json[extension_id][:department]).to eql('voltron') }
+  end
+end
spec/scim/kit/v2/schema_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Scim::Kit::V2::Schema do
       subject.add_attribute(name: 'displayName')
     end
 
-    specify { expect(result[:attributes][0][:name]).to eql('displayName') }
+    specify { expect(result[:attributes][0][:name]).to eql('display_name') }
     specify { expect(result[:attributes][0][:type]).to eql('string') }
     specify { expect(result[:attributes][0][:multiValued]).to be(false) }
     specify { expect(result[:attributes][0][:description]).to eql('') }
.gitlab-ci.yml
@@ -0,0 +1,11 @@
+image: ruby:2.6
+
+before_script:
+  - apt-get update && apt-get install -y locales
+  - echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
+  - locale-gen
+  - export LC_ALL=en_US.UTF-8
+
+rspec:
+  script:
+    - bin/cibuild
README.md
@@ -83,7 +83,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
 
 ## Contributing
 
-Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/scim-kit.
+Bug reports and pull requests are welcome on GitHub at https://github.com/mokha/scim-kit.
 
 ## License