spyke 1.0.0

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