Comparing changes

v0.2.6 v0.2.7
21 commits 12 files changed

Commits

ebc523f bump version mokha 2019-01-11 20:03:15
0fb71c8 move constants to shrink class size mokha 2019-01-11 19:10:05
0bc6f6b fix some linter errors mokha 2019-01-11 19:04:57
089d1bb add a blank description mokha 2019-01-11 18:15:18
eed78fa validate all types. mokha 2019-01-11 16:27:14
c25adfb convert pending to running spec mokha 2019-01-11 01:56:24
16c82a4 add complex type validation mokha 2019-01-11 01:53:43
7f3919c validate single valued complex type mokha 2019-01-11 01:49:53
ef3a662 add specs for type validations mokha 2019-01-11 01:20:18
1970146 update homepage mokha 2019-01-11 00:59:07
9ef75fe fix some linter errors mo 2019-01-10 05:00:45
79620de start to add array validations mo 2019-01-10 04:40:52
2bcb193 use i18n for error messages mo 2019-01-10 04:08:18
b28cad4 fix linter errors mo 2019-01-10 04:05:07
acd8364 move validations to attribute class mokha 2019-01-09 18:56:31
bbcc51b add some required field validations mokha 2019-01-09 00:33:32
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'