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,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