remotely 0.0.4
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.
- 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
|