remotely 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1 @@
1
+ require 'autotest/growl'
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .DS_Store
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/.rspec ADDED
File without changes
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source :rubygems
2
+ gemspec
@@ -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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env rake
2
+ require 'bundler/gem_tasks'
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ task :default => :spec
@@ -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