spyke 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/spyke/base.rb ADDED
@@ -0,0 +1,25 @@
1
+ require 'active_model'
2
+ require 'spyke/associations'
3
+ require 'spyke/attributes'
4
+ require 'spyke/orm'
5
+ require 'spyke/http'
6
+ require 'spyke/scopes'
7
+
8
+ module Spyke
9
+ class Base
10
+ # ActiveModel
11
+ include ActiveModel::Conversion
12
+ include ActiveModel::Model
13
+ include ActiveModel::Validations
14
+ include ActiveModel::Validations::Callbacks
15
+ extend ActiveModel::Translation
16
+ extend ActiveModel::Callbacks
17
+
18
+ # Spyke
19
+ include Associations
20
+ include Attributes
21
+ include Http
22
+ include Orm
23
+ include Scopes
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ module Spyke
2
+ class Collection < ::Array
3
+ attr_reader :metadata
4
+
5
+ def initialize(elements, metadata = {})
6
+ super(elements)
7
+ @metadata = metadata
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ module Spyke
2
+ class Config
3
+ class_attribute :connection
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module Spyke
2
+ class ResourceNotFound < StandardError; end
3
+ end
data/lib/spyke/http.rb ADDED
@@ -0,0 +1,78 @@
1
+ require 'faraday'
2
+ require 'spyke/config'
3
+ require 'spyke/path'
4
+ require 'spyke/result'
5
+
6
+ module Spyke
7
+ module Http
8
+ extend ActiveSupport::Concern
9
+ METHODS = %i{ get post put patch delete }
10
+
11
+ included do
12
+ end
13
+
14
+ module ClassMethods
15
+ METHODS.each do |method|
16
+ define_method(method) do |path, params = {}|
17
+ new_or_collection_from_result send("#{method}_raw", path, params)
18
+ end
19
+
20
+ define_method("#{method}_raw") do |path, params = {}|
21
+ request(method, path, params)
22
+ end
23
+ end
24
+
25
+ def request(method, path, params = {})
26
+ response = connection.send(method) do |request|
27
+ if method == :get
28
+ request.url path.to_s, params
29
+ else
30
+ request.url path.to_s
31
+ request.body = params
32
+ end
33
+ end
34
+ Result.new_from_response(response)
35
+ end
36
+
37
+ def new_or_collection_from_result(result)
38
+ if result.data.is_a?(Array)
39
+ new_collection_from_result(result)
40
+ else
41
+ new_from_result(result)
42
+ end
43
+ end
44
+
45
+ def new_from_result(result)
46
+ new result.data if result.data
47
+ end
48
+
49
+ def new_collection_from_result(result)
50
+ Collection.new Array(result.data).map { |record| new(record) }, result.metadata
51
+ end
52
+
53
+ def uri(uri_template = "/#{model_name.plural}/:id")
54
+ @uri ||= uri_template
55
+ end
56
+
57
+ def connection
58
+ Config.connection
59
+ end
60
+ end
61
+
62
+ METHODS.each do |method|
63
+ define_method(method) do |action = nil, params = {}|
64
+ params = action if action.is_a?(Hash)
65
+ path = case action
66
+ when Symbol then uri.join(action)
67
+ when String then Path.new(action, attributes)
68
+ else uri
69
+ end
70
+ self.attributes = self.class.send("#{method}_raw", path, params).data
71
+ end
72
+ end
73
+
74
+ def uri
75
+ Path.new(@uri_template, attributes)
76
+ end
77
+ end
78
+ end
data/lib/spyke/orm.rb ADDED
@@ -0,0 +1,83 @@
1
+ module Spyke
2
+ module Orm
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ define_model_callbacks :create, :update, :save
7
+
8
+ class_attribute :include_root
9
+ self.include_root = true
10
+
11
+ class_attribute :callback_methods
12
+ self.callback_methods = { create: :post, update: :put }.freeze
13
+ end
14
+
15
+ module ClassMethods
16
+ def include_root_in_json(value)
17
+ self.include_root = value
18
+ end
19
+
20
+ def method_for(callback, value = nil)
21
+ self.callback_methods = callback_methods.merge(callback => value) if value
22
+ callback_methods[callback]
23
+ end
24
+
25
+ def find(id)
26
+ id = strip_slug(id)
27
+ where(id: id).find_one || raise(ResourceNotFound)
28
+ end
29
+
30
+ def fetch
31
+ uri = new.uri
32
+ get_raw uri, current_scope.params.except(*uri.variables)
33
+ end
34
+
35
+ def create(attributes = {})
36
+ record = new(attributes)
37
+ record.save
38
+ record
39
+ end
40
+
41
+ def destroy(id = nil)
42
+ new(id: id).destroy
43
+ end
44
+
45
+ def build(attributes = {})
46
+ new(attributes)
47
+ end
48
+
49
+ def strip_slug(id)
50
+ id.to_s.split('-').first
51
+ end
52
+ end
53
+
54
+ def to_params
55
+ if include_root?
56
+ { self.class.model_name.param_key => attributes.except(*uri.variables) }
57
+ else
58
+ attributes.except(*uri.variables)
59
+ end
60
+ end
61
+
62
+ def persisted?
63
+ id?
64
+ end
65
+
66
+ def save
67
+ run_callbacks :save do
68
+ callback = persisted? ? :update : :create
69
+ run_callbacks(callback) do
70
+ send self.class.method_for(callback), to_params
71
+ end
72
+ end
73
+ end
74
+
75
+ def destroy
76
+ delete
77
+ end
78
+
79
+ def reload
80
+ self.attributes = self.class.find(id).attributes
81
+ end
82
+ end
83
+ end
data/lib/spyke/path.rb ADDED
@@ -0,0 +1,28 @@
1
+ require 'uri_template'
2
+
3
+ module Spyke
4
+ class Path
5
+ def initialize(uri_template, params = {})
6
+ @uri_template = URITemplate.new(:colon, uri_template)
7
+ @params = params
8
+ end
9
+
10
+ def join(other_path)
11
+ self.class.new File.join(path, other_path.to_s), @params
12
+ end
13
+
14
+ def to_s
15
+ path
16
+ end
17
+
18
+ def variables
19
+ @variables ||= @uri_template.variables.map(&:to_sym)
20
+ end
21
+
22
+ private
23
+
24
+ def path
25
+ @uri_template.expand(@params).chomp('/')
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ require 'spyke/exceptions'
2
+
3
+ module Spyke
4
+ class Relation
5
+ include Enumerable
6
+
7
+ attr_reader :klass, :params
8
+ delegate :to_ary, :[], :empty?, :last, :size, :metadata, to: :find_some
9
+
10
+ def initialize(klass, options = {})
11
+ @klass, @options, @params = klass, options, {}
12
+ end
13
+
14
+ def all
15
+ where
16
+ end
17
+
18
+ def where(conditions = {})
19
+ @params.merge!(conditions)
20
+ self
21
+ end
22
+
23
+ def find_one
24
+ @find_one ||= klass.new_from_result(fetch)
25
+ end
26
+
27
+ def find_some
28
+ @find_some ||= klass.new_collection_from_result(fetch)
29
+ end
30
+
31
+ def each
32
+ find_some.each { |record| yield record }
33
+ end
34
+
35
+ def uri
36
+ @options[:uri]
37
+ end
38
+
39
+ private
40
+
41
+ def method_missing(name, *args, &block)
42
+ if klass.respond_to?(name)
43
+ scoping { klass.send(name, *args) }
44
+ else
45
+ super
46
+ end
47
+ end
48
+
49
+ # Keep hold of current scope while running a method on the class
50
+ def scoping
51
+ klass.current_scope = self
52
+ yield
53
+ ensure
54
+ klass.current_scope = nil
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,25 @@
1
+ module Spyke
2
+ class Result
3
+ attr_reader :body
4
+
5
+ def self.new_from_response(response)
6
+ new(response.body)
7
+ end
8
+
9
+ def initialize(body)
10
+ @body = HashWithIndifferentAccess.new(body)
11
+ end
12
+
13
+ def data
14
+ body[:data]
15
+ end
16
+
17
+ def metadata
18
+ body[:metadata] || {}
19
+ end
20
+
21
+ def errors
22
+ body[:errors]
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module Spyke
2
+ class ScopeRegistry
3
+ extend ActiveSupport::PerThreadRegistry
4
+
5
+ def initialize
6
+ @registry = Hash.new { |hash, key| hash[key] = {} }
7
+ end
8
+
9
+ def value_for(scope_type, variable_name)
10
+ @registry[scope_type][variable_name]
11
+ end
12
+
13
+ def set_value_for(scope_type, variable_name, value)
14
+ @registry[scope_type][variable_name] = value
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ require 'spyke/relation'
2
+ require 'spyke/scope_registry'
3
+
4
+ module Spyke
5
+ module Scopes
6
+ extend ActiveSupport::Concern
7
+
8
+ module ClassMethods
9
+ delegate :all, :where, to: :current_scope
10
+
11
+ def scope(name, code)
12
+ self.class.send :define_method, name, code
13
+ end
14
+
15
+ def current_scope=(scope)
16
+ ScopeRegistry.set_value_for(:current_scope, name, scope)
17
+ end
18
+
19
+ def current_scope
20
+ ScopeRegistry.value_for(:current_scope, name) || Relation.new(self, uri: uri)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def current_scope
27
+ self.class.current_scope
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module Spyke
2
+ VERSION = '1.0.0'
3
+ end
data/spyke.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'spyke/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "spyke"
8
+ spec.version = Spyke::VERSION
9
+ spec.authors = ["Jens Balvig"]
10
+ spec.email = ["jens@balvig.com"]
11
+ spec.summary = %q{Interact with REST services in an ActiveRecord-like manner}
12
+ spec.description = %q{Interact with REST services in an ActiveRecord-like manner}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activesupport"
22
+ spec.add_dependency "activemodel"
23
+ spec.add_dependency "faraday"
24
+ spec.add_dependency "uri_template"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.6"
27
+ spec.add_development_dependency "faraday_middleware"
28
+ spec.add_development_dependency "minitest"
29
+ spec.add_development_dependency "minitest-line"
30
+ spec.add_development_dependency "minitest-reporters"
31
+ spec.add_development_dependency "mocha"
32
+ spec.add_development_dependency "multi_json"
33
+ spec.add_development_dependency "pry"
34
+ spec.add_development_dependency "rake"
35
+ spec.add_development_dependency "webmock"
36
+ end
@@ -0,0 +1,282 @@
1
+ require 'test_helper'
2
+
3
+ module Spyke
4
+ class AssociationsTest < MiniTest::Test
5
+ def test_association_independence
6
+ assert_kind_of Associations::HasMany, Recipe.new.groups
7
+ assert_raises NoMethodError do
8
+ Recipe.new.recipes
9
+ end
10
+ end
11
+
12
+ def test_initializing_with_has_many_association
13
+ group = Group.new(ingredients: [Ingredient.new(title: 'Water'), Ingredient.new(title: 'Flour')])
14
+ assert_equal %w{ Water Flour }, group.ingredients.map(&:title)
15
+ assert_equal({ 'group' => { 'ingredients' => [{ 'title' => 'Water' }, { 'title' => 'Flour' }] } }, group.to_params)
16
+ end
17
+
18
+ def test_initializing_with_has_one_association
19
+ recipe = Recipe.new(image: Image.new(url: 'bob.jpg'))
20
+ assert_equal 'bob.jpg', recipe.image.url
21
+ end
22
+
23
+ def test_initializing_with_blank_has_one_association
24
+ recipe = Recipe.new(image: Image.new)
25
+ assert_kind_of Image, recipe.image
26
+ end
27
+
28
+ def test_embedded_associations
29
+ stub_request(:get, 'http://sushi.com/recipes/1').to_return_json(data: { groups: [{ id: 1, name: 'Fish' }] })
30
+
31
+ recipe = Recipe.find(1)
32
+
33
+ assert_equal %w{ Fish }, recipe.groups.map(&:name)
34
+ end
35
+
36
+ def test_nested_embedded_associtations
37
+ json = { data: { groups: [{ ingredients: [{ id: 1, name: 'Fish' }] }, { ingredients: [] }] } }
38
+ stub_request(:get, 'http://sushi.com/recipes/1').to_return_json(json)
39
+
40
+ recipe = Recipe.find(1)
41
+
42
+ assert_equal %w{ Fish }, recipe.ingredients.map(&:name)
43
+ end
44
+
45
+ def test_singular_associtations
46
+ stub_request(:get, 'http://sushi.com/recipes/1').to_return_json(data: { image: { url: 'bob.jpg' } })
47
+
48
+ recipe = Recipe.find(1)
49
+
50
+ assert_equal 'bob.jpg', recipe.image.url
51
+ end
52
+
53
+ def test_unloaded_has_many_association
54
+ endpoint = stub_request(:get, 'http://sushi.com/recipes/1/groups?public=true').to_return_json(data: [{ id: 1 }])
55
+
56
+ groups = Recipe.new(id: 1).groups.where(public: true).to_a
57
+
58
+ assert_equal 1, groups.first.id
59
+ assert_requested endpoint
60
+ end
61
+
62
+ def test_unloaded_has_one_association
63
+ endpoint = stub_request(:get, 'http://sushi.com/recipes/1/image').to_return_json(data: { url: 'bob.jpg' })
64
+
65
+ image = Recipe.new(id: 1).image
66
+
67
+ assert_equal 'bob.jpg', image.url
68
+ assert_requested endpoint
69
+ end
70
+
71
+ def test_array_like_behavior
72
+ stub_request(:get, 'http://sushi.com/recipes/1/groups').to_return_json(data: [{ name: 'Fish' }, { name: 'Fruit' }, { name: 'Bread' }])
73
+
74
+ recipe = Recipe.new(id: 1)
75
+
76
+ assert_equal %w{ Fish Fruit }, recipe.groups[0..1].map(&:name)
77
+ assert_equal 'Bread', recipe.groups.last.name
78
+ assert_equal 'Fish', recipe.groups.first.name
79
+ end
80
+
81
+ def test_nil_has_one_association
82
+ stub_request(:get, 'http://sushi.com/recipes/1/image')
83
+
84
+ image = Recipe.new(id: 1).image
85
+
86
+ assert_nil image
87
+ end
88
+
89
+ def test_unloaded_belongs_to_association
90
+ endpoint = stub_request(:get, 'http://sushi.com/users/1')
91
+
92
+ recipe = Recipe.new(user_id: 1)
93
+ recipe.user
94
+
95
+ assert_requested endpoint
96
+ end
97
+
98
+ def test_scopes_on_assocations
99
+ endpoint = stub_request(:get, 'http://sushi.com/users/1/recipes?page=2')
100
+
101
+ User.new(id: 1).recipes.page(2).to_a
102
+
103
+ assert_requested endpoint
104
+ end
105
+
106
+ def test_build_has_many_association
107
+ recipe = Recipe.new(id: 1)
108
+ recipe.groups.build
109
+ assert_equal 1, recipe.groups.first.recipe_id
110
+ end
111
+
112
+ def test_new_has_many_association
113
+ recipe = Recipe.new(id: 1)
114
+ recipe.groups.new
115
+ assert_equal 1, recipe.groups.first.recipe_id
116
+ end
117
+
118
+ def test_deep_build_has_many_association
119
+ recipe = Recipe.new(id: 1)
120
+ recipe.groups.build(ingredients: [Ingredient.new(name: 'Salt')])
121
+
122
+ assert_equal %w{ Salt }, recipe.ingredients.map(&:name)
123
+ assert_equal({ 'recipe' => { 'groups' => [{ 'recipe_id' => 1, 'ingredients' => [{ 'name' => 'Salt' }] }] } }, recipe.to_params)
124
+ end
125
+
126
+ def test_deep_build_has_many_association_with_scope
127
+ recipe = User.new(id: 1).recipes.published.build
128
+
129
+ assert_equal({ 'recipe' => { 'status' => 'published' } }, recipe.to_params)
130
+ end
131
+
132
+ def test_build_association_with_ids
133
+ user = User.new(id: 1, recipes: [{ id: 1 }])
134
+ user.recipe_ids = ['', 2]
135
+
136
+ assert_equal [2], user.recipes.map(&:id)
137
+ assert_equal({ 'user' => { 'recipes' => [{ 'user_id' => 1, 'id' => 2 }] } }, user.to_params)
138
+ end
139
+
140
+ def test_converting_association_to_ids
141
+ stub_request(:get, 'http://sushi.com/users/1/recipes').to_return_json(data: [{ id: 2 }])
142
+ user = User.new(id: 1)
143
+ assert_equal [2], user.recipe_ids
144
+ end
145
+
146
+ def test_build_has_one_association
147
+ recipe = Recipe.new(id: 1)
148
+ image = recipe.build_image
149
+ assert_equal 1, image.recipe_id
150
+ assert_equal 1, recipe.image.recipe_id
151
+ end
152
+
153
+ def test_custom_class_name
154
+ stub_request(:get, 'http://sushi.com/recipes/1').to_return_json(data: { background_image: { url: 'bob.jpg' } })
155
+
156
+ recipe = Recipe.find(1)
157
+
158
+ assert_equal 'bob.jpg', recipe.background_image.url
159
+ assert_equal Image, recipe.background_image.class
160
+ end
161
+
162
+ def test_cached_result
163
+ endpoint_1 = stub_request(:get, 'http://sushi.com/recipes/1/groups?per_page=3')
164
+ endpoint_2 = stub_request(:get, 'http://sushi.com/recipes/1/groups')
165
+
166
+ recipe = Recipe.new(id: 1)
167
+ groups = recipe.groups.where(per_page: 3)
168
+ groups.any?
169
+ groups.to_a
170
+ assert_requested endpoint_1, times: 1
171
+
172
+ recipe.groups.to_a
173
+ assert_requested endpoint_2, times: 1
174
+ end
175
+
176
+ def test_custom_uri_template
177
+ endpoint = stub_request(:get, 'http://sushi.com/recipes/1/alternates/recipe')
178
+ Recipe.new(id: 1).alternate
179
+ assert_requested endpoint
180
+ end
181
+
182
+ def test_create_association
183
+ endpoint = stub_request(:post, 'http://sushi.com/recipes/1/groups').with(body: { group: { title: 'Topping' } }).to_return_json(data: { title: 'Topping', id: 1, recipe_id: 1 })
184
+
185
+ recipe = Recipe.new(id: 1)
186
+ group = recipe.groups.create(title: 'Topping')
187
+
188
+ assert_equal 'Topping', group.title
189
+ assert_equal 1, group.id
190
+ assert_equal 'Topping', recipe.groups.last.title
191
+ assert_equal 1, recipe.groups.last.id
192
+ assert_requested endpoint
193
+ end
194
+
195
+ def test_create_association_with_no_params
196
+ endpoint = stub_request(:post, 'http://sushi.com/recipes/1/groups').with(body: { group: {} })
197
+
198
+ Recipe.new(id: 1).groups.create
199
+
200
+ assert_requested endpoint
201
+ end
202
+
203
+ def test_create_scoped_association
204
+ endpoint = stub_request(:post, 'http://sushi.com/users/1/recipes').with(body: { recipe: { title: 'Sushi', status: 'published' } })
205
+
206
+ User.new(id: 1).recipes.published.create(title: 'Sushi')
207
+
208
+ assert_requested endpoint
209
+ end
210
+
211
+ def test_save_association
212
+ endpoint = stub_request(:post, 'http://sushi.com/recipes/1/groups').with(body: { group: { title: 'Topping' } }).to_return_json(data: { title: 'Topping', recipe_id: 1 })
213
+
214
+ group = Recipe.new(id: 1).groups.build(title: 'Topping')
215
+ group.save
216
+
217
+ assert_equal 'Topping', group.title
218
+ assert_requested endpoint
219
+ end
220
+
221
+ def test_nested_attributes_has_one
222
+ recipe = Recipe.new(image_attributes: { file: 'bob.jpg' })
223
+ assert_equal 'bob.jpg', recipe.image.file
224
+ end
225
+
226
+ def test_nested_attributes_belongs_to
227
+ recipe = Recipe.new(user_attributes: { name: 'Bob' })
228
+ assert_equal 'Bob', recipe.user.name
229
+ end
230
+
231
+ def test_nested_attributes_has_many
232
+ recipe = Recipe.new(groups_attributes: [{ title: 'starter' }, { title: 'sauce' }])
233
+ assert_equal %w{ starter sauce }, recipe.groups.map(&:title)
234
+ end
235
+
236
+ def test_nested_attributes_overwriting_existing
237
+ recipe = Recipe.new(groups_attributes: [{ title: 'starter' }, { title: 'sauce' }])
238
+ recipe.attributes = { groups_attributes: [{ title: 'flavor' }] }
239
+ assert_equal %w{ starter sauce flavor }, recipe.groups.map(&:title)
240
+ end
241
+
242
+ def test_nested_attributes_merging_with_existing
243
+ recipe = Recipe.new(groups_attributes: [{ id: 1, title: 'starter', description: 'nice' }, { id: 2, title: 'sauce', description: 'spicy' }])
244
+ recipe.attributes = { groups_attributes: [{ 'id' => '2', 'title' => 'flavor' }] }
245
+ assert_equal %w{ starter flavor }, recipe.groups.map(&:title)
246
+ assert_equal %w{ nice spicy }, recipe.groups.map(&:description)
247
+ end
248
+
249
+ def test_deeply_nested_attributes
250
+ params = { groups_attributes: [{ id: 1, ingredient_attributes: [{ id: 1, title: 'fish' }]}] }
251
+ recipe = Recipe.new(params)
252
+ recipe.attributes = params
253
+ assert_equal 1, recipe.groups.size
254
+ end
255
+
256
+ def test_nested_attributes_has_many_using_hash_syntax
257
+ recipe = Recipe.new(groups_attributes: { '0' => { title: 'starter' }, '1' => { title: 'sauce' } })
258
+ assert_equal %w{ starter sauce }, recipe.groups.map(&:title)
259
+ end
260
+
261
+ def test_nested_nested_attributes
262
+ recipe = Recipe.new(groups_attributes: { '0' => { ingredients_attributes: { '0' => { name: 'Salt' } } } })
263
+ assert_equal %w{ Salt }, recipe.ingredients.map(&:name)
264
+ end
265
+
266
+ def test_reflect_on_association
267
+ assert_equal Group, Recipe.reflect_on_association(:group).klass
268
+ skip 'wishlisted'
269
+ assert_equal Recipe, Recipe.reflect_on_association(:alternate).klass
270
+ end
271
+
272
+ def test_embed_only_singular_associations
273
+ assert_nil Recipe.new.background_image
274
+ assert_equal 'photo.jpg', Recipe.new(background_image: { url: 'photo.jpg' }).background_image.url
275
+ end
276
+
277
+ def test_embed_only_plural_associations
278
+ assert_equal [], Group.new.ingredients.to_a
279
+ assert_equal [1], Group.new(ingredients: [{ id: 1 }]).ingredients.map(&:id)
280
+ end
281
+ end
282
+ end