resource 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. data/.document +5 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +26 -0
  4. data/Guardfile +22 -0
  5. data/LICENSE.txt +20 -0
  6. data/README.rdoc +19 -0
  7. data/Rakefile +49 -0
  8. data/VERSION +1 -0
  9. data/lib/api_resource.rb +51 -0
  10. data/lib/api_resource/associations.rb +472 -0
  11. data/lib/api_resource/attributes.rb +154 -0
  12. data/lib/api_resource/base.rb +517 -0
  13. data/lib/api_resource/callbacks.rb +49 -0
  14. data/lib/api_resource/connection.rb +162 -0
  15. data/lib/api_resource/core_extensions.rb +7 -0
  16. data/lib/api_resource/custom_methods.rb +119 -0
  17. data/lib/api_resource/exceptions.rb +74 -0
  18. data/lib/api_resource/formats.rb +14 -0
  19. data/lib/api_resource/formats/json_format.rb +25 -0
  20. data/lib/api_resource/formats/xml_format.rb +36 -0
  21. data/lib/api_resource/log_subscriber.rb +15 -0
  22. data/lib/api_resource/mocks.rb +249 -0
  23. data/lib/api_resource/model_errors.rb +86 -0
  24. data/lib/api_resource/observing.rb +29 -0
  25. data/resource.gemspec +125 -0
  26. data/spec/lib/associations_spec.rb +412 -0
  27. data/spec/lib/attributes_spec.rb +109 -0
  28. data/spec/lib/base_spec.rb +454 -0
  29. data/spec/lib/callbacks_spec.rb +68 -0
  30. data/spec/lib/model_errors_spec.rb +29 -0
  31. data/spec/spec_helper.rb +32 -0
  32. data/spec/support/mocks/association_mocks.rb +18 -0
  33. data/spec/support/mocks/error_resource_mocks.rb +21 -0
  34. data/spec/support/mocks/test_resource_mocks.rb +23 -0
  35. data/spec/support/requests/association_requests.rb +14 -0
  36. data/spec/support/requests/error_resource_requests.rb +25 -0
  37. data/spec/support/requests/test_resource_requests.rb +31 -0
  38. data/spec/support/test_resource.rb +19 -0
  39. metadata +277 -0
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,26 @@
1
+ source "http://rubygems.org"
2
+ # Add dependencies required to use your gem here.
3
+ # Example:
4
+ # gem "activesupport", ">= 2.3.5"
5
+ gem 'rails', '3.0.9'
6
+ gem 'activeresource', '3.0.9'
7
+ gem 'hash_dealer'
8
+ gem 'rest-client'
9
+
10
+ # Add dependencies to develop your gem here.
11
+ # Include everything needed to run rake, tests, features, etc.
12
+ group :development do
13
+ gem "rspec"
14
+ gem "ruby-debug19", :require => "ruby-debug"
15
+ gem "growl"
16
+ gem 'rspec-rails'
17
+ gem 'factory_girl'
18
+ gem 'simplecov'
19
+ gem 'faker'
20
+ gem 'guard'
21
+ gem 'guard-rspec'
22
+ gem 'mocha'
23
+ gem "bundler", "~> 1.0.0"
24
+ gem "jeweler", "~> 1.6.4"
25
+ gem "rcov", ">= 0"
26
+ end
@@ -0,0 +1,22 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard 'rspec', :version => 2, :cli => "--color --format nested" do
5
+ watch(%r{^spec/.+_spec\.rb$})
6
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
7
+ watch(%r{^lib/api_resource/(.+)\.rb$}) {|m| "spec/lib/#{m[1]}_spec.rb"}
8
+ watch('spec/spec_helper.rb') { "spec/" }
9
+
10
+ # Rails example
11
+ watch(%r{^spec/.+_spec\.rb$})
12
+ watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
13
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
14
+ watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] }
15
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec/" }
16
+ watch('spec/spec_helper.rb') { "spec/" }
17
+ watch('config/routes.rb') { "spec/routing" }
18
+ watch('app/controllers/application_controller.rb') { "spec/controllers" }
19
+ # Capybara request specs
20
+ watch(%r{^app/views/(.+)/.*\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" }
21
+ end
22
+
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Ethan Langevin
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,19 @@
1
+ = resource
2
+
3
+ Description goes here.
4
+
5
+ == Contributing to resource
6
+
7
+ * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
8
+ * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
9
+ * Fork the project
10
+ * Start a feature/bugfix branch
11
+ * Commit and push until you are happy with your contribution
12
+ * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
13
+ * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2011 Ethan Langevin. See LICENSE.txt for
18
+ further details.
19
+
@@ -0,0 +1,49 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ Jeweler::Tasks.new do |gem|
16
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
17
+ gem.name = "resource"
18
+ gem.homepage = "http://github.com/ejlangev/resource"
19
+ gem.license = "MIT"
20
+ gem.summary = %Q{A replacement for ActiveResource for RESTful APIs that handles associated object and multiple data sources}
21
+ gem.description = %Q{A replacement for ActiveResource for RESTful APIs that handles associated object and multiple data sources}
22
+ gem.email = "ejl6266@gmail.com"
23
+ gem.authors = ["Ethan Langevin"]
24
+ # dependencies defined in Gemfile
25
+ end
26
+ Jeweler::RubygemsDotOrgTasks.new
27
+
28
+ require 'rspec/core'
29
+ require 'rspec/core/rake_task'
30
+ RSpec::Core::RakeTask.new(:spec) do |spec|
31
+ spec.pattern = FileList['spec/**/*_spec.rb']
32
+ end
33
+
34
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
35
+ spec.pattern = 'spec/**/*_spec.rb'
36
+ spec.rcov = true
37
+ end
38
+
39
+ task :default => :spec
40
+
41
+ require 'rake/rdoctask'
42
+ Rake::RDocTask.new do |rdoc|
43
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
44
+
45
+ rdoc.rdoc_dir = 'rdoc'
46
+ rdoc.title = "resource #{version}"
47
+ rdoc.rdoc_files.include('README*')
48
+ rdoc.rdoc_files.include('lib/**/*.rb')
49
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,51 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext/hash'
3
+ require 'active_support/core_ext/object'
4
+ require 'active_support/core_ext/class/attribute_accessors'
5
+ require 'active_support/core_ext/class/inheritable_attributes'
6
+ require 'api_resource/core_extensions'
7
+ require 'active_model'
8
+
9
+ require 'api_resource/exceptions'
10
+
11
+ module ApiResource
12
+
13
+ extend ActiveSupport::Autoload
14
+
15
+ autoload :Associations
16
+ autoload :Attributes
17
+ autoload :Base
18
+ autoload :Callbacks
19
+ autoload :Connection
20
+ autoload :CustomMethods
21
+ autoload :Formats
22
+ autoload :Observing
23
+ autoload :Mocks
24
+ autoload :ModelErrors
25
+ autoload :Validations
26
+ autoload :LogSubscriber
27
+
28
+ def self.load_mocks_and_factories
29
+ require 'hash_dealer'
30
+ Mocks.clear_endpoints
31
+ Mocks.init
32
+
33
+ Dir["#{File.dirname(__FILE__)}/../spec/support/requests/*.rb"].each {|f| require f}
34
+ Dir["#{File.dirname(__FILE__)}/../spec/support/**/*.rb"].each {|f| require f}
35
+ end
36
+
37
+ def self.site=(new_site)
38
+ ApiResource::Base.site = new_site
39
+ end
40
+
41
+ def self.format=(new_format)
42
+ ApiResource::Base.format = new_format
43
+ end
44
+
45
+ # Use this method to enable logging in the future
46
+ # def self.logging(val = nil)
47
+ # return (@@logging || false) unless val
48
+ # return @@logging = val
49
+ # end
50
+
51
+ end
@@ -0,0 +1,472 @@
1
+ require 'active_support'
2
+ require 'active_support/string_inquirer'
3
+
4
+ module ApiResource
5
+
6
+ module Associations
7
+ extend ActiveSupport::Concern
8
+
9
+ ASSOCIATION_TYPES = [:belongs_to, :has_one, :has_many]
10
+
11
+ included do
12
+ # Define a class inheritable accessor for keeping track of the associations
13
+ # when this module is included
14
+ class_inheritable_accessor :related_objects
15
+
16
+ # Hash to hold onto the definitions of the related objects
17
+ self.related_objects = {
18
+ :belongs_to => {}.with_indifferent_access,
19
+ :has_one => {}.with_indifferent_access,
20
+ :has_many => {}.with_indifferent_access,
21
+ :scope => {}.with_indifferent_access
22
+ }.with_indifferent_access
23
+
24
+ end
25
+
26
+ module ClassMethods
27
+
28
+ # Define the methods for creating and testing for associations, unfortunately
29
+ # scopes are different enough to require different methods :(
30
+ ApiResource::Associations::ASSOCIATION_TYPES.each do |assoc|
31
+ self.module_eval <<-EOE, __FILE__, __LINE__ + 1
32
+ def #{assoc}(*args)
33
+ options = args.extract_options!
34
+ # Raise an error if we have multiple args and options
35
+ raise "Invalid arguments to #{assoc}" unless options.blank? || args.length == 1
36
+ args.each do |arg|
37
+ self.related_objects[:#{assoc}][arg.to_sym] = (options[:class_name] ? options[:class_name].to_s.classify : arg.to_s.classify)
38
+ # We need to define reader and writer methods here
39
+ define_association_as_attribute(:#{assoc}, arg)
40
+ end
41
+ end
42
+
43
+ def #{assoc}?(name)
44
+ return self.related_objects[:#{assoc}][name.to_s.pluralize.to_sym].present? || self.related_objects[:#{assoc}][name.to_s.singularize.to_sym].present?
45
+ end
46
+
47
+ def #{assoc}_class_name(name)
48
+ raise "No such" + :#{assoc}.to_s + " association on #{name}" unless self.#{assoc}?(name)
49
+ return self.related_objects[:#{assoc}][name.to_sym]
50
+ end
51
+
52
+ EOE
53
+ end
54
+
55
+ def scopes
56
+ return self.related_objects[:scope]
57
+ end
58
+
59
+ def scope(name, hsh)
60
+ raise ArgumentError, "Expecting an attributes hash given #{hsh.inspect}" unless hsh.is_a?(Hash)
61
+ self.related_objects[:scope][name.to_sym] = hsh
62
+ # we also need to define a class method for each scope
63
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
64
+ def #{name}(opts = {})
65
+ return ApiResource::Associations::ResourceScope.new(self, :#{name}, opts)
66
+ end
67
+ EOE
68
+ end
69
+
70
+ def scope?(name)
71
+ self.related_objects[:scope][name.to_sym].present?
72
+ end
73
+
74
+ def scope_attributes(name)
75
+ raise "No such scope #{name}" unless self.scope?(name)
76
+ self.related_objects[:scope][name.to_sym]
77
+ end
78
+
79
+ def association?(assoc)
80
+ self.related_objects.any? do |key, value|
81
+ next if key.to_s == "scope"
82
+ value.detect { |k,v| k.to_sym == assoc.to_sym }
83
+ end
84
+ end
85
+
86
+ def association_class_name(assoc)
87
+ raise ArgumentError, "#{assoc} is not a valid association of #{self}" unless self.association?(assoc)
88
+ result = self.related_objects.detect do |key,value|
89
+ ret = value.detect{|k,v| k.to_sym == assoc.to_sym }
90
+ return ret[1] if ret
91
+ end
92
+ end
93
+
94
+ def clear_associations
95
+ self.related_objects.each do |_, val|
96
+ val.clear
97
+ end
98
+ end
99
+
100
+ protected
101
+ def define_association_as_attribute(assoc_type, assoc_name)
102
+ define_attributes assoc_name
103
+ case assoc_type.to_sym
104
+ when :has_many
105
+ self.class_eval <<-EOE, __FILE__, __LINE__ + 1
106
+ def #{assoc_name}
107
+ self.attributes[:#{assoc_name}] ||= MultiObjectProxy.new(self.class.to_s, nil)
108
+ end
109
+ EOE
110
+ else
111
+ self.class_eval <<-EOE, __FILE__, __LINE__ + 1
112
+ def #{assoc_name}
113
+ self.attributes[:#{assoc_name}] ||= SingleObjectProxy.new(self.class.to_s, nil)
114
+ end
115
+ EOE
116
+ end
117
+ # Always define the setter the same
118
+ self.class_eval <<-EOE, __FILE__, __LINE__ + 1
119
+ def #{assoc_name}=(val)
120
+ #{assoc_name}_will_change! unless self.#{assoc_name}.internal_object == val
121
+ self.#{assoc_name}.internal_object = val
122
+ end
123
+ EOE
124
+ end
125
+
126
+ end
127
+
128
+ module InstanceMethods
129
+ # For convenience we will define the methods for testing for the existence of an association
130
+ # and getting the class for an association as instance methods too to avoid tons of self.class calls
131
+ ApiResource::Associations::ASSOCIATION_TYPES.each do |assoc|
132
+ module_eval <<-EOE, __FILE__, __LINE__ + 1
133
+ def #{assoc}?(name)
134
+ return self.class.#{assoc}?(name)
135
+ end
136
+
137
+ def #{assoc}_class_name(name)
138
+ return self.class.#{assoc}_class_name(name)
139
+ end
140
+ EOE
141
+ end
142
+
143
+ def association?(assoc)
144
+ self.class.association?(assoc)
145
+ end
146
+
147
+ def association_class_name(assoc)
148
+ self.class.association_class_name(assoc)
149
+ end
150
+
151
+ def scopes
152
+ return self.class.scopes
153
+ end
154
+
155
+ def scope?(name)
156
+ return self.class.scope?(name)
157
+ end
158
+
159
+ def scope_attributes(name)
160
+ return self.class.scope_attributes(name)
161
+ end
162
+
163
+ end
164
+
165
+ class Scope
166
+
167
+ attr_accessor :klass, :current_scope, :internal_object
168
+
169
+ attr_reader :scopes
170
+
171
+ def initialize(klass, current_scope, opts)
172
+ # Holds onto the association proxy this RelationScope is bound to
173
+ @klass = klass
174
+ @current_scope = Array.wrap(current_scope.to_s)
175
+ # define methods for the scopes of the object
176
+
177
+ klass.scopes.each do |key, val|
178
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
179
+ # This class always has at least one scope, adding a new one should clone this object
180
+ def #{key}(opts = {})
181
+ obj = self.clone
182
+ # Call reload to make it go back to the webserver the next time it loads
183
+ obj.reload
184
+ obj.enhance_current_scope(:#{key}, opts)
185
+ return obj
186
+ end
187
+ EOE
188
+ self.scopes[key.to_s] = val
189
+ end
190
+ # Use the method current scope because it gives a string
191
+ # This expression substitutes the options from opts into the default attributes of the scope, it will only copy keys that exist in the original
192
+ self.scopes[self.current_scope] = opts.inject(self.scopes[current_scope]){|accum,(k,v)| accum.key?(k.to_s) ? accum.merge(k.to_s => v) : accum}
193
+ end
194
+
195
+ # Use this method to access the internal data, this guarantees that loading only occurs once per object
196
+ def internal_object
197
+ raise "Not Implemented: This method must be implemented in a subclass"
198
+ end
199
+
200
+ def scopes
201
+ @scopes ||= {}.with_indifferent_access
202
+ end
203
+
204
+ def scope?(scp)
205
+ self.scopes.key?(scp.to_s)
206
+ end
207
+
208
+ def current_scope
209
+ ActiveSupport::StringInquirer.new(@current_scope.join("_and_").concat("_scope"))
210
+ end
211
+
212
+ def to_query
213
+ self.scopes[self.current_scope].to_query
214
+ end
215
+
216
+ def method_missing(method, *args, &block)
217
+ self.internal_object.send(method, *args, &block)
218
+ end
219
+
220
+ def reload
221
+ remove_instance_variable(:@internal_object) if instance_variable_defined?(:@internal_object)
222
+ self
223
+ end
224
+
225
+ def to_s
226
+ self.internal_object.to_s
227
+ end
228
+
229
+ def inspect
230
+ self.internal_object.inspect
231
+ end
232
+
233
+ protected
234
+ def enhance_current_scope(scp, opts)
235
+ scp = scp.to_s
236
+ raise ArgumentError, "Unknown scope #{scp}" unless self.scope?(scp)
237
+ # Hold onto the attributes related to the old scope that we're going to chain to
238
+ current_scope_hash = self.scopes[self.current_scope]
239
+ # This sets the new current scope making them unique and sorted to make it order independent
240
+ @current_scope = @current_scope.concat([scp.to_s]).uniq.sort
241
+ # This sets up the new options for the current scope, it merges the defaults for the new scope then substitutes from opts
242
+ self.scopes[self.current_scope] = opts.inject(current_scope_hash.merge(self.scopes[scp.to_s])){|accum,(k,v)| accum.key?(k.to_s) ? accum.merge(k.to_s => v) : accum }
243
+ end
244
+ end
245
+
246
+ class RelationScope < Scope
247
+
248
+ def reload
249
+ remove_instance_variable(:@internal_object) if instance_variable_defined?(:@internal_object)
250
+ self.klass.reload(self.current_scope, self.scopes[self.current_scope])
251
+ self
252
+ end
253
+
254
+ # Use this method to access the internal data, this guarantees that loading only occurs once per object
255
+ def internal_object
256
+ @internal_object ||= self.klass.send(:load_scope_with_options, self.current_scope, self.scopes[self.current_scope])
257
+ end
258
+
259
+ end
260
+
261
+ class ResourceScope < Scope
262
+
263
+ include Enumerable
264
+
265
+ def internal_object
266
+ @internal_object ||= self.klass.send(:find, :all, :params => self.scopes[self.current_scope])
267
+ end
268
+
269
+ alias_method :all, :internal_object
270
+
271
+ def each(*args, &block)
272
+ self.internal_object.each(*args, &block)
273
+ end
274
+
275
+ end
276
+
277
+ class AssociationProxy
278
+
279
+ cattr_accessor :remote_path_element; self.remote_path_element = :service_uri
280
+ cattr_accessor :include_class_scopes; self.include_class_scopes = true
281
+
282
+ attr_accessor :loaded, :klass, :internal_object, :remote_path, :scopes, :times_loaded
283
+
284
+ def initialize(klass_name, contents)
285
+ raise "Cannot create an association proxy to the unknown object #{klass_name}" unless defined?(klass_name.to_s.classify)
286
+ # A simple attr_accessor for testing purposes
287
+ self.times_loaded = 0
288
+ self.klass = klass_name.to_s.classify.constantize
289
+ self.load(contents)
290
+ self.loaded = {}.with_indifferent_access
291
+ if self.class.include_class_scopes
292
+ self.scopes = self.scopes.reverse_merge(self.klass.scopes)
293
+ end
294
+ # Now that we have set up all the scopes with the load method we need to create methods
295
+ self.scopes.each do |key, _|
296
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
297
+ def #{key}(opts = {})
298
+ ApiResource::Associations::RelationScope.new(self, :#{key}, opts)
299
+ end
300
+ EOE
301
+ end
302
+ end
303
+
304
+ def serializable_hash(options = {})
305
+ raise "Not Implemented: This method must be implemented in a subclass"
306
+ end
307
+
308
+ def scopes
309
+ @scopes ||= {}.with_indifferent_access
310
+ end
311
+
312
+ def scope?(scp)
313
+ self.scopes.keys.include?(scp.to_s)
314
+ end
315
+
316
+ def internal_object
317
+ @internal_object ||= self.load_scope_with_options(:all, {})
318
+ end
319
+
320
+ def method_missing(method, *args, &block)
321
+ self.internal_object.send(method, *args, &block)
322
+ end
323
+
324
+ def reload(scope = nil, opts = {})
325
+ if scope.nil?
326
+ self.loaded.clear
327
+ self.times_loaded = 0
328
+ # Remove the loaded object to force it to reload
329
+ remove_instance_variable(:@internal_object)
330
+ else
331
+ # Delete this key from the loaded hash which will cause it to be reloaded
332
+ self.loaded.delete(self.loaded_hash_key(scope, opts))
333
+ end
334
+ self
335
+ end
336
+
337
+ def to_s
338
+ self.internal_object.to_s
339
+ end
340
+
341
+ def inspect
342
+ self.internal_object.inspect
343
+ end
344
+
345
+ protected
346
+ # This method loads a particular scope with a set of options from the remote server
347
+ def load_scope_with_options(scope, options)
348
+ raise "Not Implemented: This method must be implemented in a subclass"
349
+ end
350
+ # This method is a helper to initialize for loading the data passed in to create this object
351
+ def load(contents)
352
+ raise "Not Implemented: This method must be implemented in a subclass"
353
+ end
354
+
355
+ # This method create the key for the loaded hash, it ensures that a unique set of scopes
356
+ # with a unique set of options is only loaded once
357
+ def loaded_hash_key(scope, options)
358
+ options.to_a.sort.inject(scope) {|accum,(k,v)| accum << "_#{k}_#{v}"}
359
+ end
360
+ end
361
+
362
+ class SingleObjectProxy < AssociationProxy
363
+
364
+ def serializable_hash(options = {})
365
+ self.internal_object.serializable_hash(options)
366
+ end
367
+
368
+ protected
369
+ def load_scope_with_options(scope, options)
370
+ scope = self.loaded_hash_key(scope.to_s, options)
371
+ # If the service uri is blank you can't load
372
+ return nil if self.remote_path.blank?
373
+ unless self.loaded[scope]
374
+ self.times_loaded += 1
375
+ self.loaded[scope] = self.klass.connection.get("#{self.remote_path}.#{self.klass.format.extension}?#{options.to_query}")
376
+ end
377
+ self.klass.new(self.loaded[scope])
378
+ end
379
+
380
+ def load(contents)
381
+ # If we get something nil this should just behave like nil
382
+ return if contents.nil?
383
+ raise "Expected an attributes hash got #{contents}" unless contents.is_a?(Hash)
384
+ # If we don't have a 'service_uri' just assume that these are all attributes and make an object
385
+ return @internal_object = self.klass.new(contents) unless contents[self.class.remote_path_element]
386
+ # allow for symbols vs strings with these elements
387
+ self.remote_path = contents.delete(self.class.remote_path_element) || contents.delete(self.class.remote_path_element.to_s)
388
+ # There's only one hash here so it's hard to distinguish attributes from scopes, the key scopes_only says everything
389
+ # in this hash is a scope
390
+ no_attrs = (contents.delete("scopes_only") || contents.delete(:scopes_only) || false)
391
+ attrs = {}
392
+ contents.each do |key, val|
393
+ # if this key is an attribute add it to attrs, warn if we've set scopes_only
394
+ if self.klass.attribute_names.include?(key) && !no_attrs
395
+ attrs[key] = val
396
+ else
397
+ warn("#{key} is an attribute of #{self.klass}, beware of name collisions") if no_attrs && self.klass.attribute_names.include?(key)
398
+ raise "Expected the scope #{key} to have a hash for a value, got #{val}" unless val.is_a?(Hash)
399
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
400
+ def #{key}(opts = {})
401
+ ApiResource::Associations::RelationScope.new(self, :#{key}, opts)
402
+ end
403
+ EOE
404
+ self.scopes[key.to_s] = val
405
+ end
406
+ end
407
+ @internal_object = attrs.present? ? self.klass.new(attrs) : nil
408
+ end
409
+ end
410
+
411
+ class MultiObjectProxy < AssociationProxy
412
+
413
+ include Enumerable
414
+
415
+ def all
416
+ self.internal_object
417
+ end
418
+
419
+ def each(*args, &block)
420
+ self.internal_object.each(*args, &block)
421
+ end
422
+
423
+ def serializable_hash(options)
424
+ self.internal_object.collect{|obj| obj.serializable_hash(options) }
425
+ end
426
+
427
+ # force a load when calling this method
428
+ def internal_object
429
+ @internal_object ||= self.load_scope_with_options(:all, {})
430
+ end
431
+
432
+ protected
433
+ def load_scope_with_options(scope, options)
434
+ scope = self.loaded_hash_key(scope.to_s, options)
435
+ return [] if self.remote_path.blank?
436
+ unless self.loaded[scope]
437
+ self.times_loaded += 1
438
+ self.loaded[scope] = self.klass.connection.get("#{self.remote_path}.#{self.klass.format.extension}?#{options.to_query}")
439
+ end
440
+ self.loaded[scope].collect{|item| self.klass.new(item)}
441
+ end
442
+
443
+ def load(contents)
444
+ # If we have a blank array or it's just nil then we should just return after setting internal_object to a blank array
445
+ @internal_object = [] and return nil if (contents.is_a?(Array) && contents.blank?) || contents.nil?
446
+ if contents.is_a?(Array) && contents.first.is_a?(Hash) && contents.first[self.class.remote_path_element]
447
+ settings = contents.slice!(0).with_indifferent_access
448
+ end
449
+
450
+ settings = contents if contents.is_a?(Hash)
451
+ settings ||= {}.with_indifferent_access
452
+
453
+ raise "Invalid response for multi object relationship: #{contents}" unless settings[self.class.remote_path_element] || contents.is_a?(Array)
454
+ self.remote_path = settings.delete(self.class.remote_path_element)
455
+
456
+ settings.each do |key, value|
457
+ raise "Expected the scope #{key} to point to a hash, to #{value}" unless value.is_a?(Hash)
458
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
459
+ def #{key}(opts = {})
460
+ ApiResource::Associations::RelationScope.new(self, :#{key}, opts)
461
+ end
462
+ EOE
463
+ self.scopes[key.to_s] = value
464
+ end
465
+
466
+ # Create the internal object
467
+ @internal_object = contents.is_a?(Array) ? contents.collect{|item| self.klass.new(item)} : nil
468
+ end
469
+ end
470
+ end
471
+
472
+ end