arkenstone-open 3.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/codacy-analysis.yml +46 -0
- data/.gitignore +19 -0
- data/.rubocop.yml +5 -0
- data/.ruby-version +1 -0
- data/.travis.yml +16 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +101 -0
- data/LICENSE.txt +11 -0
- data/README.md +87 -0
- data/Rakefile +36 -0
- data/arkenstone.gemspec +27 -0
- data/lib/arkenstone/associations/resources.rb +76 -0
- data/lib/arkenstone/associations.rb +389 -0
- data/lib/arkenstone/document.rb +289 -0
- data/lib/arkenstone/enumerable/query_list.rb +20 -0
- data/lib/arkenstone/errors/no_url_error.rb +14 -0
- data/lib/arkenstone/helpers.rb +18 -0
- data/lib/arkenstone/network/env.rb +19 -0
- data/lib/arkenstone/network/hook.rb +74 -0
- data/lib/arkenstone/network/network.rb +72 -0
- data/lib/arkenstone/query_builder.rb +84 -0
- data/lib/arkenstone/queryable.rb +38 -0
- data/lib/arkenstone/timestamps.rb +25 -0
- data/lib/arkenstone/validation/validation_error.rb +34 -0
- data/lib/arkenstone/validation/validations.rb +192 -0
- data/lib/arkenstone/version.rb +5 -0
- data/lib/arkenstone.rb +22 -0
- data/test/associations/test_document_overrides.rb +50 -0
- data/test/associations/test_has_and_belongs_to_many.rb +80 -0
- data/test/dummy/app/models/association.rb +35 -0
- data/test/dummy/app/models/superuser.rb +8 -0
- data/test/dummy/app/models/user.rb +10 -0
- data/test/spec_helper.rb +16 -0
- data/test/test_arkenstone.rb +354 -0
- data/test/test_arkenstone_hook_inheritance.rb +39 -0
- data/test/test_associations.rb +327 -0
- data/test/test_enumerables.rb +36 -0
- data/test/test_environment.rb +14 -0
- data/test/test_helpers.rb +18 -0
- data/test/test_hooks.rb +104 -0
- data/test/test_query_builder.rb +163 -0
- data/test/test_queryable.rb +59 -0
- data/test/test_validations.rb +197 -0
- metadata +133 -0
@@ -0,0 +1,389 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/inflector'
|
4
|
+
|
5
|
+
# TODO: consider splitting the bigger associations (has_many) into separate files
|
6
|
+
module Arkenstone
|
7
|
+
module Associations
|
8
|
+
class << self
|
9
|
+
def included(base)
|
10
|
+
base.send :include, Arkenstone::Associations::Resources
|
11
|
+
base.send :include, Arkenstone::Associations::InstanceMethods
|
12
|
+
base.extend Arkenstone::Associations::ClassMethods
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
# All association data is stored in a hash (@arkenstone_data) on the instance of the class. Each entry in the hash is keyed off the association name. The value of the hash key is a basic array. This can be wrapped up and extended if (when) more functionality is needed.
|
18
|
+
# `setup_arkenstone_data` creates the following *instance* methods on the class:
|
19
|
+
#
|
20
|
+
# `arkenstone_data` - the hash for the association data. Only use this if you're absolutely 100% sure that you don't need to get up to date data.
|
21
|
+
#
|
22
|
+
# `wipe_arkenstone_cache` - clears the cache for the association provided
|
23
|
+
def setup_arkenstone_data
|
24
|
+
define_method('arkenstone_data') do
|
25
|
+
@arkenstone_data = {} if @arkenstone_data.nil?
|
26
|
+
@arkenstone_data
|
27
|
+
end
|
28
|
+
|
29
|
+
define_method('wipe_arkenstone_cache') do |model_name|
|
30
|
+
arkenstone_data[model_name] = nil
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Creates a One to Many association with the supplied `child_model_name`. Example:
|
35
|
+
#
|
36
|
+
# class Flea
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# class Llama
|
40
|
+
# has_many :fleas
|
41
|
+
#
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# Once `has_many` has evaluated, the structure of `Llama` will look like this:
|
45
|
+
#
|
46
|
+
# class Llama
|
47
|
+
# url 'http://example.com/llamas'
|
48
|
+
#
|
49
|
+
# def cached_fleas
|
50
|
+
# #snip
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# def fleas
|
54
|
+
# #snip
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# def flea_ids
|
58
|
+
# [...] # all the ids of the fleas
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# def add_flea(new_flea)
|
62
|
+
# #snip
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# def remove_flea(flea_to_remove)
|
66
|
+
# #snip
|
67
|
+
# end
|
68
|
+
# end
|
69
|
+
#
|
70
|
+
# You can override the url of the association by passing in model_name: 'something'. This will change the URL it fetches from to use the `model_name` instead:
|
71
|
+
#
|
72
|
+
# has_many :fleas, model_name: 'bugs'
|
73
|
+
#
|
74
|
+
# Will fetch `fleas` from `http://example.com/llamas/:id/bugs.
|
75
|
+
def has_many(child_model_name, options = {})
|
76
|
+
setup_arkenstone_data
|
77
|
+
child_url_fragment = options[:model_name] || child_model_name
|
78
|
+
child_class_name = options[:class_name] || child_model_name
|
79
|
+
|
80
|
+
# The method for accessing the cached data is `cached_[name]`. If the cache is empty it creates a request to repopulate it from the server.
|
81
|
+
cached_child_name = "cached_#{child_model_name}"
|
82
|
+
add_association_method cached_child_name do
|
83
|
+
cache = arkenstone_data
|
84
|
+
if cache[child_model_name].nil?
|
85
|
+
child_instances = fetch_children child_class_name, child_url_fragment
|
86
|
+
attach_nested_has_many_resource_methods(child_instances, child_model_name, child_class_name)
|
87
|
+
cache[child_model_name] = child_instances
|
88
|
+
end
|
89
|
+
cache[child_model_name]
|
90
|
+
end
|
91
|
+
|
92
|
+
# The uncached version is the name supplied to has_many. It wipes the cache for the association and refetches it.
|
93
|
+
add_association_method child_model_name do
|
94
|
+
wipe_arkenstone_cache child_model_name
|
95
|
+
send cached_child_name
|
96
|
+
end
|
97
|
+
|
98
|
+
# Creates an array of the ids of the child models for quick access.
|
99
|
+
singular = child_model_name.to_s.singularize
|
100
|
+
add_association_method "#{singular}_ids" do
|
101
|
+
(send cached_child_name).map(&:id)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Add a model to the association with add_[child_model_name]. It performs two network calls, one to add it, then another to refetch the association.
|
105
|
+
add_child_method_name = "add_#{singular}"
|
106
|
+
add_association_method add_child_method_name do |new_child|
|
107
|
+
add_child child_model_name, new_child.id
|
108
|
+
wipe_arkenstone_cache child_model_name
|
109
|
+
send cached_child_name
|
110
|
+
end
|
111
|
+
|
112
|
+
# Remove a model from the association with remove_[child_model_name]. It performs two network calls, one to add it, then another to refetch the association.
|
113
|
+
remove_child_method_name = "remove_#{singular}"
|
114
|
+
add_association_method remove_child_method_name do |child_to_remove|
|
115
|
+
remove_child child_model_name, child_to_remove.id
|
116
|
+
wipe_arkenstone_cache child_model_name
|
117
|
+
send cached_child_name
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Similar to `has_many` but for a One to One association. Example:
|
122
|
+
#
|
123
|
+
# class Hat
|
124
|
+
# end
|
125
|
+
#
|
126
|
+
# class Llama
|
127
|
+
# has_one :hat
|
128
|
+
# end
|
129
|
+
#
|
130
|
+
# Once `has_one` has evaluated, the structure of `Llama` will look like this:
|
131
|
+
#
|
132
|
+
# class Llama
|
133
|
+
# def cached_hat
|
134
|
+
# #snip
|
135
|
+
# end
|
136
|
+
#
|
137
|
+
# def hat
|
138
|
+
# #snip
|
139
|
+
# end
|
140
|
+
#
|
141
|
+
# def hat=(new_value)
|
142
|
+
# #snip
|
143
|
+
# end
|
144
|
+
# end
|
145
|
+
#
|
146
|
+
# If nil is passed into the setter method (`hat=` in the above example), the association is removed.
|
147
|
+
def has_one(child_model_name, options = {})
|
148
|
+
setup_arkenstone_data
|
149
|
+
child_url_fragment = options[:model_name] || child_model_name
|
150
|
+
|
151
|
+
# The method for accessing the cached single resource is `cached_[name]`. If the value is nil it creates a request to pull the value from the server.
|
152
|
+
cached_child_name = "cached_#{child_model_name}"
|
153
|
+
add_association_method cached_child_name do
|
154
|
+
cache = arkenstone_data
|
155
|
+
cache[child_model_name] = fetch_child child_model_name, child_url_fragment if cache[child_model_name].nil?
|
156
|
+
cache[child_model_name]
|
157
|
+
end
|
158
|
+
|
159
|
+
# The uncached version is retrieved by wiping the cache for the association, and then re-getting it.
|
160
|
+
add_association_method child_model_name do
|
161
|
+
arkenstone_data[child_model_name] = nil
|
162
|
+
send cached_child_name
|
163
|
+
end
|
164
|
+
|
165
|
+
# A single association is updated or removed with a setter method.
|
166
|
+
setter_method_name = "#{child_model_name}="
|
167
|
+
add_association_method setter_method_name do |new_value|
|
168
|
+
if new_value.nil?
|
169
|
+
old_model = send child_model_name
|
170
|
+
remove_child child_model_name, old_model.id
|
171
|
+
wipe_arkenstone_cache child_model_name
|
172
|
+
else
|
173
|
+
add_child child_model_name, new_value.id
|
174
|
+
wipe_arkenstone_cache child_model_name
|
175
|
+
send cached_child_name
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# The opposite of a has_X relationship. Allows you to go back up the association tree. Example:
|
181
|
+
#
|
182
|
+
# class Hat
|
183
|
+
# belongs_to :llama
|
184
|
+
# end
|
185
|
+
#
|
186
|
+
# class Llama
|
187
|
+
# end
|
188
|
+
#
|
189
|
+
# Once `belongs_to` has been evaluated, the structure of `Hat` will look like this:
|
190
|
+
#
|
191
|
+
# class Hat
|
192
|
+
# def llama
|
193
|
+
# #snip
|
194
|
+
# end
|
195
|
+
# end
|
196
|
+
def belongs_to(parent_model_name)
|
197
|
+
setup_arkenstone_data
|
198
|
+
|
199
|
+
parent_model_field = "#{parent_model_name}_id"
|
200
|
+
|
201
|
+
self.arkenstone_attributes = [] unless arkenstone_attributes
|
202
|
+
arkenstone_attributes << parent_model_field.to_sym
|
203
|
+
class_eval("attr_accessor :#{parent_model_field}", __FILE__, __LINE__)
|
204
|
+
|
205
|
+
# The method for accessing the cached data is `cached_[name]`. If the cache is empty it creates a request to repopulate it from the server.
|
206
|
+
cached_parent_model_name = "cached_#{parent_model_name}"
|
207
|
+
add_association_method cached_parent_model_name do
|
208
|
+
cache = arkenstone_data
|
209
|
+
cache[parent_model_name] = fetch_parent parent_model_name if cache[parent_model_name].nil?
|
210
|
+
cache[parent_model_name]
|
211
|
+
end
|
212
|
+
|
213
|
+
# The uncached version is the name supplied to belongs_to. It wipes the cache for the association and refetches it.
|
214
|
+
add_association_method parent_model_name.to_s do
|
215
|
+
arkenstone_data[parent_model_name] = nil
|
216
|
+
send cached_parent_model_name
|
217
|
+
end
|
218
|
+
|
219
|
+
define_method("#{parent_model_name}=") do |parent_instance|
|
220
|
+
send "#{parent_model_field}=".to_sym, parent_instance.id
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
### Support for `has_and_belongs_to_many` relationship
|
225
|
+
def has_and_belongs_to_many(model_klass_name)
|
226
|
+
# Gather the namespace
|
227
|
+
namespace = to_s.split(/::/)
|
228
|
+
model_klass_name = model_klass_name.to_s.singularize.underscore.to_sym
|
229
|
+
current_klass_name = namespace.pop.underscore.to_sym
|
230
|
+
|
231
|
+
# Build join class needs
|
232
|
+
join_klass_name = [model_klass_name, current_klass_name].sort.join('_')
|
233
|
+
join_klass_classified = join_klass_name.classify.to_sym
|
234
|
+
join_klass_pluralized = join_klass_name.pluralize
|
235
|
+
namespace = Kernel.const_get(namespace.join('::'))
|
236
|
+
|
237
|
+
# Create the join class if it doesn't exist already
|
238
|
+
unless namespace.constants.include?(join_klass_classified)
|
239
|
+
join_klass = namespace.const_set(join_klass_classified, Class.new)
|
240
|
+
join_klass.instance_eval { include Arkenstone::Document }
|
241
|
+
|
242
|
+
# The join class should belong to both foreign sides of the relationship
|
243
|
+
join_klass.send :belongs_to, model_klass_name
|
244
|
+
join_klass.send :belongs_to, current_klass_name
|
245
|
+
end
|
246
|
+
|
247
|
+
# This class should belong to the join table
|
248
|
+
send(:has_many, join_klass_pluralized.to_sym) unless respond_to?(join_klass_pluralized.to_sym)
|
249
|
+
|
250
|
+
# These are helper variables for the cached and uncached join `:through` instances
|
251
|
+
model_klass_pluralized = model_klass_name.to_s.pluralize
|
252
|
+
cached_instances_field = "cached_#{model_klass_pluralized}"
|
253
|
+
|
254
|
+
send :attr_accessor, cached_instances_field.to_sym
|
255
|
+
|
256
|
+
# Creates a `self.join_through_instances` helper method
|
257
|
+
#
|
258
|
+
# This actually pulls instances of the join model and then maps on the
|
259
|
+
# complimenting foreign key to gather all the foreign join instances
|
260
|
+
#
|
261
|
+
define_method model_klass_pluralized.to_s do
|
262
|
+
current_klass_instance = self # The instance calling this method
|
263
|
+
current_klass_pluralized = current_klass_name.to_s.pluralize
|
264
|
+
|
265
|
+
# Check for cached joined instances
|
266
|
+
cached_instances = current_klass_instance.send cached_instances_field
|
267
|
+
return cached_instances if cached_instances
|
268
|
+
|
269
|
+
# Get from joined instances
|
270
|
+
model_klass_instances = send("cached_#{join_klass_pluralized}".to_sym).map(&:"#{model_klass_pluralized}")
|
271
|
+
|
272
|
+
# Redefine `<<` so that you can something like `beer.tags << new_tag`
|
273
|
+
model_klass_instances.define_singleton_method :<< do |element|
|
274
|
+
# Use built in `push` for `Array.new`
|
275
|
+
push element
|
276
|
+
|
277
|
+
# Cache the result
|
278
|
+
current_klass_instance.send "#{cached_instances_field}=", self
|
279
|
+
|
280
|
+
# Add the current class instance in the other side of the join
|
281
|
+
# The equivelant of doing `beer.tags << tag` then `tag.beers << beer`
|
282
|
+
#
|
283
|
+
# Grab the current_klass_instances from element
|
284
|
+
element_current_klass_instances = element.send(current_klass_pluralized)
|
285
|
+
|
286
|
+
# Push the current_klass_instance to what element currently has
|
287
|
+
element_current_klass_instances.push(current_klass_instance)
|
288
|
+
|
289
|
+
# Save the new stack of current_klass_instances with element
|
290
|
+
element.send "#{current_klass_pluralized}=", element_current_klass_instances
|
291
|
+
|
292
|
+
# Return the new array
|
293
|
+
self
|
294
|
+
end
|
295
|
+
model_klass_instances
|
296
|
+
end
|
297
|
+
|
298
|
+
# This creates a setter helper to set all joined instances on the
|
299
|
+
# opposite side of the foreign join
|
300
|
+
#
|
301
|
+
define_method "#{model_klass_pluralized}=" do |elements|
|
302
|
+
current_klass_instance = self
|
303
|
+
current_klass_instance.send "#{cached_instances_field}=", elements
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Adds a method to a class unless that method is already defined.
|
308
|
+
def add_association_method(method_name, &method_definition)
|
309
|
+
define_method method_name, method_definition unless method_defined? method_name
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
module InstanceMethods
|
314
|
+
### Fetches a `has_many` based resource
|
315
|
+
def fetch_children(child_model_name, child_url_fragment)
|
316
|
+
fetch_nested_resource child_model_name, child_url_fragment do |klass, response_body|
|
317
|
+
klass.parse_all response_body
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
### Fetches a single `has_one` based resource
|
322
|
+
def fetch_child(child_model_name, child_url_fragment)
|
323
|
+
fetch_nested_resource child_model_name, child_url_fragment do |klass, response_body|
|
324
|
+
return nil if response_body.nil? || response_body.empty?
|
325
|
+
|
326
|
+
klass.build JSON.parse(response_body)
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
### Fetches a single `belongs_to` parent resource.
|
331
|
+
def fetch_parent(parent_model_name)
|
332
|
+
klass_name = parent_model_name.to_s.classify
|
333
|
+
klass_name = prefix_with_class_module klass_name
|
334
|
+
klass = Kernel.const_get klass_name
|
335
|
+
parent_model_field = "#{parent_model_name}_id"
|
336
|
+
klass.send(:find, send(parent_model_field))
|
337
|
+
end
|
338
|
+
|
339
|
+
### Calls the POST url for creating a nested_resource
|
340
|
+
def add_child(child_model_name, child_id)
|
341
|
+
url = build_nested_url child_model_name
|
342
|
+
body = { id: child_id }.to_json
|
343
|
+
self.class.send_request url, :post, body
|
344
|
+
end
|
345
|
+
|
346
|
+
### Calls the DELETE route for a nested resource
|
347
|
+
def remove_child(child_model_name, child_id)
|
348
|
+
url = build_nested_url child_model_name, child_id
|
349
|
+
self.class.send_request url, :delete
|
350
|
+
end
|
351
|
+
|
352
|
+
private
|
353
|
+
|
354
|
+
### Creates the network request for fetching a child resource. Hands parsing the response off to a callback.
|
355
|
+
def fetch_nested_resource(nested_resource_name, nested_resource_fragment = nested_resource_name, &parser)
|
356
|
+
url = build_nested_url nested_resource_fragment
|
357
|
+
response = self.class.send_request url, :get
|
358
|
+
return [] unless Arkenstone::Network.response_is_success response
|
359
|
+
|
360
|
+
klass_name = nested_resource_name.to_s.classify
|
361
|
+
klass_name = prefix_with_class_module klass_name
|
362
|
+
klass = Kernel.const_get klass_name
|
363
|
+
parser[klass, response.body]
|
364
|
+
end
|
365
|
+
|
366
|
+
# If the class is in a module, preserve the module namespace.
|
367
|
+
# Example:
|
368
|
+
#
|
369
|
+
# # for the class Zoo::Llama
|
370
|
+
# prefix_with_class_module('Hat') # 'Zoo::Hat'
|
371
|
+
def prefix_with_class_module(klass)
|
372
|
+
mod = self.class.name.deconstantize
|
373
|
+
klass = "#{mod}::#{klass}" unless mod.empty?
|
374
|
+
klass
|
375
|
+
end
|
376
|
+
|
377
|
+
# Builds a RESTful nested URL based on the instance URL.
|
378
|
+
# Example:
|
379
|
+
#
|
380
|
+
# build_nested_url('fleas') # http://example.com/llamas/100/fleas
|
381
|
+
# build_nested_url('fleas', 25) # http://example.com/llamas/100/fleas/25
|
382
|
+
def build_nested_url(child_name, child_id = nil)
|
383
|
+
url = "#{instance_url}/#{child_name}"
|
384
|
+
url += "/#{child_id}" unless child_id.nil?
|
385
|
+
url
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Arkenstone
|
6
|
+
# == Document
|
7
|
+
#
|
8
|
+
# A `Document` is the main entry point for Arkenstone. A `Document` is a model that is retrieved and/or stored on a RESTful service. For example, if you have a web service that has a URL structure like:
|
9
|
+
#
|
10
|
+
# http://example.com/users
|
11
|
+
#
|
12
|
+
# You can create a `User` model, include `Document` and it will automatically create methods to fetch and save data from that URL.
|
13
|
+
#
|
14
|
+
# class User
|
15
|
+
# include Arkenstone::Document
|
16
|
+
#
|
17
|
+
# url 'http://example.com/users'
|
18
|
+
#
|
19
|
+
# attributes :first_name, :last_name, :email
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# Attributes create properties on instances that match up with the data returned by the `url`. Properties on the web service are ignored if they are not present within the `attributes` list.
|
23
|
+
module Document
|
24
|
+
class << self
|
25
|
+
def included(base)
|
26
|
+
base.send :include, Arkenstone::Helpers
|
27
|
+
base.send :include, Arkenstone::Associations
|
28
|
+
base.send :include, Arkenstone::Document::InstanceMethods
|
29
|
+
base.send :include, Arkenstone::Network
|
30
|
+
base.extend Arkenstone::Document::ClassMethods
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
module InstanceMethods
|
35
|
+
### The convention is for all Documents to have an id.
|
36
|
+
attr_accessor :id, :arkenstone_attributes, :arkenstone_server_errors
|
37
|
+
|
38
|
+
### Easy access to all of the attributes defined for this Document.
|
39
|
+
def attributes
|
40
|
+
new_hash = {}
|
41
|
+
self.class.arkenstone_attributes.each do |key|
|
42
|
+
new_hash[key.to_sym] = send(key.to_s)
|
43
|
+
end
|
44
|
+
new_hash
|
45
|
+
end
|
46
|
+
|
47
|
+
### Set attributes for a Document. If a key in the `options` hash is not present in the attributes list, it is ignored.
|
48
|
+
def attributes=(options)
|
49
|
+
options.each do |key, value|
|
50
|
+
setter = "#{key}="
|
51
|
+
send(setter.to_sym, value) if respond_to? setter
|
52
|
+
end
|
53
|
+
attributes
|
54
|
+
end
|
55
|
+
|
56
|
+
### Returns true if this is a new object that has not been saved yet.
|
57
|
+
def new_record?
|
58
|
+
id.nil?
|
59
|
+
end
|
60
|
+
|
61
|
+
### Serializes the attributes to json.
|
62
|
+
def to_json(options = {})
|
63
|
+
attributes.to_json(options)
|
64
|
+
end
|
65
|
+
|
66
|
+
### If this is a new Document, create it with a POST request, otherwise update it with a PUT. Returns whether the server response was successful or not.
|
67
|
+
def save
|
68
|
+
self.class.check_for_url
|
69
|
+
timestamp if respond_to?(:timestampable)
|
70
|
+
response = new_record? ? post_document_data : put_document_data
|
71
|
+
self.attributes = JSON.parse(response.body)
|
72
|
+
Arkenstone::Network.response_is_success response
|
73
|
+
end
|
74
|
+
|
75
|
+
### Reloading the document fetches the document again by it's id
|
76
|
+
def reload
|
77
|
+
reloaded_self = self.class.find(id)
|
78
|
+
self.attributes = reloaded_self.attributes
|
79
|
+
self
|
80
|
+
end
|
81
|
+
|
82
|
+
alias save! save
|
83
|
+
|
84
|
+
### Update a single attribute. Performs validation (by calling `update_attributes`).
|
85
|
+
def update_attribute(key, value)
|
86
|
+
hash = { key.to_sym => value }
|
87
|
+
update_attributes hash
|
88
|
+
end
|
89
|
+
|
90
|
+
### Update multiple attributes at once. Performs validation (if that is setup for this document).
|
91
|
+
def update_attributes(new_attributes)
|
92
|
+
attributes.merge! new_attributes
|
93
|
+
save
|
94
|
+
end
|
95
|
+
|
96
|
+
### Checks if there is a `valid?` method.
|
97
|
+
def has_validation_method?
|
98
|
+
self.class.method_defined? :valid?
|
99
|
+
end
|
100
|
+
|
101
|
+
# Retrieves a RESTful URL for an instance, in this case by tacking an id onto the end of the `arkenstone_url`.
|
102
|
+
# Example:
|
103
|
+
#
|
104
|
+
# # arkenstone_url
|
105
|
+
# http://example.com/users
|
106
|
+
#
|
107
|
+
# # instance_url
|
108
|
+
# http://example.com/users/100
|
109
|
+
def instance_url
|
110
|
+
"#{full_url(self.class.arkenstone_url)}#{id}"
|
111
|
+
end
|
112
|
+
|
113
|
+
### The full RESTful URL for a Document.
|
114
|
+
def class_url
|
115
|
+
full_url(self.class.arkenstone_url)
|
116
|
+
end
|
117
|
+
|
118
|
+
### Save via POST.
|
119
|
+
def post_document_data
|
120
|
+
http_response class_url, :post
|
121
|
+
end
|
122
|
+
|
123
|
+
### Save via PUT.
|
124
|
+
def put_document_data
|
125
|
+
http_response instance_url, :put
|
126
|
+
end
|
127
|
+
|
128
|
+
### Sends a DELETE request to the `instance_url`.
|
129
|
+
def destroy
|
130
|
+
resp = http_response instance_url, :delete
|
131
|
+
Arkenstone::Network.response_is_success resp
|
132
|
+
end
|
133
|
+
|
134
|
+
### Sends a network request with the `attributes` as the body.
|
135
|
+
def http_response(url, method = :post)
|
136
|
+
response = self.class.send_request url, method, saveable_attributes
|
137
|
+
self.arkenstone_server_errors = JSON.parse(response.body) if response.code == '500'
|
138
|
+
response
|
139
|
+
end
|
140
|
+
|
141
|
+
### Runs any encoding hooks on the attributes if present.
|
142
|
+
def saveable_attributes
|
143
|
+
return attributes unless Arkenstone::Hook.has_hooks? self.class
|
144
|
+
|
145
|
+
attrs = {}
|
146
|
+
Arkenstone::Hook.all_hooks_for_class(self.class).each do |hook|
|
147
|
+
new_attrs = hook.encode_attributes(attributes)
|
148
|
+
attrs.merge! new_attrs unless new_attrs.nil?
|
149
|
+
end
|
150
|
+
attrs.empty? ? attributes : attrs
|
151
|
+
end
|
152
|
+
|
153
|
+
### Creates a deep dupe of the document with the id set to nil
|
154
|
+
def dup
|
155
|
+
duped = super
|
156
|
+
duped.id = nil
|
157
|
+
duped
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
module ClassMethods
|
162
|
+
attr_accessor :arkenstone_url, :arkenstone_attributes, :arkenstone_hooks, :arkenstone_inherit_hooks
|
163
|
+
|
164
|
+
### Sets the root url used for generating RESTful requests.
|
165
|
+
def url(new_url)
|
166
|
+
self.arkenstone_url = new_url
|
167
|
+
end
|
168
|
+
|
169
|
+
# == Hooks
|
170
|
+
#
|
171
|
+
# Hooks are used to allow you to call arbitrary code at various points in the object lifecycle. For example, if you need to massage some property names before they are sent off to the `url`, you can do that with a hook. A hook should extend `Arkenstone::Hook` and then override the method you want to hook into. There are three types of hooks:
|
172
|
+
# 1. `before_request` - Called before the request is sent to the web service. Passes in the request environment (an `Arkenstone::Environment`) as a parameter.
|
173
|
+
# 2. `after_complete` - Called after the request has been *successfully* completed. Passes in a Net::HTTPResponse as a parameter.
|
174
|
+
# 3. `on_error` - Called if the response returned an error. Passes in a Net::HTTPResponse as a parameter.
|
175
|
+
#
|
176
|
+
# Example:
|
177
|
+
#
|
178
|
+
# class ErrorLogger < Arkenstone::Hook
|
179
|
+
# def on_error(response)
|
180
|
+
# # log the error here
|
181
|
+
# end
|
182
|
+
# end
|
183
|
+
#
|
184
|
+
# class User
|
185
|
+
# include Arkenstone::Document
|
186
|
+
#
|
187
|
+
# url 'http://example.com/users'
|
188
|
+
# add_hook ErrorLogger.new
|
189
|
+
# end
|
190
|
+
def add_hook(hook)
|
191
|
+
self.arkenstone_hooks = [] if arkenstone_hooks.nil?
|
192
|
+
arkenstone_hooks << hook
|
193
|
+
end
|
194
|
+
|
195
|
+
# Hooks are applied **only** to the class they are added to. This can cause a problem if you have a base class and want to use the same hooks for subclasses. If you want to use the same hooks as a parent class, use `inherit_hooks`. This will tell Arkenstone to walk up the inheritance chain and call all of the hooks it can find.
|
196
|
+
# Example:
|
197
|
+
#
|
198
|
+
# class ErrorLogger < Arkenstone::Hook
|
199
|
+
# def on_error(response)
|
200
|
+
# # log the error here
|
201
|
+
# end
|
202
|
+
# end
|
203
|
+
#
|
204
|
+
# class BaseModel
|
205
|
+
# include Arkenstone::Document
|
206
|
+
#
|
207
|
+
# add_hook ErrorLogger.new
|
208
|
+
# add_hook SomeOtherHook.new
|
209
|
+
# end
|
210
|
+
#
|
211
|
+
# class User < BaseModel
|
212
|
+
# url 'http://example.com/users'
|
213
|
+
#
|
214
|
+
# inherit_hooks
|
215
|
+
# end
|
216
|
+
#
|
217
|
+
# This will use the hooks defined for `BaseModel` and any defined for `User` too.
|
218
|
+
def inherit_hooks(val: true)
|
219
|
+
self.arkenstone_inherit_hooks = val
|
220
|
+
end
|
221
|
+
|
222
|
+
### Sets the attributes for an Arkenstone Document. These become `attr_accessors` on instances.
|
223
|
+
def attributes(*options)
|
224
|
+
self.arkenstone_attributes = options
|
225
|
+
options.each do |option|
|
226
|
+
send(:attr_accessor, option)
|
227
|
+
end
|
228
|
+
arkenstone_attributes
|
229
|
+
end
|
230
|
+
|
231
|
+
### You can use Arkenstone without defining a `url`, but you won't be able to save a model without one. This raises an error if the url is not defined.
|
232
|
+
def check_for_url
|
233
|
+
raise NoUrlError.new, NoUrlError.default_message if arkenstone_url.nil?
|
234
|
+
end
|
235
|
+
|
236
|
+
### Constructs a new instance with the provided attributes.
|
237
|
+
def build(options)
|
238
|
+
document = new
|
239
|
+
document.attributes = Hash(options).select do |key, _value|
|
240
|
+
document.respond_to? :"#{key}="
|
241
|
+
end
|
242
|
+
document
|
243
|
+
end
|
244
|
+
|
245
|
+
### Builds a list of objects with attributes set from a JSON string or an array.
|
246
|
+
def parse_all(to_parse)
|
247
|
+
return [] if to_parse.nil? || to_parse.empty?
|
248
|
+
|
249
|
+
tree = if to_parse.is_a? String
|
250
|
+
JSON.parse to_parse
|
251
|
+
else
|
252
|
+
to_parse
|
253
|
+
end
|
254
|
+
tree = ensure_parseable_is_array tree
|
255
|
+
documents = tree.map { |document| build document }
|
256
|
+
Arkenstone::QueryList.new documents
|
257
|
+
end
|
258
|
+
|
259
|
+
def ensure_parseable_is_array(to_parse)
|
260
|
+
to_parse = [to_parse] if to_parse.is_a? Hash
|
261
|
+
to_parse
|
262
|
+
end
|
263
|
+
|
264
|
+
### Creates and saves a single instance with the attribute values provided.
|
265
|
+
def create(options)
|
266
|
+
document = build(options)
|
267
|
+
document.save
|
268
|
+
document
|
269
|
+
end
|
270
|
+
|
271
|
+
### Performs a GET request to the instance url with the supplied id. Builds an instance with the response.
|
272
|
+
def find(id)
|
273
|
+
check_for_url
|
274
|
+
url = full_url(arkenstone_url) + id.to_s
|
275
|
+
response = send_request url, :get
|
276
|
+
return nil unless Arkenstone::Network.response_is_success response
|
277
|
+
|
278
|
+
build JSON.parse(response.body)
|
279
|
+
end
|
280
|
+
|
281
|
+
### Calls the `arkenstone_url` expecting to receive a json array of properties to deserialize into a list of objects.
|
282
|
+
def all
|
283
|
+
check_for_url
|
284
|
+
response = send_request arkenstone_url, :get
|
285
|
+
parse_all response.body
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Arkenstone
|
4
|
+
# QueryList extends Array to provide more customized options for Arkenstone documents.
|
5
|
+
class QueryList < Array
|
6
|
+
### If an array is provided, concatenate it onto the instance so that it becomes one long array. Otherwise, push it on.
|
7
|
+
def initialize(initial_value)
|
8
|
+
if initial_value.instance_of?(Array)
|
9
|
+
concat initial_value
|
10
|
+
else
|
11
|
+
push initial_value
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Assumes that every element is an Arkenstone::Document
|
16
|
+
def to_json(options = nil)
|
17
|
+
map(&:attributes).to_json(options)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|