smooth-io 0.0.5
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.
- data/.gitignore +2 -0
- data/.rvmrc +1 -0
- data/.travis +6 -0
- data/CNAME +1 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +147 -0
- data/Guardfile +5 -0
- data/LICENSE.md +22 -0
- data/README.md +33 -0
- data/Rakefile +10 -0
- data/bin/smooth +8 -0
- data/doc/presenters.md +47 -0
- data/lib/smooth.rb +56 -0
- data/lib/smooth/adapters/rails_cache.rb +19 -0
- data/lib/smooth/adapters/redis_cache.rb +36 -0
- data/lib/smooth/backends/active_record.rb +56 -0
- data/lib/smooth/backends/base.rb +84 -0
- data/lib/smooth/backends/file.rb +80 -0
- data/lib/smooth/backends/redis.rb +71 -0
- data/lib/smooth/backends/redis_namespace.rb +10 -0
- data/lib/smooth/backends/rest_client.rb +29 -0
- data/lib/smooth/collection.rb +236 -0
- data/lib/smooth/collection/cacheable.rb +12 -0
- data/lib/smooth/collection/query.rb +14 -0
- data/lib/smooth/endpoint.rb +23 -0
- data/lib/smooth/meta_data.rb +33 -0
- data/lib/smooth/meta_data/application.rb +29 -0
- data/lib/smooth/meta_data/inspector.rb +67 -0
- data/lib/smooth/model.rb +93 -0
- data/lib/smooth/presentable.rb +76 -0
- data/lib/smooth/presentable/api_endpoint.rb +49 -0
- data/lib/smooth/presentable/chain.rb +46 -0
- data/lib/smooth/presentable/controller.rb +39 -0
- data/lib/smooth/queryable.rb +35 -0
- data/lib/smooth/queryable/converter.rb +8 -0
- data/lib/smooth/queryable/settings.rb +23 -0
- data/lib/smooth/resource.rb +10 -0
- data/lib/smooth/version.rb +3 -0
- data/smooth-io.gemspec +35 -0
- data/spec/blueprints/people.rb +4 -0
- data/spec/environment.rb +17 -0
- data/spec/lib/backends/active_record_spec.rb +17 -0
- data/spec/lib/backends/base_spec.rb +77 -0
- data/spec/lib/backends/file_spec.rb +46 -0
- data/spec/lib/backends/multi_spec.rb +47 -0
- data/spec/lib/backends/redis_namespace_spec.rb +41 -0
- data/spec/lib/backends/redis_spec.rb +40 -0
- data/spec/lib/backends/rest_client_spec.rb +0 -0
- data/spec/lib/collection/query_spec.rb +9 -0
- data/spec/lib/collection_spec.rb +87 -0
- data/spec/lib/examples/cacheable_collection_spec.rb +27 -0
- data/spec/lib/examples/smooth_model_spec.rb +13 -0
- data/spec/lib/meta_data/application_spec.rb +43 -0
- data/spec/lib/meta_data_spec.rb +32 -0
- data/spec/lib/model_spec.rb +54 -0
- data/spec/lib/presentable/api_endpoint_spec.rb +54 -0
- data/spec/lib/presentable_spec.rb +80 -0
- data/spec/lib/queryable/converter_spec.rb +104 -0
- data/spec/lib/queryable_spec.rb +27 -0
- data/spec/lib/resource_spec.rb +17 -0
- data/spec/lib/smooth_spec.rb +13 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/support/models.rb +28 -0
- data/spec/support/schema.rb +16 -0
- metadata +359 -0
@@ -0,0 +1,84 @@
|
|
1
|
+
module Smooth
|
2
|
+
module Backends
|
3
|
+
class Base
|
4
|
+
|
5
|
+
def initialize options={}
|
6
|
+
@id_counter = 0
|
7
|
+
@maximum_updated_at = Time.now.to_i
|
8
|
+
@namespace = options[:namespace]
|
9
|
+
|
10
|
+
records = options[:records] || {}
|
11
|
+
|
12
|
+
if records.is_a?(Array)
|
13
|
+
records = records.select {|r| r.is_a?(Hash) }.inject({}) do |memo,record|
|
14
|
+
record.symbolize_keys!
|
15
|
+
record[:id] ||= @id_counter += 1
|
16
|
+
memo[ record[:id] ] = record
|
17
|
+
memo
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
@storage = {id_counter: @id_counter, records: records, maximum_updated_at: @maximum_updated_at}
|
22
|
+
end
|
23
|
+
|
24
|
+
def records
|
25
|
+
@storage[:records]
|
26
|
+
end
|
27
|
+
|
28
|
+
def maximum_updated_at
|
29
|
+
@storage[:maximum_updated_at]
|
30
|
+
end
|
31
|
+
|
32
|
+
def index
|
33
|
+
records.values
|
34
|
+
end
|
35
|
+
|
36
|
+
def show id
|
37
|
+
records[id.to_i]
|
38
|
+
end
|
39
|
+
|
40
|
+
def update attributes={}
|
41
|
+
attributes.symbolize_keys!
|
42
|
+
@storage[:maximum_updated_at] = attributes[:updated_at] = Time.now.to_i
|
43
|
+
record = records[attributes[:id]]
|
44
|
+
record.merge!(attributes)
|
45
|
+
record
|
46
|
+
end
|
47
|
+
|
48
|
+
def create attributes={}
|
49
|
+
attributes.symbolize_keys!
|
50
|
+
attributes[:id] = increment_id
|
51
|
+
@storage[:maximum_updated_at] = attributes[:created_at] = attributes[:updated_at] = Time.now.to_i
|
52
|
+
records[attributes[:id]] ||= attributes
|
53
|
+
end
|
54
|
+
|
55
|
+
def destroy id
|
56
|
+
record = records.delete(id)
|
57
|
+
!record.nil?
|
58
|
+
end
|
59
|
+
|
60
|
+
# A Naive query method which only matches for equality
|
61
|
+
def query params={}
|
62
|
+
params.symbolize_keys!
|
63
|
+
|
64
|
+
index.select do |record|
|
65
|
+
record.symbolize_keys!
|
66
|
+
|
67
|
+
params.keys.all? do |key|
|
68
|
+
record[key] == params[key]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
protected
|
74
|
+
|
75
|
+
def increment_id
|
76
|
+
@id_counter += 1
|
77
|
+
end
|
78
|
+
|
79
|
+
def storage=(object={})
|
80
|
+
@storage = object
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module Smooth
|
2
|
+
module Backends
|
3
|
+
class File < Base
|
4
|
+
|
5
|
+
class << self
|
6
|
+
attr_accessor :data_directory, :flush_threshold
|
7
|
+
end
|
8
|
+
|
9
|
+
self.data_directory = Smooth.data_directory
|
10
|
+
self.flush_threshold = 300
|
11
|
+
|
12
|
+
FileUtils.mkdir_p(data_directory)
|
13
|
+
|
14
|
+
attr_accessor :storage,
|
15
|
+
:namespace
|
16
|
+
|
17
|
+
def initialize options={}
|
18
|
+
super
|
19
|
+
|
20
|
+
@data_directory = options[:data_directory]
|
21
|
+
restore if ::File.exists?(storage_path)
|
22
|
+
|
23
|
+
setup_periodic_flushing
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def storage_path
|
28
|
+
::File.join(data_directory, "#{namespace}.json")
|
29
|
+
end
|
30
|
+
|
31
|
+
def url
|
32
|
+
"file://#{ storage_path }"
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
def kill
|
37
|
+
@periodic_flusher && @periodic_flusher.kill
|
38
|
+
end
|
39
|
+
|
40
|
+
def setup_periodic_flushing
|
41
|
+
@periodic_flusher ||= Thread.new do
|
42
|
+
while true
|
43
|
+
flush
|
44
|
+
sleep 20
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
protected
|
50
|
+
|
51
|
+
def throttled?
|
52
|
+
@last_flushed_at && (Time.now.to_i - @last_flushed_at) < self.class.flush_threshold
|
53
|
+
end
|
54
|
+
|
55
|
+
def restore
|
56
|
+
from_disk = JSON.parse( IO.read(storage_path) ) rescue {}
|
57
|
+
|
58
|
+
unless from_disk[:maximum_updated_at] && from_disk[:id_counter] && from_disk[:records]
|
59
|
+
return
|
60
|
+
end
|
61
|
+
|
62
|
+
@storage = from_disk && true
|
63
|
+
end
|
64
|
+
|
65
|
+
def flush force=false
|
66
|
+
return if !force && throttled?
|
67
|
+
|
68
|
+
::File.open(storage_path,'w+') do |fh|
|
69
|
+
fh.puts( JSON.generate(storage) )
|
70
|
+
@last_flushed_at = Time.now.to_i
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def data_directory
|
75
|
+
@data_directory || self.class.data_directory
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module Smooth
|
2
|
+
module Backends
|
3
|
+
class Redis < Base
|
4
|
+
attr_reader :connection,
|
5
|
+
:namespace
|
6
|
+
|
7
|
+
def initialize options={}
|
8
|
+
redis = ::Redis.new(options[:redis_options] || {})
|
9
|
+
|
10
|
+
@namespace = options[:namespace]
|
11
|
+
@priority = options[:priority] || 0
|
12
|
+
|
13
|
+
if options[:use_redis_namespace]
|
14
|
+
@connection = ::Redis::Namespace.new(Smooth.namespace, redis: redis)
|
15
|
+
else
|
16
|
+
@connection = redis
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def create attributes={}
|
21
|
+
attributes.symbolize_keys!
|
22
|
+
attributes[:created_at] = attributes[:updated_at] = touch
|
23
|
+
attributes[:id] = increment_id
|
24
|
+
connection.hset("#{ namespace }:records", attributes[:id], JSON.generate(attributes))
|
25
|
+
attributes
|
26
|
+
end
|
27
|
+
|
28
|
+
def update attributes={}
|
29
|
+
attributes.symbolize_keys!
|
30
|
+
if record = show(attributes[:id])
|
31
|
+
record.merge!(attributes)
|
32
|
+
record[:updated_at] = touch
|
33
|
+
connection.hset("#{ namespace }:records", attributes[:id], JSON.generate(record))
|
34
|
+
record
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def destroy id
|
39
|
+
!!(connection.hdel("#{ namespace }:records", id.to_s))
|
40
|
+
end
|
41
|
+
|
42
|
+
def index
|
43
|
+
records = connection.hvals("#{ namespace }:records")
|
44
|
+
records.map! {|r| JSON.parse(r) }
|
45
|
+
records
|
46
|
+
end
|
47
|
+
|
48
|
+
def show id
|
49
|
+
record = connection.hget("#{ namespace }:records", id)
|
50
|
+
parsed = JSON.parse(record)
|
51
|
+
parsed && parsed.symbolize_keys!
|
52
|
+
parsed
|
53
|
+
end
|
54
|
+
|
55
|
+
protected
|
56
|
+
def maximum_updated_at
|
57
|
+
connect.get("#{ namespace }:maximum_updated_at")
|
58
|
+
end
|
59
|
+
|
60
|
+
def touch
|
61
|
+
stamp = Time.now.to_i
|
62
|
+
connection.set("#{ namespace }:maximum_updated_at", stamp)
|
63
|
+
stamp
|
64
|
+
end
|
65
|
+
|
66
|
+
def increment_id
|
67
|
+
connection.incr("#{ namespace }:id_incrementer")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module Smooth
|
2
|
+
module Backends
|
3
|
+
class RedisNamespace < Smooth::Backends::Redis
|
4
|
+
def initialize options={}
|
5
|
+
@namespace, @priority = options.values_at(:namespace, :priority)
|
6
|
+
@connection = ::Redis::Namespace.new( @namespace, redis: ::Redis.new(options.fetch(:redis_options, {})))
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Smooth
|
2
|
+
module Backends
|
3
|
+
class RestClient
|
4
|
+
|
5
|
+
def initialize options={}
|
6
|
+
|
7
|
+
end
|
8
|
+
|
9
|
+
def query params={}
|
10
|
+
response = Typhoeus::Request.get(url_with_query_string(params))
|
11
|
+
end
|
12
|
+
|
13
|
+
def url
|
14
|
+
@options[:url]
|
15
|
+
end
|
16
|
+
|
17
|
+
protected
|
18
|
+
|
19
|
+
def url_with_query_string params={}
|
20
|
+
parts = params.inject([]) do |memo,element|
|
21
|
+
key, value = element
|
22
|
+
memo << "#{ key }=#{ value }"
|
23
|
+
end
|
24
|
+
|
25
|
+
url + "?#{ parts.join('&') }"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,236 @@
|
|
1
|
+
module Smooth
|
2
|
+
class Collection
|
3
|
+
include Cacheable
|
4
|
+
|
5
|
+
InvalidBackend = Class.new(Exception)
|
6
|
+
|
7
|
+
attr_reader :options, :backend
|
8
|
+
|
9
|
+
class << self
|
10
|
+
attr_accessor :model_class,
|
11
|
+
:backend,
|
12
|
+
:namespace
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.uses_model model=Smooth::Model
|
16
|
+
self.model_class = model
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.uses_namespace namespace="smooth:collections"
|
21
|
+
self.namespace = namespace
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.uses_backend backend=:file
|
26
|
+
self.backend = backend
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
# Create an instance of a Smooth::Collection.
|
31
|
+
#
|
32
|
+
# Examples:
|
33
|
+
#
|
34
|
+
# Smooth::Collection.new(namespace: "namespace", backend: "file")
|
35
|
+
#
|
36
|
+
def initialize namespace, models=[], options={}
|
37
|
+
if models.is_a?(Hash)
|
38
|
+
options = models
|
39
|
+
models = Array(options[:models])
|
40
|
+
end
|
41
|
+
|
42
|
+
if namespace.is_a?(Hash)
|
43
|
+
options = namespace
|
44
|
+
end
|
45
|
+
|
46
|
+
if namespace.is_a?(String)
|
47
|
+
options[:namespace] = namespace
|
48
|
+
end
|
49
|
+
|
50
|
+
@options = options
|
51
|
+
|
52
|
+
if options[:model] and !options[:backend]
|
53
|
+
options[:backend] = "active_record"
|
54
|
+
end
|
55
|
+
|
56
|
+
validate_backend
|
57
|
+
|
58
|
+
if options[:cache].is_a?(String)
|
59
|
+
options[:cache] = {
|
60
|
+
backend: "redis"
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
if options[:cache]
|
65
|
+
@cache_adapter = create_cache_adapter(options[:cache])
|
66
|
+
end
|
67
|
+
|
68
|
+
@model_class = options[:model_class] || self.class.model_class
|
69
|
+
|
70
|
+
@models = models
|
71
|
+
end
|
72
|
+
|
73
|
+
def sync method="read", model={}, options={}, &block
|
74
|
+
|
75
|
+
case method.to_sym
|
76
|
+
|
77
|
+
when :read
|
78
|
+
fetch_from_index
|
79
|
+
when :update
|
80
|
+
model = data_to_model(model,options)
|
81
|
+
backend.update model.as_json
|
82
|
+
fetch_from_index
|
83
|
+
when :create
|
84
|
+
model = data_to_model(model,options)
|
85
|
+
attributes = backend.create(model.as_json)
|
86
|
+
fetch_from_index
|
87
|
+
|
88
|
+
model = data_to_model(attributes, options)
|
89
|
+
when :destroy
|
90
|
+
backend.destroy model.id
|
91
|
+
else
|
92
|
+
fetch_from_index
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def add data={}, options={}
|
97
|
+
options[:collection] = self
|
98
|
+
|
99
|
+
model = data_to_model(data, options)
|
100
|
+
|
101
|
+
@models << model
|
102
|
+
|
103
|
+
model
|
104
|
+
end
|
105
|
+
|
106
|
+
def find_by attribute, value
|
107
|
+
q = {}
|
108
|
+
q[attribute.to_sym] = value
|
109
|
+
data_to_model query(q).first
|
110
|
+
end
|
111
|
+
|
112
|
+
def find id
|
113
|
+
find_by(:id, id)
|
114
|
+
end
|
115
|
+
|
116
|
+
def models
|
117
|
+
fetch_from_index unless @fetched
|
118
|
+
|
119
|
+
Array(@models).map do |model|
|
120
|
+
data_to_model(model, collection: self)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def url
|
125
|
+
backend.url rescue @namespace
|
126
|
+
end
|
127
|
+
|
128
|
+
def query params={}, options={}
|
129
|
+
query = Query.new(params)
|
130
|
+
query = apply_scope_parameters_to(query)
|
131
|
+
|
132
|
+
return backend.query(query) unless cacheable?
|
133
|
+
|
134
|
+
cache_adapter.fetch(query.cache_key) do
|
135
|
+
backend.query(query)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def fetch options={}
|
140
|
+
response = query(options, options[:query_options])
|
141
|
+
reset parse(response, options)
|
142
|
+
end
|
143
|
+
|
144
|
+
def parse response, options={}
|
145
|
+
response
|
146
|
+
end
|
147
|
+
|
148
|
+
def reset models=nil
|
149
|
+
@models = Array(models)
|
150
|
+
end
|
151
|
+
|
152
|
+
def length
|
153
|
+
models.length
|
154
|
+
end
|
155
|
+
|
156
|
+
private
|
157
|
+
def fetch_from_index
|
158
|
+
@fetched = true
|
159
|
+
@models = Array(backend && backend.index)
|
160
|
+
end
|
161
|
+
|
162
|
+
def data_to_model data={}, options={}
|
163
|
+
data = JSON.parse(data) if data.is_a?(String)
|
164
|
+
|
165
|
+
begin
|
166
|
+
if data.class.ancestors.include?(Smooth::Model)
|
167
|
+
model = data
|
168
|
+
elsif data.is_a?(Hash)
|
169
|
+
model = model_class.new(data, options)
|
170
|
+
end
|
171
|
+
|
172
|
+
model.collection ||= self
|
173
|
+
|
174
|
+
model
|
175
|
+
rescue
|
176
|
+
binding.pry if $k
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def model_class
|
181
|
+
@model_class || Smooth::Model
|
182
|
+
end
|
183
|
+
|
184
|
+
def cache_adapter
|
185
|
+
@cache_adapter
|
186
|
+
end
|
187
|
+
|
188
|
+
def cacheable?
|
189
|
+
!!(options[:cache])
|
190
|
+
end
|
191
|
+
|
192
|
+
def apply_scope_parameters_to query={}
|
193
|
+
query
|
194
|
+
end
|
195
|
+
|
196
|
+
def namespace
|
197
|
+
namespace = options[:namespace] || self.class.namespace
|
198
|
+
|
199
|
+
if options[:backend] == "active_record" and options[:model] and namespace.nil?
|
200
|
+
namespace = options[:model].table_name
|
201
|
+
end
|
202
|
+
|
203
|
+
namespace
|
204
|
+
end
|
205
|
+
|
206
|
+
def backend_options
|
207
|
+
default = {namespace: namespace}
|
208
|
+
default[:model] ||= options[:model]
|
209
|
+
|
210
|
+
default.merge!(backend_options: options.fetch(:backend_options,{}))
|
211
|
+
default
|
212
|
+
end
|
213
|
+
|
214
|
+
def configure_backend
|
215
|
+
options[:backend] ||= self.class.backend || "file"
|
216
|
+
options[:backend] = options[:backend].to_s if options[:backend].is_a?(Symbol)
|
217
|
+
|
218
|
+
if options[:backend].is_a?(String)
|
219
|
+
klass = if options[:backend].match(/::/)
|
220
|
+
options[:backend]
|
221
|
+
else
|
222
|
+
"Smooth::Backends::#{ options[:backend].camelize }"
|
223
|
+
end
|
224
|
+
|
225
|
+
@backend = klass.constantize.new(backend_options)
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
def validate_backend
|
230
|
+
configure_backend unless @backend
|
231
|
+
|
232
|
+
raise InvalidBackend unless backend.respond_to?(:query)
|
233
|
+
@backend
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|