api_resource 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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,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