Comparing changes
v0.2.6
→
v0.2.7
21 commits
12 files changed
Commits
Changed files (12)
lib
scim
spec
lib/scim/kit/v2/attribute.rb
@@ -5,25 +5,48 @@ module Scim
module V2
# Represents a SCIM Attribute
class Attribute
+ include ::ActiveModel::Validations
include Attributable
include Templatable
attr_reader :type
attr_reader :_value
+ validate :presence_of_value, if: proc { |x| x.type.required }
+ validate :inclusion_of_value, if: proc { |x| x.type.canonical_values }
+ validate :validate_type
+
def initialize(type:, value: nil)
@type = type
@_value = value
define_attributes_for(type.attributes)
end
+ def _assign(new_value, coerce: true)
+ @_value = coerce ? type.coerce(new_value) : new_value
+ end
+
def _value=(new_value)
- @_value = type.coerce(new_value)
+ _assign(new_value, coerce: true)
+ end
+
+ private
+
+ def presence_of_value
+ return unless type.required && _value.blank?
+
+ errors.add(type.name, I18n.t('errors.messages.blank'))
+ end
+
+ def inclusion_of_value
+ return if type.canonical_values.include?(_value)
+
+ errors.add(type.name, I18n.t('errors.messages.inclusion'))
+ end
+
+ def validate_type
+ return if type.valid?(_value)
- if type.canonical_values &&
- !type.canonical_values.empty? &&
- !type.canonical_values.include?(new_value)
- raise ArgumentError, new_value
- end
+ errors.add(type.name, I18n.t('errors.messages.invalid'))
end
end
end
lib/scim/kit/v2/attribute_type.rb
@@ -6,23 +6,6 @@ module Scim
# Represents a scim Attribute type
class AttributeType
include Templatable
- DATATYPES = {
- string: 'string',
- boolean: 'boolean',
- decimal: 'decimal',
- integer: 'integer',
- datetime: 'dateTime',
- binary: 'binary',
- 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
@@ -80,17 +63,52 @@ module Scim
end
def coerce(value)
- if type_is?(:boolean) && ![true, false].include?(value)
- raise ArgumentError, value
+ return value if complex?
+
+ if multi_valued
+ return value unless value.respond_to?(:to_a)
+
+ value.to_a.map do |x|
+ COERCION.fetch(type, ->(y) { y }).call(x)
+ end
+ else
+ COERCION.fetch(type, ->(x) { x }).call(value)
+ end
+ end
+
+ def valid?(value)
+ if multi_valued
+ return false unless value.respond_to?(:to_a)
+
+ return value.to_a.all? { |x| validate(x) }
end
- return value if multi_valued
- coercion = COERCION[type]
- coercion ? coercion.call(value) : value
+ complex? ? valid_complex?(value) : valid_simple?(value)
end
private
+ def validate(value)
+ complex? ? valid_complex?(value) : valid_simple?(value)
+ end
+
+ def valid_simple?(value)
+ VALIDATIONS[type]&.call(value)
+ end
+
+ def valid_complex?(item)
+ return false unless item.is_a?(Hash)
+
+ item.keys.each do |key|
+ return false unless type_for(key)&.valid?(item[key])
+ end
+ end
+
+ def type_for(name)
+ name = name.to_s.underscore
+ attributes.find { |x| x.name.to_s.underscore == name }
+ end
+
def string?
type_is?(:string)
end
lib/scim/kit/v2/resource.rb
@@ -5,6 +5,7 @@ module Scim
module V2
# Represents a SCIM Resource
class Resource
+ include ::ActiveModel::Validations
include Attributable
include Templatable
@@ -12,6 +13,9 @@ module Scim
attr_reader :meta
attr_reader :schemas
+ validates_presence_of :id
+ validate :schema_validations
+
def initialize(schemas:, location:)
@meta = Meta.new(schemas[0].name, location)
@schemas = schemas
@@ -19,6 +23,21 @@ module Scim
define_attributes_for(schema.attributes)
end
end
+
+ private
+
+ def schema_validations
+ schemas.each do |schema|
+ schema.attributes.each do |type|
+ validate_attribute(type)
+ end
+ end
+ end
+
+ def validate_attribute(type)
+ attribute = attribute_for(type.name)
+ errors.copy!(attribute.errors) unless attribute.valid?
+ end
end
end
end
lib/scim/kit/v2/schema.rb
@@ -13,6 +13,7 @@ module Scim
def initialize(id:, name:, location:)
@id = id
@name = name
+ @description = ''
@meta = Meta.new('Schema', location)
@meta.created = @meta.last_modified = @meta.version = nil
@attributes = []
lib/scim/kit/v2.rb
@@ -21,6 +21,51 @@ module Scim
module Kit
# Version 2 of the SCIM RFC https://tools.ietf.org/html/rfc7644
module V2
+ BASE64 = %r(
+ \A([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z
+ )x.freeze
+ BOOLEAN_VALUES = [true, false].freeze
+ DATATYPES = {
+ string: 'string',
+ boolean: 'boolean',
+ decimal: 'decimal',
+ integer: 'integer',
+ datetime: 'dateTime',
+ binary: 'binary',
+ reference: 'reference',
+ complex: 'complex'
+ }.freeze
+ COERCION = {
+ binary: lambda { |x|
+ VALIDATIONS[:binary].call(x) ? x : Base64.strict_encode64(x)
+ },
+ boolean: lambda { |x|
+ return true if x == 'true'
+ return false if x == 'false'
+
+ x
+ },
+ datetime: ->(x) { x.is_a?(::String) ? DateTime.parse(x) : x },
+ decimal: ->(x) { x.to_f },
+ integer: ->(x) { x.to_i },
+ string: ->(x) { x.to_s }
+ }.freeze
+ VALIDATIONS = {
+ binary: ->(x) { x.is_a?(String) && x.match?(BASE64) },
+ boolean: ->(x) { BOOLEAN_VALUES.include?(x) },
+ datetime: ->(x) { x.is_a?(DateTime) },
+ decimal: ->(x) { x.is_a?(Float) },
+ integer: lambda { |x|
+ begin
+ x&.integer?
+ rescue StandardError
+ false
+ end
+ },
+ reference: ->(x) { x =~ /\A#{URI.regexp(%w[http https])}\z/ },
+ string: ->(x) { x.is_a?(String) }
+ }.freeze
+
class << self
def configuration
@configuration ||= ::Scim::Kit::V2::Configuration.new
lib/scim/kit/version.rb
@@ -2,6 +2,6 @@
module Scim
module Kit
- VERSION = '0.2.6'
+ VERSION = '0.2.7'
end
end
lib/scim/kit.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
+require 'active_model'
+require 'active_support/core_ext/hash/indifferent_access'
require 'tilt'
require 'tilt/jbuilder'
-require 'active_support/core_ext/hash/indifferent_access'
require 'scim/kit/dynamic_attributes'
require 'scim/kit/templatable'
spec/scim/kit/v2/attribute_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe Scim::Kit::V2::Attribute do
specify { expect(subject._value).to eql(user_name) }
specify { expect(subject.as_json[:userName]).to eql(user_name) }
+ specify { expect(subject).to be_valid }
end
context 'when multiple values are allowed' do
@@ -24,6 +25,28 @@ RSpec.describe Scim::Kit::V2::Attribute do
specify { expect(subject._value).to match_array(%w[superman batman]) }
end
+ context 'when a single value is provided' do
+ before do
+ type.multi_valued = true
+ subject._value = 'batman'
+ subject.valid?
+ end
+
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:user_name]).to be_present }
+ end
+
+ context 'when the wrong type is used' do
+ before do
+ type.multi_valued = true
+ subject._assign([1.0, 2.0], coerce: false)
+ subject.valid?
+ end
+
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:user_name]).to be_present }
+ end
+
context 'when integer' do
let(:number) { rand(100) }
@@ -41,9 +64,14 @@ RSpec.describe Scim::Kit::V2::Attribute do
end
context 'when not matching a canonical value' do
- before { type.canonical_values = %w[batman robin] }
+ before do
+ type.canonical_values = %w[batman robin]
+ subject._value = 'spider man'
+ subject.valid?
+ end
- specify { expect { subject._value = 'spider man' }.to raise_error(ArgumentError) }
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:user_name]).to be_present }
end
context 'when canonical value is given' do
@@ -73,8 +101,11 @@ RSpec.describe Scim::Kit::V2::Attribute do
specify { expect(subject.as_json[:hungry]).to be(false) }
end
- context 'when string' do
- specify { expect { subject._value = 'hello' }.to raise_error(ArgumentError) }
+ context 'when invalid string' do
+ before { subject._value = 'hello' }
+
+ specify { expect(subject._value).to eql('hello') }
+ specify { expect(subject).not_to be_valid }
end
end
@@ -172,6 +203,25 @@ RSpec.describe Scim::Kit::V2::Attribute do
specify { expect(subject.as_json[:name][:givenName]).to eql('Tsuyoshi') }
end
+ context 'with single valued complex type' do
+ let(:type) do
+ x = Scim::Kit::V2::AttributeType.new(name: :person, type: :complex)
+ x.add_attribute(name: :name)
+ x.add_attribute(name: :age, type: :integer)
+ x
+ end
+
+ before { subject._value = { name: 'mo', age: 34 } }
+
+ specify { expect(subject).to be_valid }
+
+ context 'when invalid sub attribute' do
+ before { subject._value = { name: 34, age: 'wrong' } }
+
+ specify { expect(subject).not_to be_valid }
+ end
+ end
+
context 'with multi valued complex type' do
let(:type) do
x = Scim::Kit::V2::AttributeType.new(name: 'emails', type: :complex)
@@ -194,7 +244,13 @@ RSpec.describe Scim::Kit::V2::Attribute do
specify { expect(subject.as_json[:emails]).to match_array([{ value: email, primary: true }, { value: other_email, primary: false }]) }
context 'when the hash is invalid' do
- xspecify { expect { subject._value = [{ blah: 'blah' }] }.to raise_error(ArgumentError) }
+ before do
+ subject._value = [{ blah: 'blah' }]
+ subject.valid?
+ end
+
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:emails]).to be_present }
end
end
end
spec/scim/kit/v2/attribute_type_spec.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
RSpec.describe Scim::Kit::V2::AttributeType do
+ let(:image) { Base64.strict_encode64(raw_image) }
+ let(:raw_image) { IO.read('./spec/fixtures/avatar.png', mode: 'rb') }
+
specify { expect { described_class.new(name: 'displayName', type: :string) }.not_to raise_error }
specify { expect { described_class.new(name: 'primary', type: :boolean) }.not_to raise_error }
specify { expect { described_class.new(name: 'salary', type: :decimal) }.not_to raise_error }
@@ -63,4 +66,162 @@ RSpec.describe Scim::Kit::V2::AttributeType do
specify { expect(build(canonical_values: %w[User Group]).to_h[:canonicalValues]).to match_array(%w[User Group]) }
end
end
+
+ describe '#valid?' do
+ specify { expect(described_class.new(name: :x, type: :binary)).to be_valid(image) }
+ specify { expect(described_class.new(name: :x, type: :binary)).not_to be_valid('hello') }
+ specify { expect(described_class.new(name: :x, type: :binary)).not_to be_valid(1) }
+ specify { expect(described_class.new(name: :x, type: :binary)).not_to be_valid([1]) }
+
+ specify { expect(described_class.new(name: :x, type: :boolean)).to be_valid(true) }
+ specify { expect(described_class.new(name: :x, type: :boolean)).to be_valid(false) }
+ specify { expect(described_class.new(name: :x, type: :boolean)).not_to be_valid('false') }
+ specify { expect(described_class.new(name: :x, type: :boolean)).not_to be_valid(1) }
+
+ specify { expect(described_class.new(name: :x, type: :datetime)).to be_valid(DateTime.now) }
+ specify { expect(described_class.new(name: :x, type: :datetime)).not_to be_valid(DateTime.now.iso8601) }
+ specify { expect(described_class.new(name: :x, type: :datetime)).not_to be_valid(Time.now.to_i) }
+ specify { expect(described_class.new(name: :x, type: :datetime)).not_to be_valid(Time.now) }
+
+ specify { expect(described_class.new(name: :x, type: :decimal)).to be_valid(1.0) }
+ specify { expect(described_class.new(name: :x, type: :decimal)).not_to be_valid(1) }
+ specify { expect(described_class.new(name: :x, type: :decimal)).not_to be_valid('1.0') }
+
+ specify { expect(described_class.new(name: :x, type: :integer)).to be_valid(1) }
+ specify { expect(described_class.new(name: :x, type: :integer)).to be_valid(1_000) }
+ specify { expect(described_class.new(name: :x, type: :integer)).not_to be_valid(10.0) }
+ specify { expect(described_class.new(name: :x, type: :integer)).not_to be_valid('10') }
+ specify { expect(described_class.new(name: :x, type: :integer)).not_to be_valid([]) }
+
+ specify { expect(described_class.new(name: :x, type: :reference)).to be_valid(FFaker::Internet.uri('https')) }
+ specify { expect(described_class.new(name: :x, type: :reference)).not_to be_valid('hello') }
+ specify { expect(described_class.new(name: :x, type: :reference)).not_to be_valid(1) }
+ specify { expect(described_class.new(name: :x, type: :reference)).not_to be_valid(['hello']) }
+
+ specify { expect(described_class.new(name: :x, type: :string)).to be_valid('name') }
+ specify { expect(described_class.new(name: :x, type: :string)).not_to be_valid(1) }
+ specify { expect(described_class.new(name: :x, type: :string)).not_to be_valid(['string']) }
+
+ context 'when multi valued string type' do
+ subject { described_class.new(name: :emails, type: :string) }
+
+ let(:email) { FFaker::Internet.email }
+
+ before do
+ subject.multi_valued = true
+ end
+
+ specify { expect(subject).to be_valid([email]) }
+ specify { expect(subject).not_to be_valid([1]) }
+ specify { expect(subject).not_to be_valid(email) }
+ end
+
+ context 'when single valued complex type' do
+ subject { described_class.new(name: :location, type: :complex) }
+
+ before do
+ subject.multi_valued = false
+ subject.add_attribute(name: :name, type: :string)
+ subject.add_attribute(name: :latitude, type: :integer)
+ subject.add_attribute(name: :longitude, type: :integer)
+ end
+
+ specify { expect(subject).to be_valid(name: 'work', latitude: 100, longitude: 100) }
+ specify { expect(subject).not_to be_valid([name: 'work', latitude: 100, longitude: 100]) }
+ specify { expect(subject).not_to be_valid(name: 'work', latitude: 'wrong', longitude: 100) }
+ end
+
+ context 'when multi valued complex type' do
+ subject { described_class.new(name: :emails, type: :complex) }
+
+ let(:email) { FFaker::Internet.email }
+ let(:other_email) { FFaker::Internet.email }
+
+ before do
+ subject.multi_valued = true
+ subject.add_attribute(name: 'value', type: :string)
+ subject.add_attribute(name: 'primary', type: :boolean)
+ end
+
+ specify { expect(subject).to be_valid([value: email, primary: true]) }
+ specify { expect(subject).to be_valid([{ value: email, primary: true }, { value: other_email, primary: false }]) }
+ specify { expect(subject).not_to be_valid(email) }
+ specify { expect(subject).not_to be_valid([email]) }
+ specify { expect(subject).not_to be_valid([value: 1, primary: true]) }
+ specify { expect(subject).not_to be_valid([value: email, primary: 'true']) }
+ end
+ end
+
+ describe '#coerce' do
+ let(:now) { DateTime.now }
+ let(:uri) { FFaker::Internet.uri('https') }
+
+ specify { expect(described_class.new(name: :x, type: :binary).coerce(raw_image)).to eql(image) }
+ specify { expect(described_class.new(name: :x, type: :binary).coerce(image)).to eql(image) }
+
+ specify { expect(described_class.new(name: :x, type: :boolean).coerce(true)).to be(true) }
+ specify { expect(described_class.new(name: :x, type: :boolean).coerce('true')).to be(true) }
+ specify { expect(described_class.new(name: :x, type: :boolean).coerce(false)).to be(false) }
+ specify { expect(described_class.new(name: :x, type: :boolean).coerce('false')).to be(false) }
+ specify { expect(described_class.new(name: :x, type: :boolean).coerce('invalid')).to eql('invalid') }
+
+ specify { expect(described_class.new(name: :x, type: :datetime).coerce(now)).to eql(now) }
+ specify { expect(described_class.new(name: :x, type: :datetime).coerce(now.iso8601)).to be_within(1).of(now) }
+
+ specify { expect(described_class.new(name: :x, type: :decimal).coerce(1.0)).to be(1.0) }
+ specify { expect(described_class.new(name: :x, type: :decimal).coerce(1)).to be(1.0) }
+ specify { expect(described_class.new(name: :x, type: :decimal).coerce('1.0')).to be(1.0) }
+ specify { expect(described_class.new(name: :x, type: :decimal).coerce('1')).to be(1.0) }
+
+ specify { expect(described_class.new(name: :x, type: :integer).coerce(1)).to be(1) }
+ specify { expect(described_class.new(name: :x, type: :integer).coerce(1.0)).to be(1) }
+ specify { expect(described_class.new(name: :x, type: :integer).coerce('1.0')).to be(1) }
+ specify { expect(described_class.new(name: :x, type: :integer).coerce('1')).to be(1) }
+
+ specify { expect(described_class.new(name: :x, type: :reference).coerce(uri)).to eql(uri) }
+ specify { expect(described_class.new(name: :x, type: :reference).coerce('hello')).to eql('hello') }
+
+ specify { expect(described_class.new(name: :x, type: :string).coerce('name')).to eql('name') }
+ specify { expect(described_class.new(name: :x, type: :string).coerce(1)).to eql('1') }
+
+ context 'when multi valued string type' do
+ subject do
+ x = described_class.new(name: :x, type: :string)
+ x.multi_valued = true
+ x
+ end
+
+ specify { expect(subject.coerce(['1'])).to match_array(['1']) }
+ specify { expect(subject.coerce([1])).to match_array(['1']) }
+ end
+
+ context 'when single valued complex type' do
+ subject { described_class.new(name: :location, type: :complex) }
+
+ before do
+ subject.multi_valued = false
+ subject.add_attribute(name: :name, type: :string)
+ subject.add_attribute(name: :latitude, type: :integer)
+ subject.add_attribute(name: :longitude, type: :integer)
+ end
+
+ specify { expect(subject.coerce(name: 'work', latitude: 100, longitude: 100)).to eql(name: 'work', latitude: 100, longitude: 100) }
+ end
+
+ context 'when multi valued complex type' do
+ subject { described_class.new(name: :emails, type: :complex) }
+
+ let(:email) { FFaker::Internet.email }
+ let(:other_email) { FFaker::Internet.email }
+
+ before do
+ subject.multi_valued = true
+ subject.add_attribute(name: 'value', type: :string)
+ subject.add_attribute(name: 'primary', type: :boolean)
+ end
+
+ specify { expect(subject.coerce([value: email, primary: true])).to match_array([value: email, primary: true]) }
+ specify { expect(subject.coerce([{ value: email, primary: true }, { value: other_email, primary: false }])).to match_array([{ value: email, primary: true }, { value: other_email, primary: false }]) }
+ end
+ end
end
spec/scim/kit/v2/resource_spec.rb
@@ -116,4 +116,46 @@ RSpec.describe Scim::Kit::V2::Resource do
specify { expect(subject.department).to eql('voltron') }
specify { expect(subject.as_json[extension_id][:department]).to eql('voltron') }
end
+
+ describe '#valid?' do
+ context 'when invalid' do
+ before { subject.valid? }
+
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:id]).to be_present }
+ end
+
+ context 'when valid' do
+ before { subject.id = SecureRandom.uuid }
+
+ specify { expect(subject).to be_valid }
+ end
+
+ context 'when a required simple attribute is blank' do
+ before do
+ schema.add_attribute(name: 'userName') do |x|
+ x.required = true
+ end
+ subject.id = SecureRandom.uuid
+ subject.valid?
+ end
+
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:user_name]).to be_present }
+ end
+
+ context 'when not matching a canonical value' do
+ before do
+ schema.add_attribute(name: 'hero') do |x|
+ x.canonical_values = %w[batman robin]
+ end
+ subject.id = SecureRandom.uuid
+ subject.hero = 'spiderman'
+ subject.valid?
+ end
+
+ specify { expect(subject).not_to be_valid }
+ specify { expect(subject.errors[:hero]).to be_present }
+ end
+ end
end
spec/scim/kit/v2/schema_spec.rb
@@ -9,16 +9,20 @@ RSpec.describe Scim::Kit::V2::Schema do
let(:description) { FFaker::Name.name }
let(:result) { JSON.parse(subject.to_json, symbolize_names: true) }
- before do
- subject.description = description
- end
-
specify { expect(result[:id]).to eql(id) }
specify { expect(result[:name]).to eql(name) }
- specify { expect(result[:description]).to eql(description) }
+ specify { expect(result[:description]).to eql('') }
specify { expect(result[:meta][:resourceType]).to eql('Schema') }
specify { expect(result[:meta][:location]).to eql(location) }
+ context 'with a description' do
+ before do
+ subject.description = description
+ end
+
+ specify { expect(result[:description]).to eql(description) }
+ end
+
context 'with a single simple attribute' do
before do
subject.add_attribute(name: 'displayName')
scim-kit.gemspec
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.summary = 'A SCIM library.'
spec.description = 'A SCIM library.'
- spec.homepage = 'https://www.mokhan.ca/'
+ spec.homepage = 'https://www.github.com/mokhan/scim-kit'
spec.license = 'MIT'
# Specify which files should be added to the gem when it is released.
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
end
spec.require_paths = ['lib']
+ spec.add_dependency 'activemodel', '>= 5.2.0'
spec.add_dependency 'tilt', '~> 2.0'
spec.add_dependency 'tilt-jbuilder', '~> 0.7'
spec.add_development_dependency 'bundler', '~> 1.17'