remotable 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -42,7 +42,9 @@ Specify the attributes of the local model that you want to keep in sync with the
42
42
  :id => :remote_id
43
43
  end
44
44
 
45
- By default Remotable assumes that the local model and remote model are joined with the connection you would express in SQL this way: `local_model INNER JOIN remote_model ON local_model.id=remote_model.id`. But it is generally impractical to join on `local_model.id`.
45
+ ### Remote Keys
46
+
47
+ By default Remotable assumes that the local model and remote model are joined with the connection you might express in SQL this way: `local_model INNER JOIN remote_model ON local_model.id=remote_model.id`. But it is generally impractical to join on `local_model.id`.
46
48
 
47
49
  If you specify `attr_remote :id => :remote_id`, then the join will be on `local_model.remote_id=remote_model.id`, but you can also use a different attribute as the join key:
48
50
 
@@ -56,17 +58,39 @@ If you specify `attr_remote :id => :remote_id`, then the join will be on `local_
56
58
 
57
59
  Now, the join could be expressed this way: `local_model.slug=remote_model.slug`.
58
60
 
61
+ If you must look up a remote model with more than one attribute, you can express a composite key this way:
62
+
63
+ class Event < ActiveRecord::Base
64
+ remote_model RemoteEvent
65
+ attr_remote :calendar_id,
66
+ :id => :remote_id
67
+ remote_key [:calendar_id, :remote_id]
68
+ end
69
+
59
70
  ### Finders
60
71
 
61
- For all of the local attributes that you've remoted (`slug`, `name`, and `remote_id` in the example above), the remoted model will respond to custom finders (e.g. `find_by_slug`, `find_by_name`, and `find_by_remote_id`). These finders will _first_ look in the local database for the requested record and, if it isn't found, look for the resource remotely. If a finder finds the resource remotely, it creates a local copy and returns that.
72
+ For `:id` or whatever you chose to be the remote key, Remotable will create a finder method on the ActiveRecord model. These finders will _first_ look in the local database for the requested record and, if it isn't found, look for the resource remotely. If a finder finds the resource remotely, it creates a local copy and returns that.
62
73
 
63
- Without any further configuration, Remotable will assume the route to for the customer finder from the attribute. For example,
74
+ You can create additional finder with the `find_by` method:
64
75
 
65
- find_by_id(...) # Looks in api_path/tenants/:id
66
- find_by_slug(...) # Looks in api_path/tenants/by_slug/:slug
67
- find_by_customer_name(...) # Looks in api_path/tenants/by_customer_name/:customer_name
76
+ class Tenant < ActiveRecord::Base
77
+ remote_model RemoteTenant
78
+ attr_remote :slug,
79
+ :customer_name => :name,
80
+ :id => :remote_id
81
+ find_by :slug
82
+ find_by :name
83
+ end
68
84
 
69
- You can specify a custom path with the `find_by` method or you can always write an appropriate `find_by_whatever` method on your remote resource.
85
+ Remotable will create the following methods and assume the URI for the custom finders from the attribute. The example above will create the following methods:
86
+
87
+ find_by_remote_id(...) # Looks in api_path/tenants/:id
88
+ find_by_slug(...) # Looks in api_path/tenants/by_slug/:slug
89
+ find_by_name(...) # Looks in api_path/tenants/by_name/:name
90
+
91
+ Note that the finder methods are named with the _local_ attributes.
92
+
93
+ You can specify a custom path with the `find_by` method:
70
94
 
71
95
  class Tenant < ActiveRecord::Base
72
96
  remote_model RemoteTenant
@@ -76,6 +100,7 @@ You can specify a custom path with the `find_by` method or you can always write
76
100
  find_by :name, :path => "by_nombre/:name"
77
101
  end
78
102
 
103
+
79
104
  When you use `find_by`, give the name of the _local_ attribute not the remote one (if they differ). Also, the name of the symbolic part of the path should match the local attribute name as well.
80
105
 
81
106
  ### Expiration
@@ -91,6 +116,21 @@ Whenever a remoted record is instantiated, Remotable checks the value of its `ex
91
116
 
92
117
  Remotable checks class you hand to `remote_model` to see what it inherits from. Remotable checks this to use the correct adapter with the remote model. Currently, the only adapter included in Remotable is for ActiveResource::Base.
93
118
 
119
+ ### Custom Backends / Adapters
120
+
121
+ You can write your own backends for Remotable. Just hand `remote_model` an object which responds to two methods: `new_resource` and `find_by`.
122
+
123
+ * `new_resource` should take 0 arguments and return an uninitialized object which represents a remote resource.
124
+ * `find_by` &mdash; either `find_by(path)` or `find_by(remote_attr, value)` &mdash; should take either 1 or 2 arguments. If it takes 1 argument, it will be passed the relative path of a remote resource. If it takes 2 arguments, it will be passed an attribute name and value to be used to look up a remote resource. `find_by` should return either a single remote resource or nil (if none could be found).
125
+
126
+ The instances of a remote resource must also respond to certain methods. Instance should respond to:
127
+
128
+ * `save` (return true if successful, false if not)
129
+ * `destroy`
130
+ * `errors` (a hash of errors to be populated by an unsuccessful save)
131
+ * the getters and setters for all attributes which will be synchronized remotely
132
+
133
+
94
134
  ## Development
95
135
 
96
136
  ### To Do
@@ -1,5 +1,7 @@
1
1
  require "remotable/version"
2
2
  require "remotable/nosync"
3
+ require "remotable/validate_models"
4
+ require "remotable/with_remote_model_proxy"
3
5
 
4
6
 
5
7
  # Remotable keeps a locally-stored ActiveRecord
@@ -30,22 +32,116 @@ require "remotable/nosync"
30
32
  #
31
33
  module Remotable
32
34
  extend Nosync
35
+ extend ValidateModels
33
36
 
37
+ # By default, Remotable will validate the models you
38
+ # supply it via +remote_model+. You can set validate_models
39
+ # to false to skip this validation. It is recommended that
40
+ # you keep validation on in development and test environments,
41
+ # but turn it off in production.
42
+ self.validate_models = true
34
43
 
35
44
 
45
+ # == remote_model( model [optional] )
46
+ #
47
+ # When called without arguments, this method returns
48
+ # the remote model connected to this local ActiveRecord
49
+ # model.
50
+ #
51
+ # When called with an argument, it extends the ActiveRecord
52
+ # model on which it is called.
53
+ #
54
+ # <tt>model</tt> can be a class that inherits from any
55
+ # of these API consumers:
56
+ #
57
+ # * ActiveResource
58
+ #
59
+ # <tt>model</tt> can be any object that responds
60
+ # to these two methods for getting a resource:
61
+ #
62
+ # * +find_by(path)+ or +find_by(remote_attr, value)+
63
+ # +find_by+ can be defined to take either one argument or two.
64
+ # If it takes one argument, it will be passed path.
65
+ # If it takes two, it will be passed remote_attr and value.
66
+ # * +new_resource+
67
+ #
68
+ # Resources must respond to:
69
+ #
70
+ # * +save+ (return true on success and false on failure)
71
+ # * +destroy+
72
+ # * +errors+ (returning a hash of error messages by attribute)
73
+ # * getters and setters for each attribute
74
+ #
36
75
  def remote_model(*args)
37
76
  if args.any?
38
77
  @remote_model = args.first
39
78
 
40
- require "remotable/active_record_extender"
41
- include Remotable::ActiveRecordExtender
79
+ @__remotable_included ||= begin
80
+ require "remotable/active_record_extender"
81
+ include Remotable::ActiveRecordExtender
82
+ true
83
+ end
84
+
85
+ extend_remote_model(@remote_model)
86
+ end
87
+ @remote_model
88
+ end
89
+
90
+
91
+
92
+ def with_remote_model(model)
93
+ if block_given?
94
+ begin
95
+ original = self.remote_model
96
+ self.remote_model(model)
97
+ yield
98
+ ensure
99
+ self.remote_model(original)
100
+ end
42
101
  else
43
- @remote_model
102
+ WithRemoteModelProxy.new(self, model)
44
103
  end
45
104
  end
46
105
 
47
106
 
48
107
 
108
+ REQUIRED_CLASS_METHODS = [:find_by, :new_resource]
109
+ REQUIRED_INSTANCE_METHODS = [:save, :errors, :destroy]
110
+
111
+ class InvalidRemoteModel < ArgumentError; end
112
+
113
+
114
+
115
+ private
116
+
117
+ def extend_remote_model(remote_model)
118
+ if remote_model.is_a?(Class) and (remote_model < ActiveResource::Base)
119
+ require "remotable/adapters/active_resource"
120
+ remote_model.send(:include, Remotable::Adapters::ActiveResource)
121
+
122
+ #
123
+ # Adapters for other API consumers can be implemented here
124
+ #
125
+
126
+ else
127
+ assert_that_remote_model_meets_api_requirements!(remote_model) if Remotable.validate_models?
128
+ end
129
+ end
130
+
131
+ def assert_that_remote_model_meets_api_requirements!(model)
132
+ unless model.respond_to_all?(REQUIRED_CLASS_METHODS)
133
+ raise InvalidRemoteModel,
134
+ "#{model} cannot be used as a remote model with Remotable " <<
135
+ "because it does not define these methods: #{model.does_not_respond_to(REQUIRED_CLASS_METHODS).join(", ")}."
136
+ end
137
+ instance = model.new_resource
138
+ unless instance.respond_to_all?(REQUIRED_INSTANCE_METHODS)
139
+ raise InvalidRemoteModel,
140
+ "#{instance.class} cannot be used as a remote resource with Remotable " <<
141
+ "because it does not define these methods: #{instance.does_not_respond_to(REQUIRED_INSTANCE_METHODS).join(", ")}."
142
+ end
143
+ end
144
+
49
145
  end
50
146
 
51
147
 
@@ -1,5 +1,6 @@
1
1
  require "remotable/core_ext"
2
2
  require "active_support/concern"
3
+ require "active_support/core_ext/array/wrap"
3
4
 
4
5
 
5
6
  module Remotable
@@ -22,12 +23,9 @@ module Remotable
22
23
 
23
24
  validates_presence_of :expires_at
24
25
 
25
- default_remote_attributes = column_names - %w{id created_at updated_at expires_at}
26
- @remote_attribute_map = default_remote_attributes.map_to_self
27
- @remote_attribute_routes = {}
28
- @expires_after = 1.day
29
-
30
- extend_remote_model
26
+ @remote_attribute_map ||= default_remote_attributes.map_to_self
27
+ @local_attribute_routes ||= {}
28
+ @expires_after ||= 1.day
31
29
  end
32
30
 
33
31
 
@@ -39,24 +37,44 @@ module Remotable
39
37
  Remotable.nosync? || super
40
38
  end
41
39
 
40
+ # Sets the key with which a resource is identified remotely.
41
+ # If no remote key is set, the remote key is assumed to be :id.
42
+ # Which could be explicitly set like this:
43
+ #
44
+ # remote_key :id
45
+ #
46
+ # It can can be a composite key:
47
+ #
48
+ # remote_key [:calendar_id, :id]
49
+ #
50
+ # You can also supply a path for the remote key which will
51
+ # be passed to +fetch_with+:
52
+ #
53
+ # remote_key [:calendar_id, :id], :path => "calendars/:calendar_id/events/:id"
54
+ #
42
55
  def remote_key(*args)
43
56
  if args.any?
44
- remote_key = args.first
45
- raise("#{remote_key} is not the name of a remote attribute") unless remote_attribute_names.member?(remote_key)
57
+ remote_key = args.shift
58
+ options = args.shift || {}
59
+
60
+ # remote_key may be a composite of several attributes
61
+ # ensure that all of the attributs have been defined
62
+ Array.wrap(remote_key).each do |attribute|
63
+ raise(":#{attribute} is not the name of a remote attribute") unless remote_attribute_names.member?(attribute)
64
+ end
65
+
66
+ # Set up a finder method for the remote_key
67
+ fetch_with(local_key(remote_key), options)
68
+
46
69
  @remote_key = remote_key
47
- fetch_with(remote_key)
48
- remote_key
49
70
  else
50
71
  @remote_key || generate_default_remote_key
51
72
  end
52
73
  end
53
74
 
54
75
  def expires_after(*args)
55
- if args.any?
56
- @expires_after = args.first
57
- else
58
- @expires_after
59
- end
76
+ @expires_after = args.first if args.any?
77
+ @expires_after
60
78
  end
61
79
 
62
80
  def attr_remote(*attrs)
@@ -64,23 +82,29 @@ module Remotable
64
82
  map = attrs.map_to_self.merge(map)
65
83
  @remote_attribute_map = map
66
84
 
67
- @remote_attribute_routes = {}
68
- fetch_with(*local_attribute_names)
85
+ assert_that_remote_resource_responds_to_remote_attributes!(remote_model) if Remotable.validate_models?
86
+
87
+ # Reset routes
88
+ @local_attribute_routes = {}
69
89
  end
70
90
 
71
- def fetch_with(*local_keys)
72
- remote_keys_and_routes = extract_remote_keys_and_routes(*local_keys)
73
- @remote_attribute_routes.merge!(remote_keys_and_routes)
91
+ def fetch_with(local_key, options={})
92
+ @local_attribute_routes.merge!(local_key => options[:path])
74
93
  end
75
94
  alias :find_by :fetch_with
76
95
 
77
96
 
78
97
 
79
98
  attr_reader :remote_attribute_map,
80
- :remote_attribute_routes
99
+ :local_attribute_routes
81
100
 
82
- def local_key
83
- local_attribute_name(remote_key)
101
+ def local_key(remote_key=nil)
102
+ remote_key ||= self.remote_key
103
+ if remote_key.is_a?(Array)
104
+ remote_key.map(&method(:local_attribute_name))
105
+ else
106
+ local_attribute_name(remote_key)
107
+ end
84
108
  end
85
109
 
86
110
  def remote_attribute_names
@@ -99,17 +123,18 @@ module Remotable
99
123
  remote_attribute_map[remote_attr] || remote_attr
100
124
  end
101
125
 
102
- def route_for(local_key)
103
- remote_key = remote_attribute_name(local_key)
104
- remote_attribute_routes[remote_key] || default_route_for(local_key, remote_key)
126
+ def route_for(remote_key)
127
+ local_key = self.local_key(remote_key)
128
+ local_attribute_routes[local_key] || default_route_for(local_key, remote_key)
105
129
  end
106
130
 
107
131
  def default_route_for(local_key, remote_key=nil)
132
+ puts "local_key: #{local_key}; remote_key: #{remote_key}"
108
133
  remote_key ||= remote_attribute_name(local_key)
109
134
  if remote_key.to_s == primary_key
110
135
  ":#{local_key}"
111
136
  else
112
- "by_#{remote_key}/:#{local_key}"
137
+ "by_#{local_key}/:#{local_key}"
113
138
  end
114
139
  end
115
140
 
@@ -128,26 +153,55 @@ module Remotable
128
153
 
129
154
 
130
155
 
156
+ def respond_to?(method_sym, include_private=false)
157
+ return true if recognize_remote_finder_method(method_sym)
158
+ super(method_sym, include_private)
159
+ end
160
+
131
161
  def method_missing(method_sym, *args, &block)
132
- method_name = method_sym.to_s
133
-
134
- if method_name =~ /find_by_([^!]*)(!?)/
135
- local_attr, bang, value = $1.to_sym, !$2.blank?, args.first
136
- remote_attr = remote_attribute_name(local_attr)
137
-
138
- remote_key # Make sure we've figured out the remote
139
- # primary key if we're evaluating a finder
162
+ method_details = recognize_remote_finder_method(method_sym)
163
+ if method_details
164
+ local_attributes = method_details[:local_attributes]
165
+ values = args
140
166
 
141
- if remote_attribute_routes.key?(remote_attr)
142
- local_resource = where(local_attr => value).first ||
143
- fetch_by(remote_attr, value)
144
-
145
- raise ActiveRecord::RecordNotFound if local_resource.nil? && bang
146
- return local_resource
167
+ unless values.length == local_attributes.length
168
+ raise ArgumentError, "#{method_sym} was called with #{values.length} but #{local_attributes.length} was expected"
147
169
  end
170
+
171
+ local_resource = ((0...local_attributes.length).inject(scoped) do |scope, i|
172
+ scope.where(local_attributes[i] => values[i])
173
+ end).first || fetch_by(method_details[:remote_key], *values)
174
+
175
+ raise ActiveRecord::RecordNotFound if local_resource.nil? && (method_sym =~ /!$/)
176
+ local_resource
177
+ else
178
+ super(method_sym, *args, &block)
179
+ end
180
+ end
181
+
182
+ # If the missing method IS a Remotable finder method,
183
+ # returns the remote key (may be a composite key).
184
+ # Otherwise, returns false.
185
+ def recognize_remote_finder_method(method_sym)
186
+ method_name = method_sym.to_s
187
+ return false unless method_name =~ /find_by_([^!]*)(!?)/
188
+
189
+ local_attributes = $1.split("_and_").map(&:to_sym)
190
+ remote_attributes = local_attributes.map(&method(:remote_attribute_name))
191
+
192
+ local_key, remote_key = if local_attributes.length == 1
193
+ [local_attributes[0], remote_attributes[0]]
194
+ else
195
+ [local_attributes, remote_attributes]
148
196
  end
149
197
 
150
- super(method_sym, *args, &block)
198
+ generate_default_remote_key # <- Make sure we've figured out the remote
199
+ # primary key if we're evaluating a finder
200
+
201
+ return false unless local_attribute_routes.key?(local_key)
202
+
203
+ { :local_attributes => local_attributes,
204
+ :remote_key => remote_key }
151
205
  end
152
206
 
153
207
 
@@ -161,56 +215,90 @@ module Remotable
161
215
  # Looks the resource up remotely, by the given attribute
162
216
  # If the resource is found, wraps it in a new local resource
163
217
  # and returns that.
164
- def fetch_by(remote_attr, value)
165
- remote_resource = remote_model.find_by(remote_attr, value)
218
+ def fetch_by(remote_attr, *values)
219
+ remote_resource = find_remote_resource_by(remote_attr, *values)
166
220
  remote_resource && new_from_remote(remote_resource)
167
221
  end
168
222
 
223
+ # Looks the resource up remotely;
224
+ # Returns the remote resource.
225
+ def find_remote_resource_by(remote_attr, *values)
226
+ find_by = remote_model.method(:find_by)
227
+ case find_by.arity
228
+ when 1; find_by.call(remote_path_for(remote_attr, *values))
229
+ when 2; find_by.call(remote_attr, *values)
230
+ else
231
+ raise InvalidRemoteModel, "#{remote_model}.find_by should take either 1 or 2 parameters"
232
+ end
233
+ end
234
+
235
+ def remote_path_for(remote_key, *values)
236
+ route = route_for(remote_key)
237
+ local_key = self.local_key(remote_key)
238
+
239
+ if remote_key.is_a?(Array)
240
+ remote_path_for_composite_key(route, local_key, values)
241
+ else
242
+ remote_path_for_simple_key(route, local_key, values.first)
243
+ end
244
+ end
245
+
246
+ def remote_path_for_simple_key(route, local_key, value)
247
+ route.gsub(/:#{local_key}/, value.to_s)
248
+ end
249
+
250
+ def remote_path_for_composite_key(route, local_key, values)
251
+ unless values.length == local_key.length
252
+ raise ArgumentError, "local_key has #{local_key.length} attributes but values has #{values.length}"
253
+ end
254
+
255
+ (0...values.length).inject(route) do |route, i|
256
+ route.gsub(/:#{local_key[i]}/, values[i].to_s)
257
+ end
258
+ end
259
+
169
260
 
170
261
 
171
262
  private
172
263
 
173
264
 
174
265
 
175
- def extend_remote_model
176
- if remote_model < ActiveResource::Base
177
- require "remotable/adapters/active_resource"
178
- remote_model.send(:include, Remotable::Adapters::ActiveResource)
179
- remote_model.local_model = self
180
-
181
- # !todo
182
- # Adapters for other API consumers can be implemented here
183
- #
184
-
185
- else
186
- raise("#{remote_model} is not a recognized remote resource")
187
- end
266
+ def default_remote_attributes
267
+ column_names - %w{id created_at updated_at expires_at}
188
268
  end
189
269
 
190
270
 
191
- def extract_remote_keys_and_routes(*local_keys)
192
- keys_and_routes = local_keys.extract_options!
193
- {}.tap do |hash|
194
- local_keys.each {|local_key| hash[remote_attribute_name(local_key)] = nil}
195
- keys_and_routes.each {|local_key, value| hash[remote_attribute_name(local_key)] = value}
271
+
272
+ def assert_that_remote_resource_responds_to_remote_attributes!(model)
273
+ # Skip this for ActiveResource because it won't define a method until it has
274
+ # loaded an JSON for that method
275
+ return if model.is_a?(Class) and (model < ActiveResource::Base)
276
+
277
+ instance = model.new_resource
278
+ attr_getters_and_setters = remote_attribute_names + remote_attribute_names.map {|attr| :"#{attr}="}
279
+ unless instance.respond_to_all?(attr_getters_and_setters)
280
+ raise InvalidRemoteModel,
281
+ "#{instance.class} does not respond to getters and setters " <<
282
+ "for each remote attribute (not implemented: #{instance.does_not_respond_to(attr_getters_and_setters).sort.join(", ")}).\n"
196
283
  end
197
284
  end
198
285
 
199
286
 
287
+
200
288
  def generate_default_remote_key
289
+ return @remote_key if @remote_key
201
290
  raise("No remote key supplied and :id is not a remote attribute") unless remote_attribute_names.member?(:id)
202
291
  remote_key(:id)
203
292
  end
204
293
 
205
294
 
295
+
206
296
  def new_from_remote(remote_resource)
207
297
  record = self.new
208
298
  record.instance_variable_set(:@remote_resource, remote_resource)
209
299
  record.pull_remote_data!
210
300
  end
211
301
 
212
-
213
-
214
302
  end
215
303
 
216
304
 
@@ -224,6 +312,7 @@ module Remotable
224
312
  :local_attribute_names,
225
313
  :local_attribute_name,
226
314
  :expires_after,
315
+ :find_remote_resource_by,
227
316
  :to => "self.class"
228
317
 
229
318
  def expired?
@@ -256,7 +345,10 @@ module Remotable
256
345
 
257
346
  def fetch_remote_resource
258
347
  fetch_value = self[local_key]
259
- remote_model.find_by(remote_key, fetch_value)
348
+ # puts "local_key", local_key.inspect, "",
349
+ # "remote_key", remote_key.inspect, "",
350
+ # "fetch_value", fetch_value
351
+ find_remote_resource_by(remote_key, fetch_value)
260
352
  end
261
353
 
262
354
  def merge_remote_data!(remote_resource)
@@ -285,7 +377,7 @@ module Remotable
285
377
  end
286
378
 
287
379
  def create_remote_resource
288
- @remote_resource = remote_model.new
380
+ @remote_resource = remote_model.new_resource
289
381
  merge_local_data(@remote_resource)
290
382
 
291
383
  if @remote_resource.save
@@ -323,8 +415,10 @@ module Remotable
323
415
 
324
416
 
325
417
  def merge_remote_errors(errors)
326
- errors.each do |attribute, message|
327
- self.errors[local_attribute_name(attribute)] = message
418
+ errors.each do |attribute, messages|
419
+ Array.wrap(messages).each do |message|
420
+ self.errors.add(local_attribute_name(attribute), message)
421
+ end
328
422
  end
329
423
  self
330
424
  end