Skip to content

Commit

Permalink
Refactor internal schema representation
Browse files Browse the repository at this point in the history
  • Loading branch information
bhelx committed Sep 20, 2019
1 parent 430b761 commit 93b4160
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 134 deletions.
16 changes: 16 additions & 0 deletions benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
require "rubygems"
require "bundler/setup"
require "recurly"
require "benchmark"

N = 1_000

account_body = "{\"id\":\"ljvmmbjchtgs\",\"object\":\"account\",\"code\":\"[email protected]\",\"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
2 changes: 1 addition & 1 deletion lib/recurly/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 84 additions & 48 deletions lib/recurly/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@ module Recurly
# This is used for requests and resources.
class Schema
# The attributes in the schema
# @return [Array<Attribute>]
# @return [Hash<String,Attribute>]
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

Expand All @@ -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.
Expand All @@ -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"
Expand Down
18 changes: 13 additions & 5 deletions lib/recurly/schema/json_parser.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -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
Expand Down
26 changes: 10 additions & 16 deletions lib/recurly/schema/request_caster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Loading

0 comments on commit 93b4160

Please sign in to comment.