remotely 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/.autotest +1 -0
- data/.gitignore +18 -0
- data/.rspec +0 -0
- data/Gemfile +2 -0
- data/README.md +70 -0
- data/Rakefile +6 -0
- data/lib/remotely.rb +91 -0
- data/lib/remotely/associations.rb +241 -0
- data/lib/remotely/collection.rb +75 -0
- data/lib/remotely/ext/url.rb +29 -0
- data/lib/remotely/http_methods.rb +205 -0
- data/lib/remotely/model.rb +317 -0
- data/lib/remotely/version.rb +3 -0
- data/remotely.gemspec +30 -0
- data/spec/remotely/associations_spec.rb +146 -0
- data/spec/remotely/collection_spec.rb +57 -0
- data/spec/remotely/ext/url_spec.rb +27 -0
- data/spec/remotely/http_methods_spec.rb +25 -0
- data/spec/remotely/model_spec.rb +368 -0
- data/spec/remotely_spec.rb +23 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/test_classes.rb +97 -0
- data/spec/support/webmock.rb +40 -0
- metadata +199 -0
data/.autotest
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'autotest/growl'
|
data/.gitignore
ADDED
data/.rspec
ADDED
File without changes
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# Remotely
|
2
|
+
|
3
|
+
Remotely lets you specify associations for your models that should
|
4
|
+
be fetched from a remote API instead of the database.
|
5
|
+
|
6
|
+
## App Setup
|
7
|
+
|
8
|
+
Apps are where Remotely goes to find association resources. You can define as many as you want, but if you define only one, you can omit the `:app` option from your associations.
|
9
|
+
|
10
|
+
Remotely.app :legsapp, "http://omgsomanylegs.com/api/v1"
|
11
|
+
|
12
|
+
## Defining Associations
|
13
|
+
|
14
|
+
`has_many_remote` takes two options, `:app` and `:path`. `:app` tells Remotely which registered app to fetch it from. `:path` tells it the URI to the object (everything after the app).
|
15
|
+
|
16
|
+
**One app & association name matches URI**
|
17
|
+
|
18
|
+
class Millepied < ActiveRecord::Base
|
19
|
+
has_many_remote :legs # => "/legs"
|
20
|
+
end
|
21
|
+
|
22
|
+
**One app & custom path**
|
23
|
+
|
24
|
+
class Millepied < ActiveRecord::Base
|
25
|
+
has_many_remote :legs, :path => "/store/legs"
|
26
|
+
end
|
27
|
+
|
28
|
+
**One app & custom path with `id` substitution**
|
29
|
+
|
30
|
+
class Millepied < ActiveRecord::Base
|
31
|
+
has_many_remote :legs, :path => "/millepieds/:id/legs"
|
32
|
+
end
|
33
|
+
|
34
|
+
**Multiple apps (all secondary conditions from above apply)**
|
35
|
+
|
36
|
+
class Millepied < ActiveRecord::Base
|
37
|
+
has_many_remote :legs, :app => :legsapp, ...
|
38
|
+
end
|
39
|
+
|
40
|
+
### id Substitution
|
41
|
+
|
42
|
+
A path can include "`:id`" anywhere in it, which is replaced by the instance's `id`. This is useful when the resource on the API end is namespaced. For example:
|
43
|
+
|
44
|
+
class Millepied < ActiveRecord::Base
|
45
|
+
has_many_remote :legs, :path => "/millepieds/:id/legs"
|
46
|
+
end
|
47
|
+
|
48
|
+
m = Millepied.new
|
49
|
+
m.id # => 1
|
50
|
+
m.legs # => Requests "/millepieds/1/legs"
|
51
|
+
|
52
|
+
## Fetched Objects
|
53
|
+
|
54
|
+
Remote associations are Remotely::Model objects. Whatever data the API returns, becomes the attributes of the Model.
|
55
|
+
|
56
|
+
m = Millepied.new
|
57
|
+
m.legs[0] # => #<Remotely::Model:0x0000f351c8 @attributes={:length=>"1mm"}>
|
58
|
+
m.legs[0].length # => "1mm"
|
59
|
+
|
60
|
+
### Fetched Object Associations
|
61
|
+
|
62
|
+
If a fetched object includes an attribute matching "\*_id", Remotely tries to find the model it is for and retrieve it.
|
63
|
+
|
64
|
+
leg = m.legs.first
|
65
|
+
leg.user_id # => 2
|
66
|
+
leg.user # => User.find(2)
|
67
|
+
|
68
|
+
## Contributing
|
69
|
+
|
70
|
+
Fork, branch and pull-request. Bump versions in their own commit.
|
data/Rakefile
ADDED
data/lib/remotely.rb
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
require "faraday"
|
3
|
+
require "active_support/inflector"
|
4
|
+
require "active_support/concern"
|
5
|
+
require "active_support/core_ext/hash"
|
6
|
+
require "active_model"
|
7
|
+
require "remotely/ext/url"
|
8
|
+
|
9
|
+
module Remotely
|
10
|
+
autoload :Collection, "remotely/collection"
|
11
|
+
autoload :Associations, "remotely/associations"
|
12
|
+
autoload :Model, "remotely/model"
|
13
|
+
autoload :HTTPMethods, "remotely/http_methods"
|
14
|
+
|
15
|
+
class RemotelyError < StandardError
|
16
|
+
def message; self.class::MESSAGE; end
|
17
|
+
end
|
18
|
+
|
19
|
+
class URLHostError < RemotelyError
|
20
|
+
MESSAGE = "URL object missing host"
|
21
|
+
end
|
22
|
+
|
23
|
+
class RemoteAppError < RemotelyError
|
24
|
+
MESSAGE = "No app specified for association with more than one app registered."
|
25
|
+
end
|
26
|
+
|
27
|
+
class HasManyForeignKeyError < RemotelyError
|
28
|
+
MESSAGE = "has_many associations can use the :foreign_key option."
|
29
|
+
end
|
30
|
+
|
31
|
+
class NonJsonResponseError < RemotelyError
|
32
|
+
MESSAGE = "Received an HTML response. Expected JSON."
|
33
|
+
attr_reader :body
|
34
|
+
|
35
|
+
def initialize(body)
|
36
|
+
@body = body
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class << self
|
41
|
+
# @return [Hash] Hash of registered apps (key: name, value: URL)
|
42
|
+
def apps
|
43
|
+
@apps ||= {}
|
44
|
+
end
|
45
|
+
|
46
|
+
# Configure applications to be used by models. Accepts a block
|
47
|
+
# which specifies multiple apps via the `app` method.
|
48
|
+
#
|
49
|
+
# @param [Proc] block Configuration block.
|
50
|
+
#
|
51
|
+
# @example Registers an app named :fun with a URL of "http://fun.com/api/"
|
52
|
+
# Remotely.configure do
|
53
|
+
# app :fun, "http://fun.com/api/"
|
54
|
+
# end
|
55
|
+
#
|
56
|
+
def configure(&block)
|
57
|
+
instance_eval(&block)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Register an application with Remotely.
|
61
|
+
#
|
62
|
+
# @param [Symbol] name Placeholder name for the application.
|
63
|
+
# @param [String] url URL to the application's API.
|
64
|
+
#
|
65
|
+
def app(name, url)
|
66
|
+
url = URI.parse(url)
|
67
|
+
apps[name] = { base: "#{url.scheme || "http"}://#{url.host}:#{url.port}", uri: url.path }
|
68
|
+
end
|
69
|
+
|
70
|
+
# Set the Basic Auth user and password to use when making
|
71
|
+
# requests.
|
72
|
+
#
|
73
|
+
# @param [String] user BasicAuth user
|
74
|
+
# @param [String] password BasicAuth password
|
75
|
+
#
|
76
|
+
def basic_auth(user=nil, password=nil)
|
77
|
+
user and password and @basic_auth = [user, password] or @basic_auth
|
78
|
+
end
|
79
|
+
|
80
|
+
# Clear all registered apps
|
81
|
+
#
|
82
|
+
def reset!
|
83
|
+
@apps = {}
|
84
|
+
@basic_auth = nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
module ActiveRecord
|
90
|
+
class Base; include Remotely::Associations end
|
91
|
+
end
|
@@ -0,0 +1,241 @@
|
|
1
|
+
module Remotely
|
2
|
+
module Associations
|
3
|
+
# A set class methods for defining associations that are retreived from
|
4
|
+
# a remote API. They're available to all classes which inherit from
|
5
|
+
# ActiveRecord::Base orRemotely::Model.
|
6
|
+
#
|
7
|
+
# class Show < ActiveRecord::Base
|
8
|
+
# has_many_remote :members
|
9
|
+
# has_one_remote :set
|
10
|
+
# belongs_to_remote :station
|
11
|
+
# end
|
12
|
+
#
|
13
|
+
# = Warning
|
14
|
+
#
|
15
|
+
# Just like with ActiveRecord, associations will overwrite any instance method
|
16
|
+
# with the same name as the association. So don't do that.
|
17
|
+
#
|
18
|
+
# = Cardinality and Defining Associations
|
19
|
+
#
|
20
|
+
# Remotely can be used to specify one-to-one and one-to-many associations.
|
21
|
+
# Many-to-many is not supported.
|
22
|
+
#
|
23
|
+
# Unlike ActiveRecord, remote associations are only defined on the client side.
|
24
|
+
# +has_many+ relations have no accompanying +belongs_to+.
|
25
|
+
#
|
26
|
+
# == One-to-many
|
27
|
+
#
|
28
|
+
# Use +has_many_remote+ to define a one-to-many relationship where the model
|
29
|
+
# you are defining it in is the parent.
|
30
|
+
#
|
31
|
+
# === URI Assumptions
|
32
|
+
#
|
33
|
+
# Remotely assumes all +has_many_remote+ associations can be found at:
|
34
|
+
#
|
35
|
+
# /model_name(plural)/id/association_name(plural)
|
36
|
+
#
|
37
|
+
# ==== Example
|
38
|
+
#
|
39
|
+
# class User < ActiveRecord::Base
|
40
|
+
# has_many_remote :friends
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# user = User.new(:id => 1)
|
44
|
+
# user.friends # => /users/1/friends
|
45
|
+
#
|
46
|
+
#
|
47
|
+
# == One-to-one
|
48
|
+
#
|
49
|
+
# Use +has_one_remote+ to define a one-to-one relationship.
|
50
|
+
#
|
51
|
+
# === URI Assumptions
|
52
|
+
#
|
53
|
+
# Remotely assumes all +has_one_remote+ associations can be found at:
|
54
|
+
#
|
55
|
+
# /model_name(plural)/id/association_name(singular)
|
56
|
+
#
|
57
|
+
# ==== Example
|
58
|
+
#
|
59
|
+
# class Car < ActiveRecord::Base
|
60
|
+
# has_one_remote :engine
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
# car = Car.new(:id => 1)
|
64
|
+
# car.engine # => /cars/1/engine
|
65
|
+
#
|
66
|
+
#
|
67
|
+
# == Many-to-one
|
68
|
+
#
|
69
|
+
# Use +belongs_to_remote+ to define a many-to-one relationship. That is, if the
|
70
|
+
# model you're defining this on has a foreign key to the remote model.
|
71
|
+
#
|
72
|
+
# === URI Assumptions
|
73
|
+
#
|
74
|
+
# Remotely assumes all +belongs_to_remote+ associations can be found at:
|
75
|
+
#
|
76
|
+
# /association_name(plural)/{association_name}_id
|
77
|
+
#
|
78
|
+
# ==== Example
|
79
|
+
#
|
80
|
+
# class Car < ActiveRecord::Base
|
81
|
+
# belongs_to_remote :brand
|
82
|
+
# end
|
83
|
+
#
|
84
|
+
# car = Car.new(:brand_id => 2)
|
85
|
+
# car.brand # => /brands/2
|
86
|
+
#
|
87
|
+
# == Options
|
88
|
+
#
|
89
|
+
# === +:path+
|
90
|
+
# The full URI that should be used to fetch this resource.
|
91
|
+
# (supported by all methods)
|
92
|
+
#
|
93
|
+
# === +:foreign_key+
|
94
|
+
# The attribute that should be used, instead of +id+ when generating URIs.
|
95
|
+
# (supported by +belongs_to_remote+ only)
|
96
|
+
#
|
97
|
+
# == Path Variables
|
98
|
+
#
|
99
|
+
# The +path+ option will replace any symbol(ish) looking string with the
|
100
|
+
# value of that attribute, of the model.
|
101
|
+
#
|
102
|
+
# class User < ActiveRecord::Base
|
103
|
+
# belongs_to_remote :family, :path => "/families/:family_key"
|
104
|
+
# end
|
105
|
+
#
|
106
|
+
# user = User.new(:family_key => "noble")
|
107
|
+
# user.family # => /families/noble
|
108
|
+
#
|
109
|
+
module ClassMethods
|
110
|
+
# Remote associations defined and their options.
|
111
|
+
attr_accessor :remote_associations
|
112
|
+
|
113
|
+
# Specifies a one-to-many relationship.
|
114
|
+
#
|
115
|
+
# @param [Symbol] name Name of the relationship
|
116
|
+
# @param [Hash] options Association configuration options.
|
117
|
+
# @option options [String] :path Path to the remote resource
|
118
|
+
#
|
119
|
+
def has_many_remote(name, options={})
|
120
|
+
define_association_method(:has_many, name, options)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Specifies a one-to-one relationship.
|
124
|
+
#
|
125
|
+
# @param [Symbol] name Name of the relationship
|
126
|
+
# @param [Hash] options Association configuration options.
|
127
|
+
# @option options [String] :path Path to the remote resource
|
128
|
+
#
|
129
|
+
def has_one_remote(name, options={})
|
130
|
+
define_association_method(:has_one, name, options)
|
131
|
+
end
|
132
|
+
|
133
|
+
# Specifies a many-to-one relationship.
|
134
|
+
#
|
135
|
+
# @param [Symbol] name Name of the relationship
|
136
|
+
# @param [Hash] options Association configuration options.
|
137
|
+
# @option options [String] :path Path to the remote resource
|
138
|
+
# @option options [Symbol, String] :foreign_key Attribute to be used
|
139
|
+
# in place of +id+ when constructing URIs.
|
140
|
+
#
|
141
|
+
def belongs_to_remote(name, options={})
|
142
|
+
define_association_method(:belongs_to, name, options)
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def define_association_method(type, name, options)
|
148
|
+
self.remote_associations ||= {}
|
149
|
+
self.remote_associations[name] = options.merge(type: type)
|
150
|
+
define_method(name) { |reload=false| call_association(reload, name) }
|
151
|
+
define_method(:"#{name}=") { |o| set_association(name, o) }
|
152
|
+
end
|
153
|
+
|
154
|
+
def inherited(base)
|
155
|
+
base.remote_associations = self.remote_associations
|
156
|
+
base.extend(ClassMethods)
|
157
|
+
base.extend(Remotely::HTTPMethods)
|
158
|
+
super
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def remote_associations
|
163
|
+
self.class.remote_associations ||= {}
|
164
|
+
end
|
165
|
+
|
166
|
+
def path_to(name, type)
|
167
|
+
opts = remote_associations[name]
|
168
|
+
raise HasManyForeignKeyError if opts[:foreign_key] && [:has_many, :has_one].include?(type)
|
169
|
+
|
170
|
+
base = self.class.base_class.model_name.element.pluralize
|
171
|
+
fkey = opts[:foreign_key] || :"#{name}_id"
|
172
|
+
path = opts[:path]
|
173
|
+
path = self.instance_exec(&path) if path.is_a?(Proc)
|
174
|
+
|
175
|
+
# :path option takes precedence
|
176
|
+
return interpolate URL(path) if path
|
177
|
+
|
178
|
+
singular_path = name.to_s.singularize
|
179
|
+
plural_path = name.to_s.pluralize
|
180
|
+
|
181
|
+
case type
|
182
|
+
when :has_many
|
183
|
+
interpolate URL(base, self.id, plural_path)
|
184
|
+
when :has_one
|
185
|
+
interpolate URL(base, self.id, singular_path)
|
186
|
+
when :belongs_to
|
187
|
+
interpolate URL(plural_path, public_send(fkey))
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def self.included(base) #:nodoc:
|
192
|
+
base.extend(ClassMethods)
|
193
|
+
end
|
194
|
+
|
195
|
+
private
|
196
|
+
|
197
|
+
def can_fetch_remotely_association?(name)
|
198
|
+
opts = remote_associations[name]
|
199
|
+
|
200
|
+
if opts[:path]
|
201
|
+
opts[:path].scan(/:([^\/]*)/).map { |m| public_send(m.first.to_sym) }.all?
|
202
|
+
else
|
203
|
+
case opts[:type]
|
204
|
+
when :belongs_to
|
205
|
+
!public_send(opts[:foreign_key] || "#{name}_id").nil?
|
206
|
+
when :has_many, :has_one
|
207
|
+
!self.id.nil?
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def call_association(reload, name)
|
213
|
+
return unless can_fetch_remotely_association?(name)
|
214
|
+
fetch_association(name) if reload || association_undefined?(name)
|
215
|
+
get_association(name)
|
216
|
+
end
|
217
|
+
|
218
|
+
def fetch_association(name)
|
219
|
+
type = remote_associations[name][:type]
|
220
|
+
klass = name.to_s.classify.constantize
|
221
|
+
response = self.class.get(path_to(name, type), :class => klass, :parent => self)
|
222
|
+
set_association(name, response)
|
223
|
+
end
|
224
|
+
|
225
|
+
def get_association(name)
|
226
|
+
instance_variable_get("@#{name}")
|
227
|
+
end
|
228
|
+
|
229
|
+
def set_association(name, value)
|
230
|
+
instance_variable_set("@#{name}", value)
|
231
|
+
end
|
232
|
+
|
233
|
+
def association_undefined?(name)
|
234
|
+
!instance_variable_defined?("@#{name}")
|
235
|
+
end
|
236
|
+
|
237
|
+
def interpolate(content)
|
238
|
+
content.to_s.gsub(/:\w+/) { |m| public_send(m.tr(":", "")) }
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Remotely
|
2
|
+
class Collection < Array
|
3
|
+
def initialize(parent, klass, *args, &block)
|
4
|
+
@parent = parent
|
5
|
+
@klass = klass
|
6
|
+
super(*args, &block)
|
7
|
+
end
|
8
|
+
|
9
|
+
# Returns the first Model object with +id+.
|
10
|
+
#
|
11
|
+
# @param [Fixnum] id id of the record
|
12
|
+
# @return [Remotely::Model] Model object with that id
|
13
|
+
#
|
14
|
+
def find(id)
|
15
|
+
select { |e| e.id.to_i == id.to_i }.first
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns a new Collection object consisting of all the
|
19
|
+
# Model objects matching `attrs`.
|
20
|
+
#
|
21
|
+
# @param [Hash] attrs Search criteria in key-value form
|
22
|
+
# @return [Remotely::Collection] New collection of elements matching
|
23
|
+
#
|
24
|
+
def where(attrs={}, &block)
|
25
|
+
block = lambda { |e| attrs.all? { |k,v| e.send(k) == v }} unless block_given?
|
26
|
+
Collection.new(@parent, @klass, select(&block))
|
27
|
+
end
|
28
|
+
|
29
|
+
# Mimic an ActiveRecord::Relation, but just return self since
|
30
|
+
# that's already an array.
|
31
|
+
#
|
32
|
+
# @return [Remotely::Collection] self
|
33
|
+
#
|
34
|
+
def all
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
# Order the result set by a specific attribute.
|
39
|
+
#
|
40
|
+
# @example Sort by +name+
|
41
|
+
# Thing.where(:type => "awesome").order(:name)
|
42
|
+
#
|
43
|
+
# @result [Remotely::Collection] A new, ordered, Collection.
|
44
|
+
#
|
45
|
+
def order(attribute)
|
46
|
+
Collection.new(@parent, @klass, sort_by(&attribute))
|
47
|
+
end
|
48
|
+
|
49
|
+
# Instantiate a new model object, pre-build with a foreign key
|
50
|
+
# attribute set to it's parent, and add it to itself.
|
51
|
+
#
|
52
|
+
# NOTE: Does not persist the new object. You must call +save+ on the
|
53
|
+
# new object to persist it. To instantiate and persist in one operation
|
54
|
+
# @see #create.
|
55
|
+
#
|
56
|
+
# @param [Hash] attrs Attributes to instantiate the new object with.
|
57
|
+
# @return [Remotely::Model] New model object
|
58
|
+
#
|
59
|
+
def build(attrs={})
|
60
|
+
attribute = "#{@parent.class.model_name.element.to_sym}_id".to_sym
|
61
|
+
value = @parent.id
|
62
|
+
attrs.merge!(attribute => value)
|
63
|
+
|
64
|
+
@klass.new(attrs).tap { |m| self << m }
|
65
|
+
end
|
66
|
+
|
67
|
+
# Same as #build, but saves the new model object as well.
|
68
|
+
#
|
69
|
+
# @see #build
|
70
|
+
#
|
71
|
+
def create(attrs={})
|
72
|
+
build(attrs).tap { |m| m.save }
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|