require 'date' require 'erb' module Recurly # The base class for all Recurly resources (e.g. {Account}, {Subscription}, # {Transaction}). # # Resources behave much like # {ActiveModel}[http://rubydoc.info/gems/activemodel] classes, especially # like {ActiveRecord}[http://rubydoc.info/gems/activerecord]. # # == Life Cycle # # To take you through the typical life cycle of a resource, we'll use # {Recurly::Account} as an example. # # === Creating a Record # # You can instantiate a record before attempting to save it. # # account = Recurly::Account.new :first_name => 'Walter' # # Once instantiated, you can assign and reassign any attribute. # # account.first_name = 'Walt' # account.last_name = 'White' # # When you're ready to save, do so. # # account.save # => false # # If save returns +false+, validation likely failed. You can check the record # for errors. # # account.errors # => {"account_code"=>["can't be blank"]} # # Once the errors are fixed, you can try again. # # account.account_code = 'heisenberg' # account.save # => true # # The object will be updated with any information provided by the server # (including any UUIDs set). # # account.created_at # => 2011-04-30 07:13:35 -0700 # # You can also create accounts in one fell swoop. # # Recurly::Account.create( # :first_name => 'Jesse' # :last_name => 'Pinkman' # :account_code => 'capn_cook' # ) # # => # # # You can use alternative "bang" methods for exception control. If the record # fails to save, a Recurly::Resource::Invalid exception will be raised. # # begin # account = Recurly::Account.new :first_name => 'Junior' # account.save! # rescue Recurly::Resource::Invalid # p account.errors # end # # You can access the invalid record from the exception itself (if, for # example, you use the create! method). # # begin # Recurly::Account.create! :first_name => 'Skylar', :last_name => 'White' # rescue Recurly::Resource::Invalid => e # p e.record.errors # end # # === Fetching a Record # # Records are fetched by their unique identifiers. # # account = Recurly::Account.find 'better_call_saul' # # => # # # If the record doesn't exist, a Recurly::Resource::NotFound exception will # be raised. # # === Updating a Record # # Once fetched, a record can be updated with a hash of attributes. # # account.update_attributes :first_name => 'Saul', :last_name => 'Goodman' # # => true # # (A bang method, update_attributes!, will raise Recurly::Resource::Invalid.) # # You can also update a record by setting attributes and calling save. # # account.last_name = 'McGill' # account.save # Alternatively, call save! # # === Deleting a Record # # To delete (deactivate, close, etc.) a fetched record, merely call destroy # on it. # # account.destroy # => true # # === Fetching a List of Records # # If you want to iterate over a list of accounts, you can use a Pager. # # pager = Account.paginate :per_page => 50 # # If you want to iterate over _every_ record, a convenience method will # automatically paginate: # # Account.find_each { |account| p account } class Resource require 'recurly/resource/errors' require 'recurly/resource/pager' require 'recurly/resource/association' # Raised when a record cannot be found. # # @example # begin # Recurly::Account.find 'tortuga' # rescue Recurly::Resource::NotFound => e # e.message # => "Can't find Account with account_code = tortuga" # end class NotFound < API::NotFound def initialize(message) set_message message end end # Raised when a record is invalid. # # @example # begin # Recurly::Account.create! :first_name => "Flynn" # rescue Recurly::Resource::Invalid => e # e.record.errors # => errors: {"account_code"=>["can't be blank"]}> # end class Invalid < Error # @return [Resource, nil] The invalid record. attr_reader :record def initialize(message) if message.is_a? Resource @record = message set_message(record_to_message) else set_message(message) end end private def record_to_message @record.errors.map do |k, v| message = v.join(', ') k == 'base' ? message : "#{k} #{message}" end.join('; ') end end class << self # @return [String] The demodulized name of the resource class. # @example # Recurly::Account.name # => "Account" def resource_name Helper.demodulize name end # @return [String] The underscored, pluralized name of the resource # class. # @example # Recurly::Account.collection_name # => "accounts" def collection_name Helper.pluralize Helper.underscore(resource_name) end alias collection_path collection_name # @return [String] The underscored name of the resource class. # @example # Recurly::Account.member_name # => "account" def member_name Helper.underscore resource_name end # @return [String] The relative path to a resource's identifier from the # API's base URI. # @param uuid [String, nil] # @example # Recurly::Account.member_path "code" # => "accounts/code" # Recurly::Account.member_path nil # => "accounts" def member_path(uuid) uuid = ERB::Util.url_encode(uuid) if uuid [collection_path, uuid].compact.join '/' end # @return [String] The root key for this resource's xml document def xml_root_key self.member_name end # @return [Array] Per attribute, defines readers, writers, boolean and # change-tracking methods. # @param attribute_names [Array] An array of attribute names. # @example # class Account < Resource # define_attribute_methods [:name] # end # # a = Account.new # a.name? # => false # a.name # => nil # a.name = "Stephen" # a.name? # => true # a.name # => "Stephen" # a.name_changed? # => true # a.name_was # => nil # a.name_change # => [nil, "Stephen"] def define_attribute_methods(attribute_names) @attribute_names = attribute_names.map! { |m| m.to_s }.sort!.freeze remove_const :AttributeMethods if constants.include? :AttributeMethods include const_set :AttributeMethods, Module.new { attribute_names.each do |name| define_method(name) { self[name] } # Get. define_method("#{name}=") { |value| self[name] = value } # Set. define_method("#{name}?") { !!self[name] } # Present. define_method("#{name}_change") { changes[name] } # Dirt... define_method("#{name}_changed?") { changed_attributes.key? name } define_method("#{name}_was") { changed_attributes[name] } define_method("#{name}_previously_changed?") { previous_changes.key? name } define_method("#{name}_previously_was") { previous_changes[name].first if previous_changes.key? name } end } end # @return [Array, nil] The list of attribute names defined for the # resource class. attr_reader :attribute_names # @return [Pager] A pager with an iterable collection of records # @param options [Hash] A hash of pagination options # @option options [Integer] :per_page The number of records returned per # page # @option options [DateTime, Time, Integer] :cursor A timestamp that the # pager will skim back to and return records created before it # @option options [String] :etag When set, will raise # {Recurly::API::NotModified} if the pager's loaded page content has # not changed # @example Fetch 50 records and iterate over them # Recurly::Account.paginate(:per_page => 50).each { |a| p a } # @example Fetch records before January 1, 2011 # Recurly::Account.paginate(:cursor => Time.new(2011, 1, 1)) def paginate(options = {}) Pager.new self, options end alias scoped paginate alias where paginate def all(options = {}) paginate(options).to_a end # @return [Hash] Defined scopes per resource. def scopes @scopes ||= Recurly::Helper.hash_with_indifferent_read_access end # @return [Module] Module of scopes methods. def scopes_helper @scopes_helper ||= Module.new.tap { |helper| extend helper } end # Defines a new resource scope. # # @return [Proc] # @param [Symbol] name the scope name # @param [Hash] params the scope params def scope(name, params = {}) scopes[name = name.to_s] = params scopes_helper.send(:define_method, name) { paginate scopes[name] } end # Iterates through every record by automatically paging. # # @option options [Hash] Optional hash to pass to Pager#paginate # # @return [nil] # @param [Integer] per_page The number of records returned per request. # @yield [record] # @see Pager#paginate # @example # Recurly::Account.find_each { |a| p a } # @example With sorting and filter # opts = { # begin_time: DateTime.new(2016,1,1), # sort: :updated_at # } # Recurly::Account.find_each(opts) do |a| # puts a.inspect # end def find_each(options = {}, &block) paginate(options).find_each(&block) end # @return [Integer] The total record count of the resource in question. # @see Pager#count # @example # Recurly::Account.count # => 42 def count paginate.count end # @api internal # @return [Resource, nil] def first paginate(:per_page => 1).first end # @return [Resource] A record matching the designated unique identifier. # @param [String] uuid The unique identifier of the resource to be # retrieved. # @param [Hash] options A hash of options. # @option options [String] :etag When set, will raise {API::NotModified} # if the record content has not changed. # @raise [Error] If the resource has no identifier (and thus cannot be # retrieved). # @raise [NotFound] If no resource can be found for the supplied # identifier (or the supplied identifier is +nil+). # @raise [API::NotModified] If the :etag option is set and # matches the server's. # @example # Recurly::Account.find "heisenberg" # # => # # Use the following identifiers for these types of objects: # for accounts use account_code # for plans use plan_code # for invoices use invoice_number # for subscriptions use uuid # for transactions use uuid def find(uuid, options = {}) if uuid.nil? || uuid.to_s.empty? raise NotFound, "can't find a record with nil identifier" end begin from_response API.get(member_path(uuid), {}, options) rescue API::NotFound => e raise NotFound, e.description end end # Instantiates and attempts to save a record. # # @return [Resource] The record. # @raise [Transaction::Error] A monetary transaction failed. # @see create! def create(attributes = {}) new(attributes) { |record| record.save } end # Instantiates and attempts to save a record. # # @return [Resource] The saved record. # @raise [Invalid] The record is invalid. # @raise [Transaction::Error] A monetary transaction failed. # @see create def create!(attributes = {}) new(attributes) { |record| record.save! } end # Instantiates a record from an HTTP response, setting the record's # response attribute in the process. # # @return [Resource] # @param response [Net::HTTPResponse] def from_response(response) content_type = response['Content-Type'] case content_type when %r{application/pdf} response.body when %r{application/xml} record = from_xml response.body record.instance_eval { @etag, @response = response['ETag'], response } record else raise Recurly::Error, "Content-Type \"#{content_type}\" is not accepted" end end # Instantiates a record from an XML blob: either a String or XML element. # # Assuming the record is from an API response, the record is flagged as # persisted. # # @return [Resource] # @param xml [String, REXML::Element, Nokogiri::XML::Node] # @see from_response def from_xml(xml) xml = XML.new xml record = new xml.root.attributes.each do |name, value| record.instance_variable_set "@#{name}", value.to_s end xml.each_element do |el| # skip this element if it's an xml comment next if defined?(Nokogiri::XML::Node::TEXT_NODE) && el.is_a?(Nokogiri::XML::Comment) if el.name == 'a' record.links[el.attribute('name').value] = { :method => el.attribute('method').to_s, :href => el.attribute('href').value } next end # Nokogiri on Jruby-1.7.19 likes to throw NullPointer exceptions # if you try to run certian operations like el.attribute(''). Since # we dont care about text nodes, let's just skip them next if defined?(Nokogiri::XML::Node::TEXT_NODE) && el.node_type == Nokogiri::XML::Node::TEXT_NODE if association = find_association(el.name) class_name = association_class_name(association, el.name) resource_class = Recurly.const_get(class_name) is_many = association.relation == :has_many # Is this a link, or is it embedded data? if el.children.empty? && href = el.attribute('href') if is_many record[el.name] = Pager.new( resource_class, :uri => href.value, :parent => record ) else record.links[el.name] = { :resource_class => resource_class, :method => :get, :href => href.value } end else if is_many resources = el.elements.map { |e| resource_class.from_xml(e) } record[el.name] = resources else record[el.name] = resource_class.from_xml(el) end end else # TODO name tax_type conflicts with the TaxType # class so if we get to this point was can assume # it's the string. Will need to refactor this if el.name == 'tax_type' record[el.name] = el.text else val = XML.cast(el) # TODO we have to clear changed attributes after # parsing here or else it always serializes. Need # a better way of handling changed attributes if el.name == 'address' && val.kind_of?(Hash) address = Address.new(val) address.instance_variable_set(:@changed_attributes, {}) record[el.name] = address else record[el.name] = val end end end end record.persist! if record.respond_to? :persist! record end # @return [Array] A list of associations for the current class. def associations @associations ||= [] end # @return [Array] A list of associated resource classes with # the relation [:has_many, :has_one, :belongs_to] for the current class. def associations_for_relation(relation) associations.select{ |a| a.relation == relation }.map(&:resource_class) end def association_class_name(association, el_name) return association.class_name if association.class_name Helper.classify(el_name) end # @return [Association, nil] Find association for the current class # with resource class name. def find_association(resource_class) associations.find{ |a| a.resource_class.to_s == resource_class.to_s } end def associations_helper @associations_helper ||= Module.new.tap { |helper| include helper } end # Establishes a has_many association. # # @return [Proc, nil] # @param collection_name [Symbol] Association name. # @param options [Hash] A hash of association options. # @option options [true, false] :readonly Define a setter when false, defaults to true # [String] :class_name Actual associated resource class name # if not same as collection_name. def has_many(collection_name, options = {}) associations << Association.new(:has_many, collection_name.to_s, options) associations_helper.module_eval do define_method collection_name do if self[collection_name] self[collection_name] else attributes[collection_name.to_s] = [] end end if options.key?(:readonly) && options[:readonly] == false define_method "#{collection_name}=" do |collection| self[collection_name] = collection end end end end # Establishes a has_one association. # # @return [Proc, nil] # @param member_name [Symbol] Association name. # @param options [Hash] A hash of association options. # @option options [true, false] :readonly Don't define a setter. # [String] :class_name Actual associated resource class name # if not same as member_name. def has_one(member_name, options = {}) associations << Association.new(:has_one, member_name.to_s, options) associations_helper.module_eval do define_method(member_name) { self[member_name] } if options.key?(:readonly) && options[:readonly] == false associated = Recurly.const_get Helper.classify(member_name), false define_method "#{member_name}=" do |member| associated_uri = "#{path}/#{member_name}" self[member_name] = case member when Hash associated.send :new, member.merge(:uri => associated_uri) when associated member.uri = associated_uri and member else raise ArgumentError, "expected #{associated}" end end define_method "build_#{member_name}" do |*args| attributes = args.shift || {} self[member_name] = associated.send( :new, attributes.merge(:uri => "#{path}/#{associated.member_name}") ).tap { |child| child.attributes[self.class.member_name] = self } end define_method "create_#{member_name}" do |*args| send("build_#{member_name}", *args).tap { |child| child.save } end end end end # Establishes a belongs_to association. # # @return [Proc] # @param parent_name [Symbol] Association name. # @param options [Hash] A hash of association options. # @option options [true, false] :readonly Don't define a setter. # [String] :class_name Actual associated resource class name # if not same as parent_name. def belongs_to(parent_name, options = {}) associations << Association.new(:belongs_to, parent_name.to_s, options) associations_helper.module_eval do define_method(parent_name) { self[parent_name] } if options.key?(:readonly) && options[:readonly] == false define_method "#{parent_name}=" do |parent| self[parent_name] = parent end end end end # @return [:has_many, :has_one, :belongs_to, nil] An association type. def reflect_on_association(name) a = find_association(name) a.relation if a end def embedded!(root_index = false) protected :initialize private_class_method(*%w(create create!)) unless root_index private_class_method(*%w(all find_each first paginate scoped where)) end end def find_resource_class(name) resource_name = Helper.classify(name) if Recurly.const_defined?(resource_name, false) && Recurly.const_get(resource_name, false).instance_of?(Class) Recurly.const_get(resource_name, false) end end end # @return [Hash] The raw hash of record attributes. attr_reader :attributes # @return [Net::HTTPResponse, nil] The most recent response object for the # record (updated during {#save} and {#destroy}). attr_reader :response # @return [String, nil] An ETag for the current record. attr_reader :etag # @return [String, nil] A writer to override the URI the record saves to. attr_writer :uri # @return [Resource] A new resource instance. # @param attributes [Hash] A hash of attributes. def initialize(attributes = {}) if instance_of? Resource raise Error, "#{self.class} is an abstract class and cannot be instantiated" end @attributes, @new_record, @destroyed, @uri, @href = {}, true, false self.attributes = attributes yield self if block_given? end # @return [self] Reloads the record from the server. def reload(response = nil) if response return if response.body.to_s.length.zero? fresh = self.class.from_response response else options = {:etag => (etag unless changed?)} fresh = if @href self.class.from_response API.get(@href, {}, options) else self.class.find(to_param, options) end end fresh and copy_from fresh persist! true self rescue API::NotModified self end # @return [Hash] Hash of changed attributes. # @see #changes def changed_attributes @changed_attributes ||= {} end # @return [Array] A list of changed attribute keys. def changed changed_attributes.keys end # Do any attributes have unsaved changes? # @return [true, false] def changed? !changed_attributes.empty? end # @return [Hash] Map of changed attributes to original value and new value. def changes changed_attributes.inject({}) { |changes, (key, original_value)| changes[key] = [original_value, self[key]] and changes } end # @return [Hash] Previously-changed attributes. # @see #changes def previous_changes @previous_changes ||= {} end # Is the record new (i.e., not saved on Recurly's servers)? # # @return [true, false] # @see #persisted? # @see #destroyed? def new_record? @new_record end # Has the record been destroyed? (Set +true+ after a successful destroy.) # @return [true, false] # @see #new_record? # @see #persisted? def destroyed? @destroyed end # Has the record persisted (i.e., saved on Recurly's servers)? # # @return [true, false] # @see #new_record? # @see #destroyed? def persisted? !(new_record? || destroyed?) end # The value of a specified attribute, lazily fetching any defined # association. # # @param key [Symbol, String] The name of the attribute to be fetched. # @example # account.read_attribute :first_name # => "Ted" # account[:last_name] # => "Beneke" # @see #write_attribute def read_attribute(key) key = key.to_s if attributes.key? key value = attributes[key] elsif links.key?(key) && self.class.reflect_on_association(key) value = attributes[key] = follow_link key end value end alias [] read_attribute # Sets the value of a specified attribute. # # @param key [Symbol, String] The name of the attribute to be set. # @param value [Object] The value the attribute will be set to. # @example # account.write_attribute :first_name, 'Gus' # account[:company_name] = 'Los Pollos Hermanos' # @see #read_attribute def write_attribute(key, value) if changed_attributes.key?(key = key.to_s) changed_attributes.delete key if changed_attributes[key] == value elsif self[key] != value changed_attributes[key] = self[key] end association = self.class.find_association(key) if association value = fetch_associated(key, value) # FIXME: More explicit; less magic. elsif value && key.end_with?('_in_cents') && !respond_to?(:currency) value = Money.new(value, self, key) unless value.is_a?(Money) end attributes[key] = value end alias []= write_attribute # Apply a given hash of attributes to a record. # # @return [Hash] # @param attributes [Hash] A hash of attributes. def attributes=(attributes = {}) attributes.each_pair { |k, v| respond_to?(name = "#{k}=") and send(name, v) or self[k] = v } end def as_json(options = nil) attributes.reject { |k, v| v.is_a?(Recurly::Resource::Pager) } end # @return [Hash] The raw hash of record href links. def links @links ||= {} end # Whether a record has a link with the given name. # # @param key [Symbol, String] The name of the link to check for. # @example # account.link? :billing_info # => true def link?(key) links.key?(key.to_s) end # Fetch the value of a link by following the associated href. # # @param key [Symbol, String] The name of the link to be followed. # @param options [Hash] A hash of API options. # @example # account.read_link :billing_info # => def follow_link(key, options = {}) if link = links[key = key.to_s] response = API.send link[:method], link[:href], options[:body], options if resource_class = link[:resource_class] response = resource_class.from_response response response.attributes[self.class.member_name] = self end response end rescue Recurly::API::NotFound raise unless resource_class end # Serializes the record to XML. # # @return [String] An XML string. # @param options [Hash] A hash of XML options. # @example # Recurly::Account.new(:account_code => 'code').to_xml # # => "code" def to_xml(options = {}) builder = options[:builder] || XML.new("<#{self.class.xml_root_key}/>") xml_keys.each { |key| value = respond_to?(key) ? send(key) : self[key] node = builder.add_element key # Duck-typing here is problematic because of ActiveSupport's #to_xml. case value when Resource value.to_xml options.merge(:builder => node) when Array, Subscription::AddOns value.each do |e| if e.is_a? Recurly::Resource # create a node to hold this resource e_node = node.add_element Helper.singularize(key) # serialize the resource into this node e.to_xml(options.merge(builder: e_node)) else # it's just a primitive value node.add_element(Helper.singularize(key), e) end end when Hash, Recurly::Money value.each_pair { |k, v| node.add_element k.to_s, v } else node.text = value end } builder.to_s end # Attempts to save the record, returning the success of the request. # # @return [true, false] # @raise [Transaction::Error] A monetary transaction failed. # @example # account = Recurly::Account.new # account.save # => false # account.account_code = 'account_code' # account.save # => true # @see #save! def save if new_record? || changed? clear_errors @response = API.send( persisted? ? :put : :post, path, to_xml ) reload response persist! true end true rescue API::UnprocessableEntity => e apply_errors e Transaction::Error.validate! e, (self if is_a?(Transaction)) false end # Attempts to save the record, returning +true+ if the record was saved and # raising {Invalid} otherwise. # # @return [true] # @raise [Invalid] The record was invalid. # @raise [Transaction::Error] A monetary transaction failed. # @example # account = Recurly::Account.new # account.save! # raises Recurly::Resource::Invalid # account.account_code = 'account_code' # account.save! # => true # @see #save def save! save || raise(Invalid.new(self)) end # @return [true, false, nil] The validity of the record: +true+ if the # record was successfully saved (or persisted and unchanged), +false+ if # the record was not successfully saved, or +nil+ for a record with an # unknown state (i.e. (i.e. new records that haven't been saved and # persisted records with changed attributes). # @example # account = Recurly::Account.new # account.valid? # => nil # account.save # => false # account.valid? # => false # account.account_code = 'account_code' # account.save # => true # account.valid? # => true def valid? return true if persisted? && !changed? errors_empty = errors.values.flatten.empty? return if errors_empty && changed? errors_empty end # Update a record with a given hash of attributes. # # @return [true, false] The success of the update. # @param attributes [Hash] A hash of attributes. # @raise [Transaction::Error] A monetary transaction failed. # @example # account = Account.find 'junior' # account.update_attributes :account_code => 'flynn' # => true # @see #update_attributes! def update_attributes(attributes = {}) self.attributes = attributes and save end # Update a record with a given hash of attributes. # # @return [true] The update was successful. # @param attributes [Hash] A hash of attributes. # @raise [Invalid] The record was invalid. # @raise [Transaction::Error] A monetary transaction failed. # @example # account = Account.find 'gale_boetticher' # account.update_attributes! :account_code => nil # Raises an exception. # @see #update_attributes def update_attributes!(attributes = {}) self.attributes = attributes and save! end # @return [Hash] A hash with indifferent read access containing any # validation errors where the key is the attribute name and the value is # an array of error messages. # @example # account.errors # => {"account_code"=>["can't be blank"]} # account.errors[:account_code] # => ["can't be blank"] def errors @errors ||= Errors.new { |h, k| h[k] = [] } end # Marks a record as persisted, i.e. not a new or deleted record, resetting # any tracked attribute changes in the process. (This is an internal method # and should probably not be called unless you know what you're doing.) # # @api internal # @return [true] def persist!(saved = false) @new_record, @uri = false if changed? @previous_changes = changes if saved changed_attributes.clear end true end # @return [String, nil] The unique resource identifier (URI) of the record # (if persisted). # @example # Recurly::Account.new(:account_code => "account_code").uri # => nil # Recurly::Account.find("account_code").uri # # => "https://api.recurly.com/v2/accounts/account_code" def uri @href ||= ((API.base_uri + path).to_s if persisted?) end # Attempts to destroy the record. # # @return [true, false] +true+ if successful, +false+ if unable to destroy # (if the record does not persist on Recurly). # @raise [NotFound] The record cannot be found. # @example # account = Recurly::Account.find account_code # race_condition = Recurly::Account.find account_code # account.destroy # => true # account.destroy # => false (already destroyed) # race_condition.destroy # raises Recurly::Resource::NotFound def destroy return false unless persisted? @response = API.delete uri @destroyed = true rescue API::NotFound => e raise NotFound, e.description end def signable_attributes Hash[xml_keys.map { |key| [key, self[key]] }] end def ==(other) other.is_a?(self.class) && other.to_s == to_s end def marshal_dump [ @attributes.reject { |k, v| v.is_a?(Proc) }, @new_record, @destroyed, @uri, @href, changed_attributes, previous_changes, response, etag, links, @type ] end def marshal_load(serialization) @attributes, @new_record, @destroyed, @uri, @href, @changed_attributes, @previous_changes, @response, @etag, @links, @type = serialization end # @return [String] def inspect(attributes = self.class.attribute_names.to_a) string = "#<#{self.class}" string << "##@type" if respond_to?(:type) attributes += %w(errors) if errors.any? string << " %s" % attributes.map { |k| "#{k}: #{self.send(k).inspect}" }.join(', ') string << '>' end alias to_s inspect def apply_errors(exception) @response = exception.response document = XML.new exception.response.body if document.root.name == 'error' # Single error is returned from the API attribute_path = document['symbol'].text.split '.' invalid! [attribute_path[1]], document['description'].text else # Array of errors was returned by the API document.each_element 'error' do |el| attribute_path = el.attribute('field').value.split '.' invalid! attribute_path[1, attribute_path.length], el.text end end end protected def path @href or @uri or if persisted? self.class.member_path to_param else self.class.collection_path end end def invalid!(attribute_path, error) if attribute_path.length == 1 errors[attribute_path[0]] << error else child, k, v = attribute_path.shift.scan(/[^\[\]=]+/) if c = k ? self[child].find { |d| d[k] == v } : self[child] c.invalid! attribute_path, error if c.methods.include? :invalid! e = errors[child] << 'is invalid' and e.uniq! end end end def clear_errors errors.clear self.class.associations do |association| next unless respond_to? "#{association}=" # Clear writable only. [*self[association]].each do |associated| associated.clear_errors if associated.respond_to? :clear_errors end end end def copy_from(other) other.instance_variables.each do |ivar| instance_variable_set ivar, other.instance_variable_get(ivar) end end private def fetch_associated(name, value, options = {}) case value when Array value.map do |v| fetch_associated(Helper.singularize(name), v, association_name: name) end when Hash association_name = options[:association_name] || name associated_class_name = self.class.find_association(association_name).class_name associated_class_name ||= Helper.classify(name) Recurly.const_get(associated_class_name, false).send(:new, value) when Proc, Resource, Resource::Pager, nil value else raise "unexpected association #{name.inspect}=#{value.inspect}" end end def xml_keys changed_attributes.keys.sort end end end