From 029d816ba07bcf99328e34791e5a9baba5a531d4 Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Mon, 25 Aug 2014 11:20:45 -0700 Subject: [PATCH 1/9] failing test describing the schema interface --- lib/json_api_client/helpers.rb | 1 + lib/json_api_client/helpers/schemable.rb | 27 +++++++++ lib/json_api_client/resource.rb | 1 + test/unit/schemable_test.rb | 73 ++++++++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 lib/json_api_client/helpers/schemable.rb create mode 100644 test/unit/schemable_test.rb diff --git a/lib/json_api_client/helpers.rb b/lib/json_api_client/helpers.rb index a6f31c5d..ef7be641 100644 --- a/lib/json_api_client/helpers.rb +++ b/lib/json_api_client/helpers.rb @@ -7,6 +7,7 @@ module Helpers autoload :Linkable, 'json_api_client/helpers/linkable' autoload :Parsable, 'json_api_client/helpers/parsable' autoload :Queryable, 'json_api_client/helpers/queryable' + autoload :Schemable, 'json_api_client/helpers/schemable' autoload :Serializable, 'json_api_client/helpers/serializable' end end \ No newline at end of file diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb new file mode 100644 index 00000000..97a19bf9 --- /dev/null +++ b/lib/json_api_client/helpers/schemable.rb @@ -0,0 +1,27 @@ +module JsonApiClient + module Helpers + module Schemable + extend ActiveSupport::Concern + + Field = Struct.new(:name, :type, :default) + + included do + class_attribute :schema + self.schema = [] + end + + module ClassMethods + def property(name, options = {}) + + end + + def properties(*names) + options = names.last.is_a?(Hash) ? names.pop : {} + names.each do |name| + property name, options + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/json_api_client/resource.rb b/lib/json_api_client/resource.rb index 01eec798..a11a0cee 100644 --- a/lib/json_api_client/resource.rb +++ b/lib/json_api_client/resource.rb @@ -54,6 +54,7 @@ def run_request(query) include Helpers::Serializable include Helpers::Linkable include Helpers::CustomEndpoints + include Helpers::Schemable def save query = persisted? ? diff --git a/test/unit/schemable_test.rb b/test/unit/schemable_test.rb new file mode 100644 index 00000000..cb713b80 --- /dev/null +++ b/test/unit/schemable_test.rb @@ -0,0 +1,73 @@ +require 'test_helper' + +class SchemaResource < TestResource + property :a, type: :string, default: 'foo' + property :b, type: :boolean, default: false + property :c + property :d, type: :integer +end + +class SchemaResource2 < TestResource + property :a, type: :float +end + +class MultipleSchema < TestResource + properties :name, :short_name, :long_name, type: :string +end + +class SchemableTest < MiniTest::Unit::TestCase + + def test_defines_fields + resource = SchemaResource.new + + %w(a b c d).each do |method_name| + assert resource.respond_to?(method_name) + assert resource.respond_to?("#{method_name}=") + end + + assert_equal 4, SchemaResource.schema.size + end + + def test_defines_defaults + resource = SchemaResource.new + + assert_equal 'foo', resource.a + assert_equal 'foo', resource['a'] + assert_equal false, resource.b + assert_equal false, resource['b'] + assert_equal nil, resource.c + assert_equal nil, resource.d + end + + def test_find_property_definition + property = SchemaResource.schema[:a] + assert property + + assert_equal :a, property.name + assert_equal :string, property.type + assert_equal 'foo', property.default + end + + def test_casts_data + resource = SchemaResource.new + + resource.d = "1" + assert_equal 1, resource.d + end + + # sanity to make sure we're not doing anything crazy with inheritance + def test_schemas_do_not_collide + assert_equal 4, SchemaResource.schema.size + assert_equal 1, SchemaResource2.schema.size + end + + def test_can_define_multiple_properties + assert_equal 3, MultipleSchema.schema.size + + MultipleSchema.schema.each_property do |property| + assert_equal :string, property.type + assert_equal nil, property.default + end + end + +end \ No newline at end of file From e51e204f3d8eca871a3d353299a3be704e6da1a1 Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Mon, 25 Aug 2014 13:10:27 -0700 Subject: [PATCH 2/9] implement schemable --- lib/json_api_client/helpers/schemable.rb | 73 ++++++++++++++++++++++-- test/unit/schemable_test.rb | 4 +- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index 97a19bf9..14a468b9 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -3,16 +3,58 @@ module Helpers module Schemable extend ActiveSupport::Concern - Field = Struct.new(:name, :type, :default) + Field = Struct.new(:name, :type, :default) do + def cast(value) + return nil if value.nil? + + case type + when :integer + value.to_i + when :string + value.to_s + when :float + value.to_f + when :boolean + !!value + end + end + end included do - class_attribute :schema - self.schema = [] + initializer do |obj, params| + obj.send(:set_default_values) + end + end + + class Schema + def initialize + @fields = {} + end + + def add(field) + @fields[field.name.to_sym] = field + end + + def size + @fields.size + end + + def each_property(&block) + @fields.values.each(&block) + end + + def [](property_name) + @fields[property_name.to_sym] + end end module ClassMethods - def property(name, options = {}) + def schema + @schema ||= Schema.new + end + def property(name, options = {}) + schema.add(Field.new(name, options[:type], options[:default])) end def properties(*names) @@ -22,6 +64,29 @@ def properties(*names) end end end + + protected + + def set_attribute(name, value) + property = property_for(name) + value = property.cast(value) if property + super(name, value) + end + + def has_attribute?(attr_name) + !!self.class.schema[attr_name] || super + end + + def set_default_values + self.class.schema.each_property do |property| + attributes[property.name] = property.default unless attributes.has_key?(property.name) + end + end + + def property_for(name) + self.class.schema[name] + end + end end end \ No newline at end of file diff --git a/test/unit/schemable_test.rb b/test/unit/schemable_test.rb index cb713b80..58aa6704 100644 --- a/test/unit/schemable_test.rb +++ b/test/unit/schemable_test.rb @@ -21,8 +21,8 @@ def test_defines_fields resource = SchemaResource.new %w(a b c d).each do |method_name| - assert resource.respond_to?(method_name) - assert resource.respond_to?("#{method_name}=") + assert resource.respond_to?(method_name), "should respond_to?(:#{method_name})" + assert resource.respond_to?("#{method_name}="), "should respond_to?(:#{method_name}=)" end assert_equal 4, SchemaResource.schema.size From d3e783c9b3a035d8820c7308f05681bb8f0b0299 Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Mon, 25 Aug 2014 14:57:59 -0700 Subject: [PATCH 3/9] handle typing of the type field for a field --- lib/json_api_client/helpers/schemable.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index 14a468b9..c0999c59 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -6,8 +6,9 @@ module Schemable Field = Struct.new(:name, :type, :default) do def cast(value) return nil if value.nil? + return value if type.nil? - case type + case type.to_sym when :integer value.to_i when :string @@ -16,6 +17,8 @@ def cast(value) value.to_f when :boolean !!value + else + value end end end From 37c20daf2e5c4c5df8392d416f70d14c32b8cb50 Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Tue, 26 Aug 2014 09:21:54 -0700 Subject: [PATCH 4/9] change :integer type to :int --- lib/json_api_client/helpers/schemable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index c0999c59..c394de52 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -9,7 +9,7 @@ def cast(value) return value if type.nil? case type.to_sym - when :integer + when :int value.to_i when :string value.to_s From a6e54ffcb138a87193ca89f645b3fae32a89305b Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Tue, 26 Aug 2014 09:25:35 -0700 Subject: [PATCH 5/9] accept both :int and :integer as type --- lib/json_api_client/helpers/schemable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index c394de52..32c53691 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -9,7 +9,7 @@ def cast(value) return value if type.nil? case type.to_sym - when :int + when :int, :integer value.to_i when :string value.to_s From 97d2507d76136a319ae16cb7c46112754d93891c Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Tue, 26 Aug 2014 09:45:21 -0700 Subject: [PATCH 6/9] add documentation for schema stuff [skip ci] --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b9ec9fe4..32b92255 100644 --- a/README.md +++ b/README.md @@ -159,4 +159,47 @@ You can also call the instance method `verify` on a `MyApi::User` instance. We also respect the [links specification](http://jsonapi.org/format/#document-structure-resource-relationships). The client can fetch linked resources based on the defined endpoint from the link specification as well as load data from any `linked` data provided in the response. Additionally, it will still fetch missing data if not all linked resources are provided in the `linked` data response. -See the [tests](https://github.com/chingor13/json_api_client/blob/master/test/unit/links_test.rb). \ No newline at end of file +See the [tests](https://github.com/chingor13/json_api_client/blob/master/test/unit/links_test.rb). + +## Schema + +You can define schema within your client model. You can define basic types and set default values if you wish. If you declare a basic type, we will try to cast any input to be that type. + +The added benefit of declaring your schema is that you can access fields before data is set (otherwise, you'll get a `NoMethodError`). + +### Example + +``` +class User < JsonApiClient::Resource + property :name, type: :string + property :is_admin, type: :boolean, default: false + property :points_accrued, type: :int, default: 0 + property :averge_points_per_day, type: :float +end + +# default values +u = User.new +u.name +=> nil +u.is_admin +=> false +u.points_accrued +=> 0 + +# casting +u.average_points_per_day = "0.3" +u.average_points_per_day +=> 0.3 + +``` + +### Types + +The basic types that we allow are: + +* `:int` or `:integer` +* `:float` +* `:string` +* `:boolean` - *Note: we will cast the string version of "true" and "false" to their respective values* + +Also, we consider `nil` to be an acceptable value and will not cast the value. \ No newline at end of file From 9445f96d51512659109b07f8560c514937e80d0c Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Tue, 26 Aug 2014 12:02:22 -0700 Subject: [PATCH 7/9] bulk attributes should be casted to the right types. strings 'false' and 'true' should be cast to false and true --- lib/json_api_client/helpers/attributable.rb | 4 ++- lib/json_api_client/helpers/schemable.rb | 6 ++++- test/unit/schemable_test.rb | 30 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/json_api_client/helpers/attributable.rb b/lib/json_api_client/helpers/attributable.rb index b8b3c69c..2488beec 100644 --- a/lib/json_api_client/helpers/attributable.rb +++ b/lib/json_api_client/helpers/attributable.rb @@ -15,7 +15,9 @@ def attributes=(attrs = {}) @attributes ||= {}.with_indifferent_access return @attributes unless attrs.present? - @attributes.merge!(attrs) + attrs.each do |key, value| + set_attribute(key, value) + end end def update_attributes(attrs = {}) diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index 32c53691..9ffed747 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -16,7 +16,11 @@ def cast(value) when :float value.to_f when :boolean - !!value + if value.is_a?(String) + value == "false" ? false : true + else + !!value + end else value end diff --git a/test/unit/schemable_test.rb b/test/unit/schemable_test.rb index 58aa6704..5a61a917 100644 --- a/test/unit/schemable_test.rb +++ b/test/unit/schemable_test.rb @@ -51,6 +51,9 @@ def test_find_property_definition def test_casts_data resource = SchemaResource.new + resource.b = "false" + assert_equal false, resource.b, "should cast boolean strings" + resource.d = "1" assert_equal 1, resource.d end @@ -70,4 +73,31 @@ def test_can_define_multiple_properties end end + def test_casts_values_when_instantiating + resource = SchemaResource.new({ + a: 123, + b: 'false', + c: :blah, + d: "12345" + }) + assert_equal "123", resource.a + assert_equal false, resource.b + assert_equal :blah, resource.c + assert_equal 12345, resource.d + end + + def test_casts_values_when_bulk_assigning_attributes + resource = SchemaResource.new + resource.attributes = { + a: 123, + b: 'false', + c: :blah, + d: "12345" + } + assert_equal "123", resource.a + assert_equal false, resource.b + assert_equal :blah, resource.c + assert_equal 12345, resource.d + end + end \ No newline at end of file From 27046c8fe3de1764c2b40f293f78e8f4799702bb Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Tue, 26 Aug 2014 13:22:36 -0700 Subject: [PATCH 8/9] split Schema class into its own file --- lib/json_api_client.rb | 1 + lib/json_api_client/helpers/schemable.rb | 62 +++++------------------- lib/json_api_client/schema.rb | 50 +++++++++++++++++++ 3 files changed, 64 insertions(+), 49 deletions(-) create mode 100644 lib/json_api_client/schema.rb diff --git a/lib/json_api_client.rb b/lib/json_api_client.rb index ea421c78..99f3e7a4 100644 --- a/lib/json_api_client.rb +++ b/lib/json_api_client.rb @@ -14,6 +14,7 @@ module JsonApiClient autoload :Query, 'json_api_client/query' autoload :Resource, 'json_api_client/resource' autoload :ResultSet, 'json_api_client/result_set' + autoload :Schema, 'json_api_client/schema' autoload :Scope, 'json_api_client/scope' autoload :Utils, 'json_api_client/utils' end \ No newline at end of file diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index 9ffed747..39ce1cc3 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -3,67 +3,31 @@ module Helpers module Schemable extend ActiveSupport::Concern - Field = Struct.new(:name, :type, :default) do - def cast(value) - return nil if value.nil? - return value if type.nil? - - case type.to_sym - when :int, :integer - value.to_i - when :string - value.to_s - when :float - value.to_f - when :boolean - if value.is_a?(String) - value == "false" ? false : true - else - !!value - end - else - value - end - end - end - included do initializer do |obj, params| obj.send(:set_default_values) end end - class Schema - def initialize - @fields = {} - end - - def add(field) - @fields[field.name.to_sym] = field - end - - def size - @fields.size - end - - def each_property(&block) - @fields.values.each(&block) - end - - def [](property_name) - @fields[property_name.to_sym] - end - end - module ClassMethods + # Returns the schema for this resource class + # + # @return [Schema] the schema for this resource class def schema @schema ||= Schema.new end + # Declares a new property field by name + # + # @param name [Symbol] the name of the field + # @param options [Hash] field options + # @option options [Symbol] :type The field type + # @option options [Symbol] :default The default value for the field def property(name, options = {}) - schema.add(Field.new(name, options[:type], options[:default])) + schema.add(name, options) end + # Declare multiple properties with the same optional options def properties(*names) options = names.last.is_a?(Hash) ? names.pop : {} names.each do |name| @@ -81,7 +45,7 @@ def set_attribute(name, value) end def has_attribute?(attr_name) - !!self.class.schema[attr_name] || super + !!property_for(attr_name) || super end def set_default_values @@ -91,7 +55,7 @@ def set_default_values end def property_for(name) - self.class.schema[name] + self.class.schema.find(name) end end diff --git a/lib/json_api_client/schema.rb b/lib/json_api_client/schema.rb new file mode 100644 index 00000000..51bf4632 --- /dev/null +++ b/lib/json_api_client/schema.rb @@ -0,0 +1,50 @@ +module JsonApiClient + class Schema + Property = Struct.new(:name, :type, :default) do + def cast(value) + return nil if value.nil? + return value if type.nil? + + case type.to_sym + when :int, :integer + value.to_i + when :string + value.to_s + when :float + value.to_f + when :boolean + if value.is_a?(String) + value == "false" ? false : true + else + !!value + end + else + value + end + end + end + + def initialize + @properties = {} + end + + def add(name, options) + @properties[name.to_sym] = Property.new(name.to_sym, options[:type], options[:default]) + end + + def size + @properties.size + end + alias_method :length, :size + + def each_property(&block) + @properties.values.each(&block) + end + alias_method :each, :each_property + + def [](property_name) + @properties[property_name.to_sym] + end + alias_method :find, :[] + end +end \ No newline at end of file From aea0329cbbbf1b6acf1dd889d07817b782d21a5b Mon Sep 17 00:00:00 2001 From: Jeff Ching Date: Tue, 26 Aug 2014 13:28:20 -0700 Subject: [PATCH 9/9] add documentation [skip ci] --- lib/json_api_client/helpers/schemable.rb | 15 ++++++++++----- lib/json_api_client/schema.rb | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/json_api_client/helpers/schemable.rb b/lib/json_api_client/helpers/schemable.rb index 39ce1cc3..6ff5462e 100644 --- a/lib/json_api_client/helpers/schemable.rb +++ b/lib/json_api_client/helpers/schemable.rb @@ -17,17 +17,22 @@ def schema @schema ||= Schema.new end - # Declares a new property field by name + # Declares a new property by name # - # @param name [Symbol] the name of the field - # @param options [Hash] field options - # @option options [Symbol] :type The field type - # @option options [Symbol] :default The default value for the field + # @param name [Symbol] the name of the property + # @param options [Hash] property options + # @option options [Symbol] :type The property type + # @option options [Symbol] :default The default value for the property def property(name, options = {}) schema.add(name, options) end # Declare multiple properties with the same optional options + # + # @param [Array] names + # @param options [Hash] property options + # @option options [Symbol] :type The property type + # @option options [Symbol] :default The default value for the property def properties(*names) options = names.last.is_a?(Hash) ? names.pop : {} names.each do |name| diff --git a/lib/json_api_client/schema.rb b/lib/json_api_client/schema.rb index 51bf4632..9d023c40 100644 --- a/lib/json_api_client/schema.rb +++ b/lib/json_api_client/schema.rb @@ -28,10 +28,20 @@ def initialize @properties = {} end + # Add a property to the schema + # + # @param name [Symbol] the name of the property + # @param options [Hash] property options + # @option options [Symbol] :type The property type + # @option options [Symbol] :default The default value for the property + # @return [void] def add(name, options) @properties[name.to_sym] = Property.new(name.to_sym, options[:type], options[:default]) end + # How many properties are defined + # + # @return [Fixnum] the number of defined properties def size @properties.size end @@ -42,9 +52,13 @@ def each_property(&block) end alias_method :each, :each_property - def [](property_name) + # Look up a property by name + # + # @param property_name [String] the name of the property + # @return [Property, nil] the property definition for property_name or nil + def find(property_name) @properties[property_name.to_sym] end - alias_method :find, :[] + alias_method :[], :find end end \ No newline at end of file