remotely 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,29 @@
1
+ class URL
2
+ include Comparable
3
+
4
+ def initialize(*args)
5
+ @url = "/" + args.flatten.compact.join("/")
6
+ @url.gsub! %r[/{2,}], "/"
7
+ @url.gsub! %r[/$], ""
8
+ end
9
+
10
+ def +(other)
11
+ URL.new(to_s, other.to_s)
12
+ end
13
+
14
+ def -(other)
15
+ URL.new(to_s.gsub(other.to_s, ""))
16
+ end
17
+
18
+ def <=>(other)
19
+ @url <=> other.to_s
20
+ end
21
+
22
+ def to_s
23
+ @url
24
+ end
25
+ end
26
+
27
+ def URL(*args)
28
+ URL.new(*args)
29
+ end
@@ -0,0 +1,205 @@
1
+ module Remotely
2
+ module HTTPMethods
3
+ # HTTP status codes that are represent successful requests
4
+ SUCCESS_STATUSES = (200..299)
5
+
6
+ # @return [Symbol] the name of the app the model is fetched from
7
+ attr_accessor :app
8
+
9
+ # @return [String] the relative uri to the model's type of resource
10
+ attr_accessor :uri
11
+
12
+ # Set or get the app for this model belongs to. If name is passed,
13
+ # it's a setter, otherwise, a getter.
14
+ #
15
+ # @overload app()
16
+ # Gets the current `app` value.
17
+ #
18
+ # @overload app(name)
19
+ # Sets the value of `app`.
20
+ # @param [Symbol] name Name corresponding to an app defined via Remotely.app.
21
+ #
22
+ # @return [Symbol] New app symbol or current value.
23
+ #
24
+ def app(name=nil)
25
+ if @app.nil? && name.nil? && Remotely.apps.size == 1
26
+ name = Remotely.apps.first.first
27
+ end
28
+
29
+ (name and @app = name) or @app
30
+ end
31
+
32
+ # Set or get the base uri for this model. If name is passed,
33
+ # it's a setter, otherwise, a getter.
34
+ #
35
+ # @overload uri()
36
+ # Gets the current `uri` value.
37
+ #
38
+ # @overload uri(path)
39
+ # Sets the value of `uri`.
40
+ # @param [Symbol] path Relative path to this type of resource.
41
+ #
42
+ # @return [String] New uri or current value.
43
+ #
44
+ def uri(path=nil)
45
+ (path and @uri = path) or @uri
46
+ end
47
+
48
+ # The connection to the remote API.
49
+ #
50
+ # @return [Faraday::Connection] Connection to the remote API.
51
+ #
52
+ def remotely_connection
53
+ address = Remotely.apps[app][:base]
54
+ address = "http://#{address}" unless address =~ /^http/
55
+
56
+ @connection ||= Faraday::Connection.new(address) do |b|
57
+ b.request :url_encoded
58
+ b.adapter :net_http
59
+ end
60
+
61
+ @connection.basic_auth(*Remotely.basic_auth) if Remotely.basic_auth
62
+ @connection
63
+ end
64
+
65
+ # GET request.
66
+ #
67
+ # @param [String] uri Relative path of request.
68
+ # @param [Hash] params Query string, in key-value Hash form.
69
+ #
70
+ # @return [Remotely::Collection, Remotely::Model, Hash] If the result
71
+ # is an array, Collection, if it's a hash, Model, otherwise it's the
72
+ # parsed response body.
73
+ #
74
+ def get(uri, options={})
75
+ uri = expand(uri)
76
+ klass = options.delete(:class)
77
+ parent = options.delete(:parent)
78
+
79
+ before_request(uri, :get, options)
80
+
81
+ response = remotely_connection.get { |req| req.url(uri, options) }
82
+ parse_response(raise_if_html(response), klass, parent)
83
+ end
84
+
85
+ # POST request.
86
+ #
87
+ # Used mainly to create new resources. Remotely assumes that the
88
+ # remote API will return the newly created object, in JSON form,
89
+ # with the `id` assigned to it.
90
+ #
91
+ # @param [String] uri Relative path of request.
92
+ # @param [Hash] params Request payload. Gets JSON-encoded.
93
+ #
94
+ # @return [Remotely::Collection, Remotely::Model, Hash] If the result
95
+ # is an array, Collection, if it's a hash, Model, otherwise it's the
96
+ # parsed response body.
97
+ #
98
+ def post(uri, options={})
99
+ uri = expand(uri)
100
+ klass = options.delete(:class)
101
+ parent = options.delete(:parent)
102
+ body = options.delete(:body) || Yajl::Encoder.encode(options)
103
+
104
+ before_request(uri, :post, body)
105
+ raise_if_html(remotely_connection.post(uri, body))
106
+ end
107
+
108
+ # PUT request.
109
+ #
110
+ # @param [String] uri Relative path of request.
111
+ # @param [Hash] params Request payload. Gets JSON-encoded.
112
+ #
113
+ # @return [Boolean] Was the request successful? (Resulted in a
114
+ # 200-299 response code)
115
+ #
116
+ def put(uri, options={})
117
+ uri = expand(uri)
118
+ body = options.delete(:body) || Yajl::Encoder.encode(options)
119
+
120
+ before_request(uri, :put, body)
121
+ raise_if_html(remotely_connection.put(uri, body))
122
+ end
123
+
124
+ # DELETE request.
125
+ #
126
+ # @param [String] uri Relative path of request.
127
+ #
128
+ # @return [Boolean] Was the resource deleted? (Resulted in a
129
+ # 200-299 response code)
130
+ #
131
+ def http_delete(uri)
132
+ uri = expand(uri)
133
+ before_request(uri, :delete)
134
+ response = raise_if_html(remotely_connection.delete(uri))
135
+ SUCCESS_STATUSES.include?(response.status)
136
+ end
137
+
138
+ # Expand a URI to include any path specified in the the main app
139
+ # configuration. When creating a Faraday object with a path that
140
+ # includes a uri, eg: "localhost:1234/api", Faraday drops the path,
141
+ # making it "localhost:1234". We need to add the "/api" back in
142
+ # before our relative uri.
143
+ #
144
+ # @example
145
+ # Remotely.configure { app :thingapp, "http://example.com/api" }
146
+ # Model.expand("/members") # => "/api/members"
147
+ #
148
+ def expand(uri)
149
+ baseuri = Remotely.apps[app][:uri]
150
+ uri =~ /^#{baseuri}/ ? uri : URL(baseuri, uri)
151
+ end
152
+
153
+ # Gets called before a request. Override to add logging, etc.
154
+ def before_request(uri, http_verb = :get, options = {})
155
+ if ENV['REMOTELY_DEBUG']
156
+ puts "-> #{http_verb.to_s.upcase} #{uri}"
157
+ puts " #{options.inspect}"
158
+ end
159
+ end
160
+
161
+ def raise_if_html(response)
162
+ if response.body =~ %r(<html>)
163
+ raise Remotely::NonJsonResponseError.new(response.body)
164
+ end
165
+ response
166
+ end
167
+
168
+ # Parses the response depending on what was returned. The following
169
+ # table described what gets return in what situations.
170
+ #
171
+ # ------------+------------------+--------------
172
+ # Status Code | Return Body Type | Return Value
173
+ # ------------+------------------+--------------
174
+ # >= 400 | N/A | false
175
+ # ------------+------------------+--------------
176
+ # 200-299 | Array | Collection
177
+ # ------------+------------------+--------------
178
+ # 200-299 | Hash | Model
179
+ # ------------+------------------+--------------
180
+ # 200-299 | Other | Parsed JSON
181
+ # ------------+------------------+--------------
182
+ #
183
+ # @param [Faraday::Response] response Response object
184
+ #
185
+ # @return [Remotely::Collection, Remotely::Model, Other] If the result
186
+ # is an array, Collection, if it's a hash, Model, otherwise it's the
187
+ # parsed response body.
188
+ #
189
+ def parse_response(response, klass=nil, parent=nil)
190
+ return false if response.status >= 400
191
+
192
+ body = Yajl::Parser.parse(response.body) rescue nil
193
+ klass = (klass || self)
194
+
195
+ case body
196
+ when Array
197
+ Collection.new(parent, klass, body.map { |o| klass.new(o) })
198
+ when Hash
199
+ klass.new(body)
200
+ else
201
+ body
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,317 @@
1
+ module Remotely
2
+ class Model
3
+ extend Forwardable
4
+ extend ActiveModel::Naming
5
+ include ActiveModel::Conversion
6
+ include Associations
7
+
8
+ class << self
9
+ include Remotely::HTTPMethods
10
+
11
+ # Array of attributes to be sent when saving
12
+ attr_reader :savable_attributes
13
+
14
+ # Mark an attribute as safe to save. The `save` method
15
+ # will only send these attributes when called.
16
+ #
17
+ # @param [Symbols] *attrs List of attributes to make savable.
18
+ #
19
+ # @example Mark `name` and `age` as savable
20
+ # attr_savable :name, :age
21
+ #
22
+ def attr_savable(*attrs)
23
+ @savable_attributes ||= []
24
+ @savable_attributes += attrs
25
+ @savable_attributes.uniq!
26
+ end
27
+
28
+ # Fetch all entries.
29
+ #
30
+ # @return [Remotely::Collection] collection of entries
31
+ #
32
+ def all
33
+ get uri
34
+ end
35
+
36
+ # Retreive a single object. Combines `uri` and `id` to determine
37
+ # the URI to use.
38
+ #
39
+ # @param [Fixnum] id The `id` of the resource.
40
+ #
41
+ # @example Find the User with id=1
42
+ # User.find(1)
43
+ #
44
+ # @return [Remotely::Model] Single model object.
45
+ #
46
+ def find(id)
47
+ get URL(uri, id)
48
+ end
49
+
50
+ # Fetch the first record matching +attrs+ or initialize a new instance
51
+ # with those attributes.
52
+ #
53
+ # @param [Hash] attrs Attributes to search by, and subsequently instantiate
54
+ # with, if not found.
55
+ #
56
+ # @return [Remotely::Model] Fetched or initialized model object
57
+ #
58
+ def find_or_initialize(attrs={})
59
+ where(attrs).first or new(attrs)
60
+ end
61
+
62
+ # Fetch the first record matching +attrs+ or initialize and save a new
63
+ # instance with those attributes.
64
+ #
65
+ # @param [Hash] attrs Attributes to search by, and subsequently instantiate
66
+ # and save with, if not found.
67
+ #
68
+ # @return [Remotely::Model] Fetched or initialized model object
69
+ #
70
+ def find_or_create(attrs={})
71
+ where(attrs).first or create(attrs)
72
+ end
73
+
74
+ # Search the remote API for a resource matching conditions specified
75
+ # in `params`. Sends `params` as a url-encoded query string. It
76
+ # assumes the search endpoint is at "/resource_plural/search".
77
+ #
78
+ # @param [Hash] params Key-value pairs of attributes and values to search by.
79
+ #
80
+ # @example Search for a person by name and title
81
+ # User.where(:name => "Finn", :title => "The Human")
82
+ #
83
+ # @return [Remotely::Collection] Array-like collection of model objects.
84
+ #
85
+ def where(params={})
86
+ get URL(uri, "search"), params
87
+ end
88
+
89
+ # Creates a new resource.
90
+ #
91
+ # @param [Hash] params Attributes to create the new resource with.
92
+ #
93
+ # @return [Remotely::Model, Boolean] If the creation succeeds, a new
94
+ # model object is returned, otherwise false.
95
+ #
96
+ def create(params={})
97
+ new(params).save
98
+ end
99
+
100
+ alias :create! :create
101
+
102
+ # Update every entry with values from +params+.
103
+ #
104
+ # @param [Hash] params Key-Value pairs of attributes to update
105
+ # @return [Boolean] If the update succeeded
106
+ #
107
+ def update_all(params={})
108
+ put uri, params
109
+ end
110
+
111
+ alias :update_all! :update_all
112
+
113
+ # Destroy an individual resource.
114
+ #
115
+ # @param [Fixnum] id id of the resource to destroy.
116
+ #
117
+ # @return [Boolean] If the destruction succeeded.
118
+ #
119
+ def destroy(id)
120
+ http_delete URL(uri, id)
121
+ end
122
+
123
+ alias :destroy! :destroy
124
+
125
+ # Remotely models don't support single table inheritence
126
+ # so the base class is always itself.
127
+ #
128
+ def base_class
129
+ self
130
+ end
131
+
132
+ private
133
+
134
+ # Search by one or more attribute and their values.
135
+ #
136
+ # @param [String, Symbol] name The attribute name
137
+ # @param [String, Symbol] value Value to search by
138
+ #
139
+ # @see .where
140
+ #
141
+ def find_by(name, *args)
142
+ where(Hash[name.split("_and_").zip(args)]).first
143
+ end
144
+
145
+ def method_missing(name, *args, &block)
146
+ return find_by($1, *args) if name.to_s =~ /^find_by_(.*)!?$/
147
+ super
148
+ end
149
+ end
150
+
151
+ def_delegators :"self.class", :uri, :get, :post, :put, :parse_response
152
+
153
+ # @return [Hash] Key-value of attributes and values.
154
+ attr_accessor :attributes
155
+
156
+ def initialize(attributes={})
157
+ set_errors(attributes.delete('errors')) if attributes['errors']
158
+ self.attributes = attributes.symbolize_keys
159
+ associate!
160
+ end
161
+
162
+ # Update a single attribute.
163
+ #
164
+ # @param [Symbol, String] name Attribute name
165
+ # @param [Mixed] value New value for the attribute
166
+ # @param [Boolean] should_save Should it save after updating
167
+ # the attributes. Default: true
168
+ # @return [Boolean, Mixed] Boolean if the it tried to save, the
169
+ # new value otherwise.
170
+ #
171
+ def update_attribute(name, value)
172
+ self.attributes[name.to_sym] = value
173
+ save
174
+ end
175
+
176
+ # Update multiple attributes.
177
+ #
178
+ # @param [Hash] attrs Hash of attributes/values to update with.
179
+ # @return [Boolean] Did the save succeed.
180
+ #
181
+ def update_attributes(attrs={})
182
+ @attribute_cache = self.attributes.dup
183
+ self.attributes.merge!(attrs.symbolize_keys)
184
+
185
+ if save && self.errors.empty?
186
+ true
187
+ else
188
+ self.attributes = @attribute_cache
189
+ false
190
+ end
191
+ end
192
+
193
+ # Persist this object to the remote API.
194
+ #
195
+ # If the request returns a status code of 201 or 200
196
+ # (for creating new records and updating existing ones,
197
+ # respectively) it is considered a successful save and returns
198
+ # the object. Any other status will result in a return value
199
+ # of false. In addition, the `obj.errors` collection will be
200
+ # populated with any errors returns from the remote API.
201
+ #
202
+ # For `save` to handle errors correctly, the remote API should
203
+ # return a response body which matches a JSONified ActiveRecord
204
+ # errors object. ie:
205
+ #
206
+ # {"errors":{"attribute":["message", "message"]}}
207
+ #
208
+ # @return [Boolean, Model]
209
+ # Remote API returns 200/201 status: the new/updated model object
210
+ # Remote API returns any other status: false
211
+ #
212
+ def save
213
+ method = new_record? ? :post : :put
214
+ status = new_record? ? 201 : 200
215
+ attrs = new_record? ? attributes : attributes.slice(*savable_attributes)
216
+ url = new_record? ? uri : URL(uri, id)
217
+
218
+ resp = public_send(method, url, attrs)
219
+ body = Yajl::Parser.parse(resp.body)
220
+
221
+ if resp.status == status && !body.nil?
222
+ self.attributes.merge!(body.symbolize_keys)
223
+ else
224
+ set_errors(body.delete("errors")) unless body.nil?
225
+ end
226
+
227
+ self
228
+ end
229
+
230
+ def savable_attributes
231
+ (self.class.savable_attributes || attributes.keys) << :id
232
+ end
233
+
234
+ # Sets multiple errors with a hash
235
+ def set_errors(hash)
236
+ (hash || {}).each do |attribute, messages|
237
+ Array(messages).each {|m| errors.add(attribute, m) }
238
+ end
239
+ end
240
+
241
+ # Track errors with ActiveModel::Errors
242
+ def errors
243
+ @errors ||= ActiveModel::Errors.new(self)
244
+ end
245
+
246
+ # Destroy this object with the might of 60 jotun!
247
+ #
248
+ def destroy
249
+ self.class.destroy(id)
250
+ end
251
+
252
+ # Re-fetch the resource from the remote API.
253
+ #
254
+ def reload
255
+ self.attributes = get(URL(uri, id)).attributes
256
+ self
257
+ end
258
+
259
+ def id
260
+ self.attributes[:id]
261
+ end
262
+
263
+ # Assumes that if the object doesn't have an `id`, it's new.
264
+ #
265
+ def new_record?
266
+ self.attributes[:id].nil?
267
+ end
268
+
269
+ def persisted?
270
+ !new_record?
271
+ end
272
+
273
+ def respond_to?(name)
274
+ self.attributes and self.attributes.include?(name) or super
275
+ end
276
+
277
+ def to_json
278
+ Yajl::Encoder.encode(self.attributes)
279
+ end
280
+
281
+ private
282
+
283
+ def metaclass
284
+ (class << self; self; end)
285
+ end
286
+
287
+ # Finds all attributes that match `*_id`, and creates a method for it,
288
+ # that will fetch that record. It uses the `*` part of the attribute
289
+ # to determine the model class and calls `find` on it with the value
290
+ # if the attribute.
291
+ #
292
+ def associate!
293
+ self.attributes.select { |k,v| k =~ /_id$/ }.each do |key, id|
294
+ name = key.to_s.gsub("_id", "")
295
+ metaclass.send(:define_method, name) { |reload=false| fetch(name, id, reload) }
296
+ end
297
+ end
298
+
299
+ def fetch(name, id, reload)
300
+ klass = name.to_s.classify.constantize
301
+ set_association(name, klass.find(id)) if reload || association_undefined?(name)
302
+ get_association(name)
303
+ end
304
+
305
+ def method_missing(name, *args, &block)
306
+ if self.attributes.include?(name)
307
+ self.attributes[name]
308
+ elsif name =~ /(.*)=$/ && self.attributes.include?($1.to_sym)
309
+ self.attributes[$1.to_sym] = args.first
310
+ elsif name =~ /(.*)\?$/ && self.attributes.include?($1.to_sym)
311
+ !!self.attributes[$1.to_sym]
312
+ else
313
+ super
314
+ end
315
+ end
316
+ end
317
+ end