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.
@@ -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