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.
Files changed (59) hide show
  1. data/.document +5 -0
  2. data/.rspec +3 -0
  3. data/Gemfile +29 -0
  4. data/Gemfile.lock +152 -0
  5. data/Guardfile +22 -0
  6. data/LICENSE.txt +20 -0
  7. data/README.rdoc +19 -0
  8. data/Rakefile +49 -0
  9. data/VERSION +1 -0
  10. data/api_resource.gemspec +154 -0
  11. data/lib/api_resource.rb +129 -0
  12. data/lib/api_resource/association_activation.rb +19 -0
  13. data/lib/api_resource/associations.rb +169 -0
  14. data/lib/api_resource/associations/association_proxy.rb +115 -0
  15. data/lib/api_resource/associations/belongs_to_remote_object_proxy.rb +16 -0
  16. data/lib/api_resource/associations/dynamic_resource_scope.rb +23 -0
  17. data/lib/api_resource/associations/has_many_remote_object_proxy.rb +16 -0
  18. data/lib/api_resource/associations/has_one_remote_object_proxy.rb +24 -0
  19. data/lib/api_resource/associations/multi_argument_resource_scope.rb +15 -0
  20. data/lib/api_resource/associations/multi_object_proxy.rb +73 -0
  21. data/lib/api_resource/associations/related_object_hash.rb +12 -0
  22. data/lib/api_resource/associations/relation_scope.rb +30 -0
  23. data/lib/api_resource/associations/resource_scope.rb +34 -0
  24. data/lib/api_resource/associations/scope.rb +107 -0
  25. data/lib/api_resource/associations/single_object_proxy.rb +81 -0
  26. data/lib/api_resource/attributes.rb +162 -0
  27. data/lib/api_resource/base.rb +587 -0
  28. data/lib/api_resource/callbacks.rb +49 -0
  29. data/lib/api_resource/connection.rb +171 -0
  30. data/lib/api_resource/core_extensions.rb +7 -0
  31. data/lib/api_resource/custom_methods.rb +119 -0
  32. data/lib/api_resource/exceptions.rb +87 -0
  33. data/lib/api_resource/formats.rb +14 -0
  34. data/lib/api_resource/formats/json_format.rb +25 -0
  35. data/lib/api_resource/formats/xml_format.rb +36 -0
  36. data/lib/api_resource/local.rb +12 -0
  37. data/lib/api_resource/log_subscriber.rb +15 -0
  38. data/lib/api_resource/mocks.rb +269 -0
  39. data/lib/api_resource/model_errors.rb +86 -0
  40. data/lib/api_resource/observing.rb +29 -0
  41. data/lib/api_resource/railtie.rb +22 -0
  42. data/lib/api_resource/scopes.rb +45 -0
  43. data/spec/lib/associations_spec.rb +656 -0
  44. data/spec/lib/attributes_spec.rb +121 -0
  45. data/spec/lib/base_spec.rb +504 -0
  46. data/spec/lib/callbacks_spec.rb +68 -0
  47. data/spec/lib/connection_spec.rb +76 -0
  48. data/spec/lib/local_spec.rb +20 -0
  49. data/spec/lib/mocks_spec.rb +28 -0
  50. data/spec/lib/model_errors_spec.rb +29 -0
  51. data/spec/spec_helper.rb +36 -0
  52. data/spec/support/mocks/association_mocks.rb +46 -0
  53. data/spec/support/mocks/error_resource_mocks.rb +21 -0
  54. data/spec/support/mocks/test_resource_mocks.rb +43 -0
  55. data/spec/support/requests/association_requests.rb +14 -0
  56. data/spec/support/requests/error_resource_requests.rb +25 -0
  57. data/spec/support/requests/test_resource_requests.rb +31 -0
  58. data/spec/support/test_resource.rb +64 -0
  59. metadata +334 -0
@@ -0,0 +1,23 @@
1
+ require 'api_resource/associations/resource_scope'
2
+
3
+ module ApiResource
4
+ module Associations
5
+ class DynamicResourceScope < ResourceScope
6
+
7
+ attr_accessor :dynamic_value
8
+ # initializer - set up the dynamic value
9
+ def initialize(klass, current_scope, dynamic_value, opts = {})
10
+ self.dynamic_value = dynamic_value
11
+ super(klass, current_scope, opts)
12
+ end
13
+ # get the to_query value for this resource scope
14
+ def to_hash
15
+ hsh = self.scopes[self.current_scope].clone
16
+ hsh.each_pair do |k,v|
17
+ hsh[k] = self.dynamic_value
18
+ end
19
+ self.parent_hash.merge(hsh)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ require 'api_resource/associations/multi_object_proxy'
2
+ module ApiResource
3
+ module Associations
4
+ class HasManyRemoteObjectProxy < MultiObjectProxy
5
+ def initialize(klass_name, contents, owner)
6
+ super
7
+ return if self.internal_object.present? || self.remote_path
8
+ # now if we have an owner and a foreign key, we set the data up to load
9
+ if owner
10
+ self.load({"service_uri" => self.klass.collection_path(self.owner.class.to_s.foreign_key => self.owner.id)}.merge(self.klass.scopes))
11
+ end
12
+ true
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ require 'api_resource/associations/single_object_proxy'
2
+ module ApiResource
3
+ module Associations
4
+ class HasOneRemoteObjectProxy < 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
10
+ self.load({"service_uri" => self.klass.collection_path(self.owner.class.to_s.foreign_key => self.owner.id)}.merge(self.klass.scopes))
11
+ end
12
+ true
13
+ end
14
+ protected
15
+ # load data from the remote server
16
+ # In a has_one, we can get back an Array, so we use the first element
17
+ def load_from_remote(options)
18
+ data = super(options)
19
+ data = data.first if data.is_a?(Array)
20
+ data
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ require 'api_resource/associations/dynamic_resource_scope'
2
+
3
+ module ApiResource
4
+ module Associations
5
+ class MultiArgumentResourceScope < DynamicResourceScope
6
+ # initialize with a variable number of dynamic arguments
7
+ def initialize(klass, current_scope, *dynamic_value)
8
+ # pull off opts
9
+ opts = dynamic_value.extract_options!
10
+ # we always dynamic value to be an Array, so we don't use the splat here
11
+ super(klass, current_scope, dynamic_value.flatten, opts)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,73 @@
1
+ require 'api_resource/associations/association_proxy'
2
+
3
+ module ApiResource
4
+
5
+ module Associations
6
+
7
+ class MultiObjectProxy < AssociationProxy
8
+
9
+ include Enumerable
10
+
11
+ def all
12
+ self.internal_object
13
+ end
14
+
15
+ def each(*args, &block)
16
+ self.internal_object.each(*args, &block)
17
+ end
18
+
19
+ def serializable_hash(options)
20
+ self.internal_object.collect{|obj| obj.serializable_hash(options) }
21
+ end
22
+
23
+ # force a load when calling this method
24
+ def internal_object
25
+ @internal_object ||= self.load_scope_with_options(:all, {})
26
+ end
27
+
28
+ def internal_object=(contents)
29
+ return @internal_object = contents if contents.all?{|o| o.is_a?(self.klass)}
30
+ return load(contents)
31
+ end
32
+
33
+ protected
34
+ def load_scope_with_options(scope, options)
35
+ scope = self.loaded_hash_key(scope.to_s, options)
36
+ return [] if self.remote_path.blank?
37
+ unless self.loaded[scope]
38
+ self.times_loaded += 1
39
+ self.loaded[scope] = self.load_from_remote(options)
40
+ end
41
+ self.loaded[scope].collect{|item| self.klass.new(item)}
42
+ end
43
+
44
+ def load(contents)
45
+ @internal_object = [] and return nil if contents.blank?
46
+ if contents.is_a?(Array) && contents.first.is_a?(Hash) && contents.first[self.class.remote_path_element]
47
+ settings = contents.slice!(0).with_indifferent_access
48
+ end
49
+
50
+ settings = contents.with_indifferent_access if contents.is_a?(Hash)
51
+ settings ||= {}.with_indifferent_access
52
+
53
+ raise "Invalid response for multi object relationship: #{contents}" unless settings[self.class.remote_path_element] || contents.is_a?(Array)
54
+ self.remote_path = settings.delete(self.class.remote_path_element)
55
+
56
+ settings.each do |key, value|
57
+ raise "Expected the scope #{key} to point to a hash, to #{value}" unless value.is_a?(Hash)
58
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
59
+ def #{key}(opts = {})
60
+ ApiResource::Associations::RelationScope.new(self, :#{key}, opts)
61
+ end
62
+ EOE
63
+ self.scopes[key.to_s] = value
64
+ end
65
+
66
+ # Create the internal object
67
+ @internal_object = contents.is_a?(Array) ? contents.collect{|item| self.klass.new(item)} : nil
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ end
@@ -0,0 +1,12 @@
1
+ module ApiResource
2
+ module Associations
3
+ # RelatedObjectHash, re-defines dup to be recursive
4
+ class RelatedObjectHash < HashWithIndifferentAccess
5
+ def dup
6
+ Marshal.load(Marshal.dump(self))
7
+ end
8
+ # use this behavior for clone too
9
+ alias_method :clone, :dup
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,30 @@
1
+ require 'api_resource/associations/scope'
2
+
3
+ module ApiResource
4
+
5
+ module Associations
6
+
7
+ class RelationScope < Scope
8
+
9
+ def reload
10
+ remove_instance_variable(:@internal_object) if instance_variable_defined?(:@internal_object)
11
+ self.klass.reload(self.current_scope, self.scopes[self.current_scope])
12
+ self
13
+ end
14
+
15
+ # Use this method to access the internal data, this guarantees that loading only occurs once per object
16
+ def internal_object
17
+ @internal_object ||= self.klass.send(:load_scope_with_options, self.current_scope, self.to_hash)
18
+ end
19
+
20
+ #
21
+ # class factory
22
+ def self.class_factory(hsh)
23
+ ApiResource::Associations::RelationScope
24
+ end
25
+
26
+ end
27
+
28
+ end
29
+
30
+ end
@@ -0,0 +1,34 @@
1
+ require 'api_resource/associations/scope'
2
+
3
+ module ApiResource
4
+
5
+ module Associations
6
+
7
+ class ResourceScope < Scope
8
+
9
+ include Enumerable
10
+
11
+ def internal_object
12
+ @internal_object ||= self.klass.send(:find, :all, :params => self.to_hash)
13
+ end
14
+
15
+ alias_method :all, :internal_object
16
+
17
+ def each(*args, &block)
18
+ self.internal_object.each(*args, &block)
19
+ end
20
+
21
+ # get the relevant class
22
+ def self.class_factory(hsh)
23
+ case hsh.values.first
24
+ when TrueClass, FalseClass
25
+ ApiResource::Associations::ResourceScope
26
+ when Array
27
+ ApiResource::Associations::MultiArgumentResourceScope
28
+ else
29
+ ApiResource::Associations::DynamicResourceScope
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,107 @@
1
+ module ApiResource
2
+
3
+ module Associations
4
+
5
+ class Scope
6
+
7
+ attr_accessor :klass, :current_scope, :internal_object
8
+
9
+ attr_reader :scopes
10
+
11
+ def initialize(klass, current_scope, opts = {})
12
+ # Holds onto the association proxy this RelationScope is bound to
13
+ @klass = klass
14
+ @parent = opts.delete(:parent)
15
+ # splits on _and_ and sorts to get a consistent scope key order
16
+ @current_scope = (self.parent_scope + Array.wrap(current_scope.to_s)).sort
17
+ # define methods for the scopes of the object
18
+
19
+ klass.scopes.each do |key, val|
20
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
21
+ # This class always has at least one scope, adding a new one should clone this object
22
+ def #{key}(*args)
23
+ obj = self.clone
24
+ # Call reload to make it go back to the webserver the next time it loads
25
+ obj.reload
26
+ return obj.enhance_current_scope(:#{key}, *args)
27
+ end
28
+ EOE
29
+ self.scopes[key.to_s] = val
30
+ end
31
+ # Use the method current scope because it gives a string
32
+ # This expression substitutes the options from opts into the default attributes of the scope, it will only copy keys that exist in the original
33
+ 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}
34
+ end
35
+
36
+ # Use this method to access the internal data, this guarantees that loading only occurs once per object
37
+ def internal_object
38
+ raise "Not Implemented: This method must be implemented in a subclass"
39
+ end
40
+
41
+ def scopes
42
+ @scopes ||= {}.with_indifferent_access
43
+ end
44
+
45
+ def scope?(scp)
46
+ self.scopes.key?(scp.to_s)
47
+ end
48
+
49
+ def current_scope
50
+ ActiveSupport::StringInquirer.new(@current_scope.join("_and_").concat("_scope"))
51
+ end
52
+
53
+ def to_hash
54
+ self.parent_hash.merge(self.scopes[self.current_scope])
55
+ end
56
+
57
+ # gets the current hash and calls to_query on it
58
+ def to_query
59
+ self.to_hash.to_query
60
+ end
61
+
62
+ def method_missing(method, *args, &block)
63
+ self.internal_object.send(method, *args, &block)
64
+ end
65
+
66
+ def reload
67
+ remove_instance_variable(:@internal_object) if instance_variable_defined?(:@internal_object)
68
+ self
69
+ end
70
+
71
+ def to_s
72
+ self.internal_object.to_s
73
+ end
74
+
75
+ def inspect
76
+ self.internal_object.inspect
77
+ end
78
+
79
+ def blank?
80
+ self.internal_object.blank?
81
+ end
82
+ alias_method :empty?, :blank?
83
+
84
+ protected
85
+ # scope from the parent
86
+ def parent_scope
87
+ ret = @parent ? Array.wrap(@parent.current_scope).collect{|el| el.gsub(/_scope$/,'')} : []
88
+ ret.collect{|el| el.split(/_and_/)}.flatten
89
+ end
90
+ # querystring hash from parent
91
+ def parent_hash
92
+ @parent ? @parent.to_hash : {}
93
+ end
94
+ def enhance_current_scope(scp, *args)
95
+ opts = args.extract_options!
96
+ check_scope(scp)
97
+ self.class.class_factory(self.scopes[scp]).new(self.klass, scp, *args, opts.merge(:parent => self))
98
+ end
99
+ # make sure we have a valid scope
100
+ def check_scope(scp)
101
+ raise ArgumentError, "Unknown scope #{scp}" unless self.scope?(scp.to_s)
102
+ end
103
+ end
104
+
105
+ end
106
+
107
+ end
@@ -0,0 +1,81 @@
1
+ require 'api_resource/associations/association_proxy'
2
+
3
+ module ApiResource
4
+
5
+ module Associations
6
+
7
+ class SingleObjectProxy < AssociationProxy
8
+
9
+ def serializable_hash(options = {})
10
+ return if self.internal_object.nil?
11
+ self.internal_object.serializable_hash(options)
12
+ end
13
+
14
+ def internal_object=(contents)
15
+ return @internal_object = contents if contents.is_a?(self.klass)
16
+ return @internal_object = contents if contents.respond_to?(:internal_object) && contents.internal_object.is_a?(self.klass)
17
+ return load(contents)
18
+ end
19
+
20
+ def blank?
21
+ return @internal_object.blank?
22
+ end
23
+
24
+ def present?
25
+ return @internal_object.present?
26
+ end
27
+
28
+ protected
29
+ def load_scope_with_options(scope, options)
30
+ scope = self.loaded_hash_key(scope.to_s, options)
31
+ # If the service uri is blank you can't load
32
+ return nil if self.remote_path.blank?
33
+ unless self.loaded[scope]
34
+ self.times_loaded += 1
35
+ self.loaded[scope] = self.load_from_remote(options)
36
+ end
37
+ self.klass.new(self.loaded[scope])
38
+ end
39
+
40
+ def load(contents)
41
+ # If we get something nil this should just behave like nil
42
+ return if contents.nil?
43
+ # if we get an array with a length of one, make it a hash
44
+ if contents.is_a?(self.class)
45
+ contents = contents.internal_object.serializable_hash
46
+ elsif contents.is_a?(Array) && contents.length == 1
47
+ contents = contents.first
48
+ end
49
+ raise "Expected an attributes hash got #{contents}" unless contents.is_a?(Hash)
50
+ contents = contents.with_indifferent_access
51
+ # If we don't have a 'service_uri' just assume that these are all attributes and make an object
52
+ return @internal_object = self.klass.new(contents) unless contents[self.class.remote_path_element]
53
+ # allow for symbols vs strings with these elements
54
+ self.remote_path = contents.delete(self.class.remote_path_element)
55
+ # There's only one hash here so it's hard to distinguish attributes from scopes, the key scopes_only says everything
56
+ # in this hash is a scope
57
+ no_attrs = (contents.delete(:scopes_only) || false)
58
+ attrs = {}
59
+ contents.each do |key, val|
60
+ # if this key is an attribute add it to attrs, warn if we've set scopes_only
61
+ if self.klass.attribute_names.include?(key.to_sym) && !no_attrs
62
+ attrs[key] = val
63
+ else
64
+ warn("#{key} is an attribute of #{self.klass}, beware of name collisions") if no_attrs && self.klass.attribute_names.include?(key)
65
+ raise "Expected the scope #{key} to have a hash for a value, got #{val}" unless val.is_a?(Hash)
66
+ self.instance_eval <<-EOE, __FILE__, __LINE__ + 1
67
+ def #{key}(opts = {})
68
+ ApiResource::Associations::RelationScope.new(self, :#{key}, opts)
69
+ end
70
+ EOE
71
+ self.scopes[key.to_s] = val
72
+ end
73
+ end
74
+ @internal_object = attrs.present? ? self.klass.new(attrs) : nil
75
+ end
76
+
77
+ end
78
+
79
+ end
80
+
81
+ end
@@ -0,0 +1,162 @@
1
+ module ApiResource
2
+
3
+ module Attributes
4
+
5
+ extend ActiveSupport::Concern
6
+ include ActiveModel::AttributeMethods
7
+ include ActiveModel::Dirty
8
+
9
+ included do
10
+
11
+ alias_method_chain :save, :dirty_tracking
12
+
13
+ class_inheritable_accessor :attribute_names, :public_attribute_names, :protected_attribute_names
14
+
15
+ attr_reader :attributes
16
+
17
+ self.attribute_names = []
18
+ self.public_attribute_names = []
19
+ self.protected_attribute_names = []
20
+
21
+ define_method(:attributes) do
22
+ return @attributes if @attributes
23
+ # Otherwise make the attributes hash of all the attributes
24
+ @attributes = HashWithIndifferentAccess.new
25
+ self.class.attribute_names.each do |attr|
26
+ @attributes[attr] = self.send("#{attr}")
27
+ end
28
+ @attributes
29
+ end
30
+
31
+ end
32
+
33
+ module ClassMethods
34
+
35
+ def define_attributes(*args)
36
+ # This is provided by ActiveModel::AttributeMethods, it should define the basic methods
37
+ # but we need to override all the setters so we do dirty tracking
38
+ define_attribute_methods args
39
+ args.each do |arg|
40
+ self.attribute_names << arg.to_sym
41
+ self.public_attribute_names << arg.to_sym
42
+
43
+ # Override the setter for dirty tracking
44
+ self.class_eval <<-EOE, __FILE__, __LINE__ + 1
45
+ def #{arg}
46
+ self.attributes[:#{arg}]
47
+ end
48
+
49
+ def #{arg}=(val)
50
+ #{arg}_will_change! unless self.#{arg} == val
51
+ self.attributes[:#{arg}] = val
52
+ end
53
+
54
+ def #{arg}?
55
+ self.attributes[:#{arg}].present?
56
+ end
57
+ EOE
58
+ end
59
+ self.attribute_names.uniq!
60
+ self.public_attribute_names.uniq!
61
+ end
62
+
63
+ def define_protected_attributes(*args)
64
+ define_attribute_methods args
65
+ args.each do |arg|
66
+ self.attribute_names << arg.to_sym
67
+ self.protected_attribute_names << arg.to_sym
68
+
69
+ # These attributes cannot be set, throw an error if you try
70
+ self.class_eval <<-EOE, __FILE__, __LINE__ + 1
71
+
72
+ def #{arg}
73
+ self.attributes[:#{arg}]
74
+ end
75
+
76
+ def #{arg}=(val)
77
+ raise "#{arg} is a protected attribute and cannot be set"
78
+ end
79
+
80
+ def #{arg}?
81
+ self.attributes[:#{arg}].present?
82
+ end
83
+ EOE
84
+ end
85
+ self.attribute_names.uniq!
86
+ self.protected_attribute_names.uniq!
87
+ end
88
+
89
+ def attribute?(name)
90
+ self.attribute_names.include?(name.to_sym)
91
+ end
92
+
93
+ def protected_attribute?(name)
94
+ self.protected_attribute_names.include?(name.to_sym)
95
+ end
96
+
97
+ def clear_attributes
98
+ self.attribute_names.clear
99
+ self.public_attribute_names.clear
100
+ self.protected_attribute_names.clear
101
+ end
102
+ end
103
+
104
+ module InstanceMethods
105
+
106
+ # set new attributes
107
+ def attributes=(new_attrs)
108
+ new_attrs.each_pair do |k,v|
109
+ self.send("#{k}=",v) unless k.to_sym == :id
110
+ end
111
+ new_attrs
112
+ end
113
+
114
+ def save_with_dirty_tracking(*args)
115
+ if save_without_dirty_tracking(*args)
116
+ @previously_changed = self.changes
117
+ @changed_attributes.clear
118
+ return true
119
+ else
120
+ return false
121
+ end
122
+ end
123
+
124
+ def set_attributes_as_current(*attrs)
125
+ @changed_attributes.clear and return if attrs.blank?
126
+ attrs.each do |attr|
127
+ @changed_attributes.delete(attr.to_s)
128
+ end
129
+ end
130
+
131
+ def reset_attribute_changes(*attrs)
132
+ attrs = self.class.public_attribute_names if attrs.blank?
133
+ attrs.each do |attr|
134
+ self.send("reset_#{attr}!")
135
+ end
136
+
137
+ set_attributes_as_current(*attrs)
138
+ end
139
+
140
+ def attribute?(name)
141
+ self.class.attribute?(name)
142
+ end
143
+
144
+ def protected_attribute?(name)
145
+ self.class.protected_attribute?(name)
146
+ end
147
+
148
+ def respond_to?(sym)
149
+ if sym =~ /\?$/
150
+ return true if self.attribute?($`)
151
+ elsif sym =~ /=$/
152
+ return true if self.class.public_attribute_names.include?($`)
153
+ else
154
+ return true if self.attribute?(sym.to_sym)
155
+ end
156
+ super
157
+ end
158
+ end
159
+
160
+ end
161
+
162
+ end