diff --git a/benchmark.rb b/benchmark.rb new file mode 100644 index 000000000..7f9f65ba4 --- /dev/null +++ b/benchmark.rb @@ -0,0 +1,16 @@ +require "rubygems" +require "bundler/setup" +require "recurly" +require "benchmark" + +N = 1_000 + +account_body = "{\"id\":\"ljvmmbjchtgs\",\"object\":\"account\",\"code\":\"9ebd49f7288@example.com\",\"parent_account_id\":null,\"bill_to\":\"self\",\"state\":\"active\",\"username\":null,\"email\":null,\"cc_emails\":null,\"preferred_locale\":null,\"first_name\":\"Benjamin\",\"last_name\":\"Du Monde\",\"company\":null,\"vat_number\":null,\"tax_exempt\":false,\"exemption_certificate\":null,\"address\":null,\"billing_info\":null,\"shipping_addresses\":[{\"object\":\"shipping_address\",\"first_name\":\"Benjamin\",\"last_name\":\"Du Monde\",\"company\":null,\"phone\":null,\"street1\":\"1 Tchoupitoulas St\",\"street2\":null,\"city\":\"New Orleans\",\"region\":\"LA\",\"postal_code\":\"70115\",\"country\":\"US\",\"nickname\":\"Home\",\"email\":null,\"vat_number\":null,\"id\":\"ljvmmbk9e1as\",\"account_id\":\"ljvmmbjchtgs\",\"created_at\":\"2019-09-19T22:45:59Z\",\"updated_at\":\"2019-09-19T22:45:59Z\"}],\"custom_fields\":[],\"hosted_login_token\":\"PSvcHow5H4HGEGKTfHXadLNoDcRaDVMK\",\"created_at\":\"2019-09-19T22:45:59Z\",\"updated_at\":\"2019-09-19T22:45:59Z\",\"deleted_at\":null}" + +Benchmark.bm do |benchmark| + benchmark.report("JSON parsing and casting\n") do + N.times do + _account = Recurly::JSONParser.parse(nil, account_body) + end + end +end diff --git a/lib/recurly/request.rb b/lib/recurly/request.rb index d54b7248c..09384b233 100644 --- a/lib/recurly/request.rb +++ b/lib/recurly/request.rb @@ -17,7 +17,7 @@ def ==(other_resource) protected def initialize(attributes = {}) - @attributes = self.class.cast(attributes.clone) + @attributes = self.class.cast_request(attributes) end def to_s diff --git a/lib/recurly/schema.rb b/lib/recurly/schema.rb index c05a5a199..dd34e7df4 100644 --- a/lib/recurly/schema.rb +++ b/lib/recurly/schema.rb @@ -3,21 +3,21 @@ module Recurly # This is used for requests and resources. class Schema # The attributes in the schema - # @return [Array] + # @return [Hash] attr_reader :attributes def initialize - @attributes = [] + @attributes = {} end # Adds an attribute to the schema definition # # @param name [Symbol] The name of the attribute # @param type [Class,Symbol] The type of the attribute. Use capitalized symbol for Recurly class. Example: :Account. - # @param options [Hash] The attribute options. See {Attribute#options} + # @param options [Schema::Attribute] The created and registered attribute object. def add_attribute(name, type, options) - attribute = Attribute.new(name, type, options) - @attributes.push(attribute) + attribute = Attribute.build(type, options) + @attributes[name.to_s] = attribute attribute end @@ -26,8 +26,7 @@ def add_attribute(name, type, options) # @param name [String,Symbol] The name/key of the attribute # @return [Attribute,nil] The found Attribute. nil if not found. def get_attribute(name) - name = name.to_s - @attributes.find { |a| a.name.to_s == name } + @attributes[name.to_s] end # Gets a recurly class given a symbol name. @@ -46,71 +45,108 @@ def self.get_recurly_class(type) Resources::Address elsif Requests.const_defined?(type) Requests.const_get(type) - elsif Resources.const_defined?(type) + elsif Recurly::Resources.const_defined?(type) Resources.const_get(type) else raise ArgumentError, "Recurly type '#{type}' is unknown" end end - # Describes a list attribute type - class List - # The type of the elements of the list - # @return [Symbol] - attr_accessor :item_type + class Attribute + # The type of the attribute. Might be a class like `DateTime` + # or could be a Recurly object. In this case a symbol should be used. + # Example: :Account. To get the Recurly type use #recurly_class + # @return [Class,Symbol] + attr_reader :type + + PRIMITIVE_TYPES = [ + String, + Integer, + Float, + Hash, + ].freeze + + def self.build(type, options = {}) + if PRIMITIVE_TYPES.include? type + PrimitiveAttribute.new(type) + elsif type == :Boolean + BooleanAttribute.new + elsif type == DateTime + DateTimeAttribute.new + elsif type.is_a? Symbol + ResourceAttribute.new(type) + elsif type == Array + item_attr = build(options[:item_type]) + ArrayAttribute.new(item_attr) + else + throw ArgumentError + end + end - def initialize(item_type) - @item_type = item_type + def initialize(type = nil) + @type = type end - def to_s - "List<#{item_type}>" + def cast(value) + value end - end - # Describes and attribute for a schema. - class Attribute - # The name of the attribute. - # @return [Symbol] - attr_accessor :name + def recurly_class + @recurly_class ||= Schema.get_recurly_class(type) + end + end - # The type of the attribute. Might be a class like `DateTime` - # or could be a Recurly object. In this case a symbol should be used. - # Example: :Account - # @return [Class,Symbol] - attr_accessor :type + class PrimitiveAttribute < Attribute + def is_valid?(value) + value.is_a? self.type + end + end - # Options for the attribute. - # @return [Hash] - attr_accessor :options + class BooleanAttribute < Attribute + def is_valid?(value) + [true, false].include? value + end + end - # The description of the attribute for documentation. - # @return [String] - attr_accessor :description + class DateTimeAttribute < Attribute + def is_valid?(value) + value.is_a?(String) || value.is_a?(DateTime) + end - def initialize(name, type, options = {}, description = nil) - @name = name - @type = type - @options = options - @description = description + def cast(value) + if value.is_a?(DateTime) + value + else + DateTime.parse(value) + end end - def read_only? - @options.fetch(:read_only, false) + def type + DateTime end + end - def recurly_class - Schema.get_recurly_class(type == Array ? options[:item_type] : type) + class ResourceAttribute < Attribute + def is_valid?(value) + value.is_a? Hash end - def is_primitive? - t = type == Array ? options[:item_type] : type - t.is_a?(Class) || t == :Boolean + def cast(value) + self.recurly_class.cast(value) end end - private_constant :List - private_constant :Attribute + class ArrayAttribute < Attribute + def is_valid?(value) + value.is_a? Array + end + + def cast(value) + value.map do |v| + self.type.cast(v) + end + end + end end require_relative "./schema/schema_factory" diff --git a/lib/recurly/schema/json_parser.rb b/lib/recurly/schema/json_parser.rb index 970ff2d1b..94c17dfcf 100644 --- a/lib/recurly/schema/json_parser.rb +++ b/lib/recurly/schema/json_parser.rb @@ -1,8 +1,9 @@ require "json" module Recurly - # This is a wrapper class to help parse responses into Recurly objects. + # This is a wrapper class to help parse http response into Recurly objects. class JSONParser + # Parses the json body into a recurly object. # # @param client [Client] The Recurly client which made the request. @@ -17,6 +18,11 @@ def self.parse(client, body) # Converts the parsed JSON into a Recurly object. # + # *TODO*: Instead of inferring this type from the `object` + # attribute. We should instead "register" the response type + # in the client/operations code. The `get`, `post`, etc methods + # could explicitly state their response types. + # # @param data [Hash] The parsed JSON data # @return [Error,Resource] def self.from_json(data) @@ -33,11 +39,10 @@ def self.from_json(data) data = data["error"] if klazz == Resources::Error - klazz.from_json(data) + klazz.cast(data) end # Returns the Recurly ruby class responsible for the Recurly json key. - # TODO figure out how we should handle nil types # # @example # JSONParser.recurly_class('list') @@ -61,8 +66,11 @@ def self.recurly_class(type) if klazz.ancestors.include?(Resource) klazz else - # TODO might want to throw an error? - nil + if Recurly::STRICT_MODE + raise ArgumentError, "Could not find Recurly Resource responsible for key #{type}" + else + nil + end end end end diff --git a/lib/recurly/schema/request_caster.rb b/lib/recurly/schema/request_caster.rb index 73bd46801..4b7347208 100644 --- a/lib/recurly/schema/request_caster.rb +++ b/lib/recurly/schema/request_caster.rb @@ -2,19 +2,20 @@ module Recurly class Schema - # *Note*: This class is for internal use. + # *Note*: This module is for internal use. # The RequestCaster turns mixed data into a pure Hash # so it can be serialized into JSON and used as the body of a request. # This module is to be extended by the Request class. module RequestCaster + # This method casts the data object (of mixed types) into a Hash ready for JSON # serialization. The *schema* will default to the self's schema. # You should pass in the schema where possible. This is because objects are serialized # differently depending on the context in which they are being written. # # @example - # Recurly::Requests::AccountUpdatable.cast(account_code: 'benjamin') - # #=> {:account_code=>"benjamin"} + # Recurly::Requests::AccountUpdatable.cast(code: 'benjamin') + # #=> {:code=>"benjamin"} # @example # # If you have some mixed data, like passing in an Address, it should cast that # # address into a Hash based on the Schema defined in AccountUpdatable @@ -25,26 +26,26 @@ module RequestCaster # @param data [Hash,Resource,Request] The data to transform into a JSON Hash. # @param schema [Schema] The schema to use to transform the data into a JSON Hash. # @return [Hash] The pure Hash ready to be serialized into JSON. - def cast(data, schema = self.schema) + def cast_request(data, schema = self.schema) casted = {} if data.is_a?(Resource) || data.is_a?(Request) - data = as_json(data, schema) + data = data.attributes.reject { |_k, v| v.nil? } end data.each do |k, v| schema_attr = schema.get_attribute(k) norm_val = if v.respond_to?(:attributes) - cast(v, schema_attr.recurly_class.schema) + cast_request(v, v.class.schema) elsif v.is_a?(Array) v.map do |elem| if elem.respond_to?(:attributes) - cast(elem, schema_attr.recurly_class.schema) + cast_request(elem, elem.class.schema) else elem end end - elsif v.is_a?(Hash) && schema_attr && schema_attr.type.is_a?(Symbol) - cast(v, schema_attr.recurly_class.schema) + elsif v.is_a?(Hash) && schema_attr && schema_attr.is_a?(Schema::ResourceAttribute) + cast_request(v, schema_attr.recurly_class.schema) else v end @@ -54,13 +55,6 @@ def cast(data, schema = self.schema) casted end - - private - - def as_json(resource, schema) - writeable_attributes = schema.attributes.reject(&:read_only?).map(&:name) - resource.attributes.select { |k, _| writeable_attributes.include?(k) } - end end end end diff --git a/lib/recurly/schema/resource_caster.rb b/lib/recurly/schema/resource_caster.rb index 17db3e2cc..45a299b09 100644 --- a/lib/recurly/schema/resource_caster.rb +++ b/lib/recurly/schema/resource_caster.rb @@ -2,20 +2,21 @@ module Recurly class Schema - # The purpose of this class is to turn Recurly defined - # JSON data into Recurly ruby objects. It's to be used + # The purpose of this class is to turn JSON parsed Hashes + # defined into Recurly ruby objects. It's to be used # by the Resource as an extension. module ResourceCaster + # Gives the class the ability to initialize itself # given some json data. # # @example - # Recurly::Resources::Account.from_json({"code" => "mycode"}) + # Recurly::Resources::Account.cast({"code" => "mycode"}) # #=> #"mycode"}> # - # @param attributes [Hash] A primitive Hash from JSON.parse of Recurly result. + # @param attributes [Hash] A primitive Hash from JSON.parse of Recurly response. # @return [Resource] the {Resource} (ruby object) representing the passed in JSON data. - def from_json(attributes = {}) + def cast(attributes = {}) resource = new() attributes.each do |attr_name, val| next if attr_name == "object" @@ -23,30 +24,21 @@ def from_json(attributes = {}) schema_attr = self.schema.get_attribute(attr_name) if schema_attr - # if the Hash val is a recurly type, parse it into a Resource - val = if val.is_a?(Hash) && !schema_attr.is_primitive? - schema_attr.recurly_class.from_json(val) - elsif val.is_a?(Array) - val.map do |e| - if e.is_a?(Hash) && !schema_attr.is_primitive? - schema_attr.recurly_class.from_json(e) - else - e - end - end - elsif val && schema_attr.type == DateTime && val.is_a?(String) - DateTime.parse(val) - else + val = if val.nil? val + elsif schema_attr.is_valid?(val) + schema_attr.cast(val) + else + if Recurly::STRICT_MODE + msg = "#{self.class}##{attr_name} does not have the right type. Value: #{val.inspect} was expected to be a #{schema_attr}" + raise ArgumentError, msg + end end writer = "#{attr_name}=" - resource.send(writer, val) - else - if Recurly::STRICT_MODE - raise ArgumentError, "#{resource.class.name} encountered json attribute #{attr_name.inspect}: #{val.inspect} but it's unknown to it's schema" - end + elsif Recurly::STRICT_MODE + raise ArgumentError, "#{resource.class.name} encountered json attribute #{attr_name.inspect}: #{val.inspect} but it's unknown to it's schema" end end resource diff --git a/lib/recurly/schema/schema_factory.rb b/lib/recurly/schema/schema_factory.rb index 0a9705243..b53608cb8 100644 --- a/lib/recurly/schema/schema_factory.rb +++ b/lib/recurly/schema/schema_factory.rb @@ -41,8 +41,6 @@ def define_attribute(name, type, options = {}) self.attributes[name] = val end - protected "#{name}=" if attribute.read_only? - self end end diff --git a/lib/recurly/schema/schema_validator.rb b/lib/recurly/schema/schema_validator.rb index a2938fa3b..5ae8cb05a 100644 --- a/lib/recurly/schema/schema_validator.rb +++ b/lib/recurly/schema/schema_validator.rb @@ -24,45 +24,42 @@ def validate! err_msg = "Attribute '#{attr_name}' does not exist on request #{self.class.name}." if did_you_mean = get_did_you_mean(schema, attr_name) err_msg << " Did you mean '#{did_you_mean}'?" - raise ArgumentError, err_msg end - elsif schema_attr.read_only? - raise ArgumentError, "Attribute '#{attr_name}' on resource #{self.class.name} is not writeable" + raise ArgumentError, err_msg else - validate_attribute!(schema_attr, val) + validate_attribute!(attr_name, schema_attr, val) end end end # Validates an individual attribute - def validate_attribute!(schema_attr, val) - unless schema_attr.type.is_a?(Symbol) || val.is_a?(schema_attr.type) + def validate_attribute!(name, schema_attr, val) + unless schema_attr.is_valid?(val) # If it's safely castable, the json deserializer or server # will take care of it for us unless safely_castable?(val.class, schema_attr.type) - expected = case schema_attr.type - when Array - "Array of #{schema_attr.type.item_type}s" + expected = case schema_attr + when Schema::ArrayAttribute + "Array of #{schema_attr.type}s" else schema_attr.type end - raise ArgumentError, "Attribute '#{schema_attr.name}' on the resource #{self.class.name} is type #{val.class} but should be a #{expected}" + raise ArgumentError, "Attribute '#{name}' on the resource #{self.class.name} is type #{val.class} but should be a #{expected}" end end # This is the convention for a recurly object - if schema_attr.type.is_a?(Symbol) && val.is_a?(Hash) - klazz = Schema.get_recurly_class(schema_attr.type) + if schema_attr.is_a?(Schema::ResourceAttribute) && val.is_a?(Hash) # Using send because the initializer may be private - instance = klazz.send(:new, val) + instance = schema_attr.recurly_class.send(:new, val) instance.validate! end end # Gets the closest term to the misspelled attribute def get_did_you_mean(schema, misspelled_attr) - closest = schema.attributes.map(&:name).sort_by do |v| + closest = schema.attributes.keys.sort_by do |v| levenshtein_distance(v, misspelled_attr) end.first diff --git a/spec/recurly/client_spec.rb b/spec/recurly/client_spec.rb index af53d0ddf..259b89cb5 100644 --- a/spec/recurly/client_spec.rb +++ b/spec/recurly/client_spec.rb @@ -94,7 +94,7 @@ end it "should return a the created account for create_account" do - body = { account_code: "benjamin-du-monde" } + body = { code: "benjamin-du-monde" } req = Recurly::HTTP::Request.new(:post, "/accounts", JSON.dump(body)) expect(client).to receive(:run_request).with(req, any_args).and_return(response) account = subject.create_account(body: body) diff --git a/spec/recurly/request_spec.rb b/spec/recurly/request_spec.rb index eb4a07eac..7ea67072e 100644 --- a/spec/recurly/request_spec.rb +++ b/spec/recurly/request_spec.rb @@ -136,7 +136,7 @@ describe "#cast" do context "with primitive Hash type" do it "should not transform the same Hash" do - casted = Recurly::Requests::MyRequest.cast(hash_data) + casted = Recurly::Requests::MyRequest.cast_request(hash_data) expect(casted).to eql(hash_data) end end @@ -144,7 +144,7 @@ it "should not cast the Resource to a Hash" do hash_data[:a_sub_request] = Recurly::Requests::MySubRequest.new(a_string: "a_string") hash_data[:a_sub_request_array] = [Recurly::Requests::MySubRequest.new(a_string: "a_string")] - casted = Recurly::Requests::MyRequest.cast(hash_data) + casted = Recurly::Requests::MyRequest.cast_request(hash_data) expect(casted).to_not eql(hash_data) expect(casted[:a_sub_request]).to eql(a_string: "a_string") expect(casted[:a_sub_request_array]).to eql([a_string: "a_string"]) diff --git a/spec/recurly/resource_spec.rb b/spec/recurly/resource_spec.rb index c061b4e05..3b33c17af 100644 --- a/spec/recurly/resource_spec.rb +++ b/spec/recurly/resource_spec.rb @@ -17,7 +17,7 @@ } end - subject! { Recurly::Resources::MyResource.from_json(json_data) } + subject! { Recurly::Resources::MyResource.cast(json_data) } describe "#attributes" do let(:attributes) { subject.attributes } @@ -25,22 +25,6 @@ it "returns a Hash" do attributes.is_a? Hash end - - # TODO expecting to turn hash elements into keys - # TODO expecting use overridden == for resources - # it "returns the expected Hash" do - # expect(attributes).to eq({ - # a_string: "A String", - # a_hash: {a: 1, b: 2}, - # an_integer: 42, - # a_float: 4.2, - # a_boolean: false, - # a_datetime: DateTime.new(2020, 1, 1), - # a_string_array: %w(I am a string array), - # a_sub_resource: Recurly::Resources::MySubResource.from_json({ "a_string" => "SubResource String" }), - # a_sub_resource_array: [Recurly::Resources::MySubResource.from_json({"a_string" => "SubResource String"})] - # }) - # end end describe "attributes methods" do @@ -49,7 +33,7 @@ end # TODO expecting to turn hash elements into keys # it "should respond to a_hash" do - # expect(subject.send(:a_hash)).to eq({a: 1, b: 2}) + # expect(subject.send(:a_hash)).to eq({ a: 1, b: 2 }) # end it "should respond to an_integer" do expect(subject.send(:an_integer)).to eq(42) @@ -67,10 +51,10 @@ expect(subject.send(:a_string_array)).to eq(%w(I am a string array)) end it "should respond to a_sub_resource" do - expect(subject.send(:a_sub_resource)).to eq(Recurly::Resources::MySubResource.from_json({ "a_string" => "SubResource String" })) + expect(subject.send(:a_sub_resource)).to eq(Recurly::Resources::MySubResource.cast({ "a_string" => "SubResource String" })) end it "should respond to a_sub_resource_array" do - expect(subject.send(:a_sub_resource_array)).to eq([Recurly::Resources::MySubResource.from_json({ "a_string" => "SubResource String" })]) + expect(subject.send(:a_sub_resource_array)).to eq([Recurly::Resources::MySubResource.cast({ "a_string" => "SubResource String" })]) end end end @@ -94,7 +78,7 @@ end it "should be buildable from json data" do - expect(res_class.from_json({})).to be_instance_of(res_class) + expect(res_class.cast({})).to be_instance_of(res_class) end end end