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,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