api_resource 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.document +5 -0
- data/.rspec +3 -0
- data/Gemfile +29 -0
- data/Gemfile.lock +152 -0
- data/Guardfile +22 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +19 -0
- data/Rakefile +49 -0
- data/VERSION +1 -0
- data/api_resource.gemspec +154 -0
- data/lib/api_resource.rb +129 -0
- data/lib/api_resource/association_activation.rb +19 -0
- data/lib/api_resource/associations.rb +169 -0
- data/lib/api_resource/associations/association_proxy.rb +115 -0
- data/lib/api_resource/associations/belongs_to_remote_object_proxy.rb +16 -0
- data/lib/api_resource/associations/dynamic_resource_scope.rb +23 -0
- data/lib/api_resource/associations/has_many_remote_object_proxy.rb +16 -0
- data/lib/api_resource/associations/has_one_remote_object_proxy.rb +24 -0
- data/lib/api_resource/associations/multi_argument_resource_scope.rb +15 -0
- data/lib/api_resource/associations/multi_object_proxy.rb +73 -0
- data/lib/api_resource/associations/related_object_hash.rb +12 -0
- data/lib/api_resource/associations/relation_scope.rb +30 -0
- data/lib/api_resource/associations/resource_scope.rb +34 -0
- data/lib/api_resource/associations/scope.rb +107 -0
- data/lib/api_resource/associations/single_object_proxy.rb +81 -0
- data/lib/api_resource/attributes.rb +162 -0
- data/lib/api_resource/base.rb +587 -0
- data/lib/api_resource/callbacks.rb +49 -0
- data/lib/api_resource/connection.rb +171 -0
- data/lib/api_resource/core_extensions.rb +7 -0
- data/lib/api_resource/custom_methods.rb +119 -0
- data/lib/api_resource/exceptions.rb +87 -0
- data/lib/api_resource/formats.rb +14 -0
- data/lib/api_resource/formats/json_format.rb +25 -0
- data/lib/api_resource/formats/xml_format.rb +36 -0
- data/lib/api_resource/local.rb +12 -0
- data/lib/api_resource/log_subscriber.rb +15 -0
- data/lib/api_resource/mocks.rb +269 -0
- data/lib/api_resource/model_errors.rb +86 -0
- data/lib/api_resource/observing.rb +29 -0
- data/lib/api_resource/railtie.rb +22 -0
- data/lib/api_resource/scopes.rb +45 -0
- data/spec/lib/associations_spec.rb +656 -0
- data/spec/lib/attributes_spec.rb +121 -0
- data/spec/lib/base_spec.rb +504 -0
- data/spec/lib/callbacks_spec.rb +68 -0
- data/spec/lib/connection_spec.rb +76 -0
- data/spec/lib/local_spec.rb +20 -0
- data/spec/lib/mocks_spec.rb +28 -0
- data/spec/lib/model_errors_spec.rb +29 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/mocks/association_mocks.rb +46 -0
- data/spec/support/mocks/error_resource_mocks.rb +21 -0
- data/spec/support/mocks/test_resource_mocks.rb +43 -0
- data/spec/support/requests/association_requests.rb +14 -0
- data/spec/support/requests/error_resource_requests.rb +25 -0
- data/spec/support/requests/test_resource_requests.rb +31 -0
- data/spec/support/test_resource.rb +64 -0
- metadata +334 -0
data/lib/api_resource.rb
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/inflector'
|
3
|
+
require 'active_support/core_ext/hash'
|
4
|
+
require 'active_support/core_ext/object'
|
5
|
+
require 'active_support/core_ext/class/attribute_accessors'
|
6
|
+
require 'active_support/core_ext/class/inheritable_attributes'
|
7
|
+
require 'api_resource/core_extensions'
|
8
|
+
|
9
|
+
require 'active_model'
|
10
|
+
|
11
|
+
require 'log4r'
|
12
|
+
require 'log4r/outputter/consoleoutputters'
|
13
|
+
|
14
|
+
require 'api_resource/exceptions'
|
15
|
+
|
16
|
+
require 'differ'
|
17
|
+
require 'colorize'
|
18
|
+
|
19
|
+
module ApiResource
|
20
|
+
|
21
|
+
extend ActiveSupport::Autoload
|
22
|
+
|
23
|
+
autoload :Associations
|
24
|
+
autoload :AssociationActivation
|
25
|
+
autoload :Attributes
|
26
|
+
autoload :Base
|
27
|
+
autoload :Callbacks
|
28
|
+
autoload :Connection
|
29
|
+
autoload :CustomMethods
|
30
|
+
autoload :Formats
|
31
|
+
autoload :Local
|
32
|
+
autoload :LogSubscriber
|
33
|
+
autoload :Mocks
|
34
|
+
autoload :ModelErrors
|
35
|
+
autoload :Observing
|
36
|
+
autoload :Scopes
|
37
|
+
autoload :Validations
|
38
|
+
|
39
|
+
|
40
|
+
mattr_writer :logger
|
41
|
+
mattr_accessor :raise_missing_definition_error; self.raise_missing_definition_error = false
|
42
|
+
|
43
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
44
|
+
|
45
|
+
# Load a fix for inflections for words ending in ess
|
46
|
+
ActiveSupport::Inflector.inflections do |inflect|
|
47
|
+
inflect.singular(/ess$/i, 'ess')
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.load_mocks_and_factories
|
51
|
+
require 'hash_dealer'
|
52
|
+
Mocks.clear_endpoints
|
53
|
+
Mocks.init
|
54
|
+
|
55
|
+
Dir["#{File.dirname(__FILE__)}/../spec/support/requests/*.rb"].each {|f| require f}
|
56
|
+
Dir["#{File.dirname(__FILE__)}/../spec/support/**/*.rb"].each {|f| require f}
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.site=(new_site)
|
60
|
+
ApiResource::Base.site = new_site
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.format=(new_format)
|
64
|
+
ApiResource::Base.format = new_format
|
65
|
+
end
|
66
|
+
# set token
|
67
|
+
def self.token=(new_token)
|
68
|
+
ApiResource::Base.token = new_token
|
69
|
+
end
|
70
|
+
# get token
|
71
|
+
def self.token
|
72
|
+
ApiResource::Base.token
|
73
|
+
end
|
74
|
+
# Run a block with a given token - useful for AroundFilters
|
75
|
+
def self.with_token(new_token, &block)
|
76
|
+
old_token = self.token
|
77
|
+
begin
|
78
|
+
self.token = new_token
|
79
|
+
yield
|
80
|
+
ensure
|
81
|
+
self.token = old_token
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# delegated to Base
|
86
|
+
def self.reset_connection
|
87
|
+
ApiResource::Base.reset_connection
|
88
|
+
end
|
89
|
+
|
90
|
+
# set the timeout val and reset the connection
|
91
|
+
def self.timeout=(val)
|
92
|
+
@timeout = val
|
93
|
+
self.reset_connection
|
94
|
+
val
|
95
|
+
end
|
96
|
+
|
97
|
+
# Getter for timeout
|
98
|
+
def self.timeout
|
99
|
+
@timeout ||= DEFAULT_TIMEOUT
|
100
|
+
end
|
101
|
+
|
102
|
+
# set the timeout val and reset the connection
|
103
|
+
def self.open_timeout=(val)
|
104
|
+
@open_timeout = val
|
105
|
+
self.reset_connection
|
106
|
+
val
|
107
|
+
end
|
108
|
+
|
109
|
+
# Getter for timeout
|
110
|
+
def self.open_timeout
|
111
|
+
@open_timeout ||= DEFAULT_TIMEOUT
|
112
|
+
end
|
113
|
+
|
114
|
+
# logger
|
115
|
+
def self.logger
|
116
|
+
return @logger if @logger
|
117
|
+
@logger = Log4r::Logger.new("api_resource")
|
118
|
+
@logger.outputters = [Log4r::StdoutOutputter.new('console')]
|
119
|
+
@logger.level = Log4r::INFO
|
120
|
+
@logger
|
121
|
+
end
|
122
|
+
|
123
|
+
# Use this method to enable logging in the future
|
124
|
+
# def self.logging(val = nil)
|
125
|
+
# return (@@logging || false) unless val
|
126
|
+
# return @@logging = val
|
127
|
+
# end
|
128
|
+
|
129
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ApiResource
|
2
|
+
module AssociationActivation
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
cattr_accessor :association_types
|
7
|
+
# our default association types
|
8
|
+
self.association_types = {:belongs_to => :single, :has_one => :single, :has_many => :multi}
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def activate_associations(assoc_types = nil)
|
13
|
+
self.association_types = assoc_types unless assoc_types.nil?
|
14
|
+
self.send(:include, ApiResource::Associations)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/string_inquirer'
|
3
|
+
require 'api_resource/association_activation'
|
4
|
+
require 'api_resource/associations/relation_scope'
|
5
|
+
require 'api_resource/associations/resource_scope'
|
6
|
+
require 'api_resource/associations/dynamic_resource_scope'
|
7
|
+
require 'api_resource/associations/multi_argument_resource_scope'
|
8
|
+
require 'api_resource/associations/multi_object_proxy'
|
9
|
+
require 'api_resource/associations/single_object_proxy'
|
10
|
+
require 'api_resource/associations/belongs_to_remote_object_proxy'
|
11
|
+
require 'api_resource/associations/has_one_remote_object_proxy'
|
12
|
+
require 'api_resource/associations/has_many_remote_object_proxy'
|
13
|
+
require 'api_resource/associations/related_object_hash'
|
14
|
+
|
15
|
+
module ApiResource
|
16
|
+
|
17
|
+
module Associations
|
18
|
+
extend ActiveSupport::Concern
|
19
|
+
|
20
|
+
included do
|
21
|
+
|
22
|
+
raise "Cannot include Associations without first including AssociationActivation" unless self.ancestors.include?(ApiResource::AssociationActivation)
|
23
|
+
class_inheritable_accessor :related_objects
|
24
|
+
|
25
|
+
# Hash to hold onto the definitions of the related objects
|
26
|
+
self.related_objects = RelatedObjectHash.new
|
27
|
+
self.association_types.keys.each do |type|
|
28
|
+
self.related_objects[type] = RelatedObjectHash.new({})
|
29
|
+
end
|
30
|
+
self.related_objects[:scope] = RelatedObjectHash.new({})
|
31
|
+
|
32
|
+
self.define_association_methods
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
# module methods to include the proper associations in various libraries - this is usually loaded in Railties
|
37
|
+
def self.activate_active_record
|
38
|
+
ActiveRecord::Base.class_eval do
|
39
|
+
include ApiResource::AssociationActivation
|
40
|
+
self.activate_associations(:has_many_remote => :has_many_remote, :belongs_to_remote => :belongs_to_remote, :has_one_remote => :has_one_remote)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
module ClassMethods
|
45
|
+
|
46
|
+
# Define the methods for creating and testing for associations, unfortunately
|
47
|
+
# scopes are different enough to require different methods :(
|
48
|
+
def define_association_methods
|
49
|
+
self.association_types.each_key do |assoc|
|
50
|
+
self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
|
51
|
+
def #{assoc}(*args)
|
52
|
+
options = args.extract_options!
|
53
|
+
options = options.with_indifferent_access
|
54
|
+
# Raise an error if we have multiple args and options
|
55
|
+
raise "Invalid arguments to #{assoc}" unless options.blank? || args.length == 1
|
56
|
+
args.each do |arg|
|
57
|
+
klass_name = (options[:class_name] ? options[:class_name].to_s.classify : arg.to_s.classify)
|
58
|
+
# add this to any descendants - the other methods etc are handled by inheritance
|
59
|
+
([self] + self.descendants).each do |klass|
|
60
|
+
klass.related_objects[:#{assoc}][arg.to_sym] = klass_name
|
61
|
+
end
|
62
|
+
# We need to define reader and writer methods here
|
63
|
+
define_association_as_attribute(:#{assoc}, arg)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def #{assoc}?(name)
|
68
|
+
return self.related_objects[:#{assoc}][name.to_s.pluralize.to_sym].present? || self.related_objects[:#{assoc}][name.to_s.singularize.to_sym].present?
|
69
|
+
end
|
70
|
+
|
71
|
+
def #{assoc}_class_name(name)
|
72
|
+
raise "No such" + :#{assoc}.to_s + " association on #{name}" unless self.#{assoc}?(name)
|
73
|
+
return self.find_namespaced_class_name(self.related_objects[:#{assoc}][name.to_sym])
|
74
|
+
end
|
75
|
+
|
76
|
+
EOE
|
77
|
+
# For convenience we will define the methods for testing for the existence of an association
|
78
|
+
# and getting the class for an association as instance methods too to avoid tons of self.class calls
|
79
|
+
self.class_eval <<-EOE, __FILE__, __LINE__ + 1
|
80
|
+
def #{assoc}?(name)
|
81
|
+
return self.class.#{assoc}?(name)
|
82
|
+
end
|
83
|
+
|
84
|
+
def #{assoc}_class_name(name)
|
85
|
+
return self.class.#{assoc}_class_name(name)
|
86
|
+
end
|
87
|
+
EOE
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def association?(assoc)
|
92
|
+
self.related_objects.any? do |key, value|
|
93
|
+
next if key.to_s == "scope"
|
94
|
+
value.detect { |k,v| k.to_sym == assoc.to_sym }
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def association_names
|
99
|
+
# structure is {:has_many => {"myname" => "ClassName"}}
|
100
|
+
self.related_objects.clone.delete_if{|k,v| k.to_s == "scope"}.collect{|k,v| v.keys.first.to_sym}
|
101
|
+
end
|
102
|
+
|
103
|
+
def association_class_name(assoc)
|
104
|
+
raise ArgumentError, "#{assoc} is not a valid association of #{self}" unless self.association?(assoc)
|
105
|
+
result = self.related_objects.detect do |key,value|
|
106
|
+
ret = value.detect{|k,v| k.to_sym == assoc.to_sym }
|
107
|
+
return self.find_namespaced_class_name(ret[1]) if ret
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def clear_associations
|
112
|
+
self.related_objects.each do |_, val|
|
113
|
+
val.clear
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
protected
|
118
|
+
def define_association_as_attribute(assoc_type, assoc_name)
|
119
|
+
self.class_eval <<-EOE, __FILE__, __LINE__ + 1
|
120
|
+
def #{assoc_name}
|
121
|
+
self.attributes[:#{assoc_name}] ||= #{self.association_types[assoc_type.to_sym].to_s.classify}ObjectProxy.new(self.association_class_name('#{assoc_name}'), nil, self)
|
122
|
+
end
|
123
|
+
def #{assoc_name}=(val)
|
124
|
+
#{assoc_name}_will_change! unless self.#{assoc_name}.internal_object == val
|
125
|
+
self.#{assoc_name}.internal_object = val
|
126
|
+
end
|
127
|
+
def #{assoc_name}?
|
128
|
+
self.#{assoc_name}.internal_object.present?
|
129
|
+
end
|
130
|
+
EOE
|
131
|
+
end
|
132
|
+
|
133
|
+
def find_namespaced_class_name(klass)
|
134
|
+
# return the name if it is itself namespaced
|
135
|
+
return klass if klass =~ /::/
|
136
|
+
ancestors = self.name.split("::")
|
137
|
+
if ancestors.size > 1
|
138
|
+
receiver = Object
|
139
|
+
namespaces = ancestors[0..-2].collect do |mod|
|
140
|
+
receiver = receiver.const_get(mod)
|
141
|
+
end
|
142
|
+
if namespace = namespaces.reverse.detect{|ns| ns.const_defined?(klass, false)}
|
143
|
+
return namespace.const_get(klass).name
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
return klass
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
|
152
|
+
module InstanceMethods
|
153
|
+
def association?(assoc)
|
154
|
+
self.class.association?(assoc)
|
155
|
+
end
|
156
|
+
|
157
|
+
def association_class_name(assoc)
|
158
|
+
self.class.association_class_name(assoc)
|
159
|
+
end
|
160
|
+
|
161
|
+
# list of all association names
|
162
|
+
def association_names
|
163
|
+
self.class.association_names
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
168
|
+
|
169
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module ApiResource
|
2
|
+
|
3
|
+
module Associations
|
4
|
+
|
5
|
+
class AssociationProxy
|
6
|
+
|
7
|
+
cattr_accessor :remote_path_element; self.remote_path_element = :service_uri
|
8
|
+
cattr_accessor :include_class_scopes; self.include_class_scopes = true
|
9
|
+
|
10
|
+
attr_accessor :owner, :loaded, :klass, :internal_object, :remote_path, :scopes, :times_loaded
|
11
|
+
|
12
|
+
# TODO: added owner - moved it to the end because the tests don't use it - it's useful here though
|
13
|
+
def initialize(klass_name, contents, owner = nil)
|
14
|
+
raise "Cannot create an association proxy to the unknown object #{klass_name}" unless defined?(klass_name.to_s.classify)
|
15
|
+
# A simple attr_accessor for testing purposes
|
16
|
+
self.times_loaded = 0
|
17
|
+
self.owner = owner
|
18
|
+
self.klass = klass_name.to_s.classify.constantize
|
19
|
+
self.load(contents)
|
20
|
+
self.loaded = {}.with_indifferent_access
|
21
|
+
if self.class.include_class_scopes
|
22
|
+
self.scopes = self.scopes.reverse_merge(self.klass.scopes)
|
23
|
+
end
|
24
|
+
# Now that we have set up all the scopes with the load method we need to create methods
|
25
|
+
self.scopes.each do |key, _|
|
26
|
+
self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
|
27
|
+
def #{key}(opts = {})
|
28
|
+
ApiResource::Associations::RelationScope.new(self, :#{key}, opts)
|
29
|
+
end
|
30
|
+
EOE
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def serializable_hash(options = {})
|
35
|
+
raise "Not Implemented: This method must be implemented in a subclass"
|
36
|
+
end
|
37
|
+
|
38
|
+
def scopes
|
39
|
+
@scopes ||= {}.with_indifferent_access
|
40
|
+
end
|
41
|
+
|
42
|
+
def scope?(scp)
|
43
|
+
self.scopes.keys.include?(scp.to_s)
|
44
|
+
end
|
45
|
+
|
46
|
+
def internal_object
|
47
|
+
@internal_object ||= self.load_scope_with_options(:all, {})
|
48
|
+
end
|
49
|
+
|
50
|
+
def blank?
|
51
|
+
self.internal_object.blank?
|
52
|
+
end
|
53
|
+
alias_method :empty?, :blank?
|
54
|
+
|
55
|
+
def method_missing(method, *args, &block)
|
56
|
+
self.internal_object.send(method, *args, &block)
|
57
|
+
end
|
58
|
+
|
59
|
+
def reload(scope = nil, opts = {})
|
60
|
+
if scope.nil?
|
61
|
+
self.loaded.clear
|
62
|
+
self.times_loaded = 0
|
63
|
+
# Remove the loaded object to force it to reload
|
64
|
+
remove_instance_variable(:@internal_object) if instance_variable_defined?(:@internal_object)
|
65
|
+
else
|
66
|
+
# Delete this key from the loaded hash which will cause it to be reloaded
|
67
|
+
self.loaded.delete(self.loaded_hash_key(scope, opts))
|
68
|
+
end
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
def to_s
|
73
|
+
self.internal_object.to_s
|
74
|
+
end
|
75
|
+
|
76
|
+
def inspect
|
77
|
+
self.internal_object.inspect
|
78
|
+
end
|
79
|
+
|
80
|
+
protected
|
81
|
+
# This method loads a particular scope with a set of options from the remote server
|
82
|
+
def load_scope_with_options(scope, options)
|
83
|
+
raise "Not Implemented: This method must be implemented in a subclass"
|
84
|
+
end
|
85
|
+
# This method is a helper to initialize for loading the data passed in to create this object
|
86
|
+
def load(contents)
|
87
|
+
raise "Not Implemented: This method must be implemented in a subclass"
|
88
|
+
end
|
89
|
+
|
90
|
+
# get the remote URI based on our config and options
|
91
|
+
def build_load_path(options)
|
92
|
+
path = self.remote_path
|
93
|
+
# add a format if it doesn't exist and there is no query string yet
|
94
|
+
path += ".#{self.klass.format.extension}" unless path =~ /\./ || path =~/\?/
|
95
|
+
# add the query string, allowing for other user-provided options in the remote_path if we have options
|
96
|
+
unless options.blank?
|
97
|
+
path += (path =~ /\?/ ? "&" : "?") + options.to_query
|
98
|
+
end
|
99
|
+
path
|
100
|
+
end
|
101
|
+
|
102
|
+
# get data from the remote server
|
103
|
+
def load_from_remote(options)
|
104
|
+
self.klass.connection.get(self.build_load_path(options))
|
105
|
+
end
|
106
|
+
# This method create the key for the loaded hash, it ensures that a unique set of scopes
|
107
|
+
# with a unique set of options is only loaded once
|
108
|
+
def loaded_hash_key(scope, options)
|
109
|
+
options.to_a.sort.inject(scope) {|accum,(k,v)| accum << "_#{k}_#{v}"}
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'api_resource/associations/single_object_proxy'
|
2
|
+
module ApiResource
|
3
|
+
module Associations
|
4
|
+
class BelongsToRemoteObjectProxy < SingleObjectProxy
|
5
|
+
def initialize(klass_name, contents, owner)
|
6
|
+
super
|
7
|
+
return if self.internal_object
|
8
|
+
# now if we have an owner and a foreign key, we set the data up to load
|
9
|
+
if owner && key = owner.send(self.klass.to_s.foreign_key)
|
10
|
+
self.load({"service_uri" => self.klass.element_path(key), "scopes_only" => true}.merge(self.klass.scopes))
|
11
|
+
end
|
12
|
+
true
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|