amfetamine 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +8 -0
- data/.rvmrc +1 -0
- data/Gemfile +4 -0
- data/README.md +196 -0
- data/Rakefile +11 -0
- data/amfetamine.gemspec +40 -0
- data/lib/amfetamine.rb +19 -0
- data/lib/amfetamine/base.rb +189 -0
- data/lib/amfetamine/cache.rb +9 -0
- data/lib/amfetamine/caching_adapter.rb +66 -0
- data/lib/amfetamine/config.rb +34 -0
- data/lib/amfetamine/exceptions.rb +9 -0
- data/lib/amfetamine/helpers/rspec_matchers.rb +5 -0
- data/lib/amfetamine/helpers/test_helpers.rb +113 -0
- data/lib/amfetamine/logger.rb +18 -0
- data/lib/amfetamine/query_methods.rb +187 -0
- data/lib/amfetamine/relationship.rb +108 -0
- data/lib/amfetamine/relationships.rb +77 -0
- data/lib/amfetamine/rest_helpers.rb +122 -0
- data/lib/amfetamine/version.rb +3 -0
- data/spec/amfetamine/base_spec.rb +207 -0
- data/spec/amfetamine/caching_spec.rb +37 -0
- data/spec/amfetamine/callbacks_spec.rb +36 -0
- data/spec/amfetamine/conditions_spec.rb +110 -0
- data/spec/amfetamine/dummy_spec.rb +27 -0
- data/spec/amfetamine/relationships_spec.rb +103 -0
- data/spec/amfetamine/rest_helpers_spec.rb +25 -0
- data/spec/amfetamine/rspec_matchers_spec.rb +7 -0
- data/spec/amfetamine/testing_helpers_spec.rb +101 -0
- data/spec/dummy/child.rb +25 -0
- data/spec/dummy/configure.rb +4 -0
- data/spec/dummy/dummy.rb +48 -0
- data/spec/dummy/dummy_rest_client.rb +6 -0
- data/spec/helpers/active_model_lint.rb +21 -0
- data/spec/helpers/fakeweb_responses.rb +120 -0
- data/spec/spec_helper.rb +33 -0
- metadata +246 -0
@@ -0,0 +1,108 @@
|
|
1
|
+
module Amfetamine
|
2
|
+
class Relationship
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
attr_reader :on, :type, :from
|
6
|
+
|
7
|
+
def initialize(opts)
|
8
|
+
@type = opts[:type]
|
9
|
+
@on = opts[:on] # Target class
|
10
|
+
@from = opts[:from] # receiving object
|
11
|
+
end
|
12
|
+
|
13
|
+
def << (other)
|
14
|
+
other.send("#{from_singular_name}_id=", @from.id)
|
15
|
+
other.instance_variable_set("@#{from_singular_name}", Amfetamine::Relationship.new(:on => @from, :from => other, :type => :belongs_to))
|
16
|
+
@children ||= [] # No need to do a request here, but it needs to be an array if it isn't yet.
|
17
|
+
@children << other
|
18
|
+
end
|
19
|
+
|
20
|
+
def on_class
|
21
|
+
if @on.is_a?(Symbol)
|
22
|
+
Amfetamine.parent.const_get(@on.to_s.gsub('/', '::').singularize.gsub('_','').capitalize)
|
23
|
+
else
|
24
|
+
@on.class
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Id of object this relationship references
|
29
|
+
def parent_id
|
30
|
+
if @on.is_a?(Symbol)
|
31
|
+
@from.send(@on.to_s.downcase + "_id") if @type == :belongs_to
|
32
|
+
else
|
33
|
+
@on.id
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Id of the receiving object
|
38
|
+
def from_id
|
39
|
+
@from.id
|
40
|
+
end
|
41
|
+
|
42
|
+
def from_plural_name
|
43
|
+
@from.class.name.to_s.downcase.pluralize
|
44
|
+
end
|
45
|
+
|
46
|
+
def from_singular_name
|
47
|
+
@from.class.name.to_s.downcase
|
48
|
+
end
|
49
|
+
|
50
|
+
def on_plural_name
|
51
|
+
if @on.is_a?(Symbol)
|
52
|
+
@on.to_s.pluralize
|
53
|
+
else
|
54
|
+
@on.class.name.to_s.pluralize.downcase
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def rest_path
|
59
|
+
on_class.rest_path(:relationship => self)
|
60
|
+
end
|
61
|
+
|
62
|
+
def find_path(id)
|
63
|
+
on_class.find_path(id, :relationship => self)
|
64
|
+
end
|
65
|
+
|
66
|
+
def singular_path
|
67
|
+
find_path(@from.id)
|
68
|
+
end
|
69
|
+
|
70
|
+
def full_path
|
71
|
+
if @type == :has_many
|
72
|
+
raise InvalidPath if from_id == nil
|
73
|
+
"#{from_plural_name}/#{from_id}/#{on_plural_name}"
|
74
|
+
elsif @type == :belongs_to
|
75
|
+
raise InvalidPath if parent_id == nil
|
76
|
+
"#{on_plural_name}/#{parent_id}/#{from_plural_name}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def each
|
81
|
+
all.each { |c| yield c }
|
82
|
+
end
|
83
|
+
|
84
|
+
# Delegates the all method to child class with a nested path set
|
85
|
+
def all(opts={})
|
86
|
+
force = opts.delete(:force)
|
87
|
+
request = lambda { on_class.all({ :nested_path => rest_path }.merge(opts)) }
|
88
|
+
|
89
|
+
@children = if force
|
90
|
+
request.call
|
91
|
+
else
|
92
|
+
@children || request.call
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Delegates the find method to child class with a nested path set
|
97
|
+
def find(id, opts={})
|
98
|
+
on_class.find(id, {:nested_path => find_path(id)}.merge(opts))
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
def include?(other)
|
103
|
+
self.all
|
104
|
+
@children.include?(other)
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module Amfetamine
|
2
|
+
module Relationships
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
def initialize(args={})
|
8
|
+
#super(args)
|
9
|
+
if self.class._relationship_children
|
10
|
+
self.class._relationship_children.each do |klass|
|
11
|
+
instance_variable_set("@#{klass}", Amfetamine::Relationship.new(:on => klass, :from => self, :type => :has_many))
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
if self.class._relationship_parents
|
16
|
+
self.class._relationship_parents.each do |klass|
|
17
|
+
instance_variable_set("@#{klass}", Amfetamine::Relationship.new(:on => klass, :from => self, :type => :belongs_to))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def belongs_to_relationship?
|
23
|
+
self.class._relationship_parents && self.class._relationship_parents.any?
|
24
|
+
end
|
25
|
+
|
26
|
+
def belongs_to_relationships
|
27
|
+
if self.class._relationship_parents
|
28
|
+
self.class._relationship_parents.collect { |e| self.send(e) }
|
29
|
+
else
|
30
|
+
[]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
module ClassMethods
|
35
|
+
def has_many_resources(*klasses)
|
36
|
+
self.class_eval do
|
37
|
+
@_relationship_children = []
|
38
|
+
klasses.each do |klass|
|
39
|
+
attr_reader klass
|
40
|
+
@_relationship_children << klass
|
41
|
+
|
42
|
+
parent_id_field = self.name.to_s.downcase.singularize + "_id"
|
43
|
+
|
44
|
+
define_method("build_#{klass.to_s.singularize}") do |*args|
|
45
|
+
args = args.shift || {}
|
46
|
+
Amfetamine.parent.const_get(klass.to_s.gsub('/', '::').singularize.gsub('_','').capitalize).new(args.merge(parent_id_field => self.id))
|
47
|
+
end
|
48
|
+
|
49
|
+
define_method("create_#{klass.to_s.singularize}") do |*args|
|
50
|
+
args = args.shift || {}
|
51
|
+
Amfetamine.parent.const_get(klass.to_s.gsub('/', '::').singularize.gsub('_','').capitalize).create(args.merge(parent_id_field => self.id))
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def _relationship_children
|
58
|
+
@_relationship_children
|
59
|
+
end
|
60
|
+
|
61
|
+
def belongs_to_resource(*klasses)
|
62
|
+
self.class_eval do
|
63
|
+
@_relationship_parents = []
|
64
|
+
klasses.each do |klass|
|
65
|
+
attr_reader klass
|
66
|
+
|
67
|
+
@_relationship_parents << klass
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def _relationship_parents
|
73
|
+
@_relationship_parents
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'active_support/inflector'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Amfetamine
|
5
|
+
module RestHelpers
|
6
|
+
|
7
|
+
RESPONSE_STATUSES = { 422 => :errors, 404 => :notfound, 200 => :success, 201 => :created, 500 => :server_error, 406 => :not_acceptable }
|
8
|
+
|
9
|
+
def rest_path(args={})
|
10
|
+
self.class.rest_path(args)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.included(base)
|
14
|
+
base.extend ClassMethods
|
15
|
+
end
|
16
|
+
|
17
|
+
def singular_path(args={})
|
18
|
+
self.class.find_path(self.id, args)
|
19
|
+
end
|
20
|
+
|
21
|
+
# This method handles the save response
|
22
|
+
# TODO: Needs refactoring, now just want to make the test pass =)
|
23
|
+
# Making assumption here that when response is nil, it should have possitive result. Needs refactor when slept more
|
24
|
+
def handle_response(response)
|
25
|
+
if response[:status] == :success || response[:status] == :created
|
26
|
+
self.instance_variable_set('@notsaved', false)
|
27
|
+
true
|
28
|
+
elsif response[:status] == :errors
|
29
|
+
Amfetamine.logger.warn "Errors from response"
|
30
|
+
response[:body].each do |attr, mesg|
|
31
|
+
errors.add(attr.to_sym, mesg )
|
32
|
+
end
|
33
|
+
false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
module ClassMethods
|
39
|
+
def rest_path(params={})
|
40
|
+
result = if params[:relationship]
|
41
|
+
relationship = params[:relationship]
|
42
|
+
"/#{relationship.full_path}"
|
43
|
+
else
|
44
|
+
"/#{self.name.downcase.pluralize}"
|
45
|
+
end
|
46
|
+
|
47
|
+
result = base_uri + result unless params[:no_base_uri]
|
48
|
+
result = result + resource_suffix unless params[:no_resource_suffix]
|
49
|
+
return result
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_path(id, params={})
|
53
|
+
params_for_rest_path = params.merge({:no_base_uri => true, :no_resource_suffix => true})
|
54
|
+
result = "#{self.rest_path(params_for_rest_path)}/#{id.to_s}"
|
55
|
+
|
56
|
+
result = base_uri + result unless params[:no_base_uri]
|
57
|
+
result = result + resource_suffix unless params[:no_resource_suffix]
|
58
|
+
return result
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
def base_uri
|
63
|
+
@base_uri || Amfetamine::Config.base_uri
|
64
|
+
end
|
65
|
+
|
66
|
+
# wraps rest requests to the corresponding service
|
67
|
+
# *emerging*
|
68
|
+
def handle_request(method, path, opts={})
|
69
|
+
Amfetamine.logger.warn "Making request to #{path} with #{method} and #{opts.inspect}"
|
70
|
+
case method
|
71
|
+
when :get
|
72
|
+
response = rest_client.get(path, opts)
|
73
|
+
when :post
|
74
|
+
response = rest_client.post(path, opts)
|
75
|
+
when :put
|
76
|
+
response = rest_client.put(path, opts)
|
77
|
+
when :delete
|
78
|
+
response = rest_client.delete(path, opts)
|
79
|
+
else
|
80
|
+
raise UnknownRESTMethod, "handle_request only responds to get, put, post and delete"
|
81
|
+
end
|
82
|
+
parse_response(response)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns a hash with human readable status and parsed body
|
86
|
+
def parse_response(response)
|
87
|
+
status = RESPONSE_STATUSES.fetch(response.code) { raise "Response not known" }
|
88
|
+
raise Amfetamine::RecordNotFound if status == :notfound
|
89
|
+
body = if response.body && !(response.body.blank?)
|
90
|
+
response.parsed_response
|
91
|
+
else
|
92
|
+
self.to_json
|
93
|
+
end
|
94
|
+
{ :status => status, :body => body }
|
95
|
+
end
|
96
|
+
|
97
|
+
def rest_client
|
98
|
+
@rest_client || Amfetamine::Config.rest_client
|
99
|
+
end
|
100
|
+
|
101
|
+
def resource_suffix
|
102
|
+
@resource_suffix || Amfetamine::Config.resource_suffix || ""
|
103
|
+
end
|
104
|
+
|
105
|
+
# Allows setting a different rest client per class
|
106
|
+
def rest_client=(value)
|
107
|
+
raise Amfetamine::ConfigurationInvalid, 'Invalid value for rest_client' if ![:get,:put,:delete,:post].all? { |m| value.respond_to?(m) }
|
108
|
+
@rest_client = value
|
109
|
+
end
|
110
|
+
|
111
|
+
def resource_suffix=(value)
|
112
|
+
raise Amfetamine::ConfigurationInvalid, 'Invalid value for resource suffix' if !value.is_a?(String)
|
113
|
+
@resource_suffix = value
|
114
|
+
end
|
115
|
+
|
116
|
+
def base_uri=(value)
|
117
|
+
raise Amfetamine::ConfigurationInvalid, 'Invalid value for base uri' if !value.is_a?(String)
|
118
|
+
@base_uri = value
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,207 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
# Integration tests :)
|
4
|
+
describe Amfetamine::Base do
|
5
|
+
describe "Dummy, our ever faitful test subject" do
|
6
|
+
# Some hight level tests, due to the complexity this makes it a lot easier to refactor
|
7
|
+
let(:dummy) { build(:dummy) }
|
8
|
+
subject { dummy }
|
9
|
+
|
10
|
+
it { should be_valid }
|
11
|
+
its(:title) { should ==('Dummy')}
|
12
|
+
its(:description) { should ==('Crash me!')}
|
13
|
+
its(:to_json) { should match(/dummy/) }
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "Class dummy, setup with amfetamine::base" do
|
18
|
+
let(:dummy) { build(:dummy) }
|
19
|
+
let(:dummy2) { build(:dummy) }
|
20
|
+
subject { Dummy}
|
21
|
+
|
22
|
+
it { should be_cacheable }
|
23
|
+
|
24
|
+
context "#attributes" do
|
25
|
+
it "should update attribute correctly if I edit it" do
|
26
|
+
dummy.title = "Oh a new title!"
|
27
|
+
dummy.attributes['title'].should == "Oh a new title!"
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should include attributes in json" do
|
31
|
+
dummy.title = "Something new"
|
32
|
+
dummy.to_json.should match(/Something new/)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "#find" do
|
37
|
+
it "should find dummy" do
|
38
|
+
dummy.instance_variable_set('@notsaved', false)
|
39
|
+
stub_single_response(dummy) do
|
40
|
+
Dummy.find(dummy.id).should == dummy
|
41
|
+
end
|
42
|
+
dummy.should be_cached
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should return nil if object not found" do
|
46
|
+
lambda {
|
47
|
+
stub_nil_response do
|
48
|
+
Dummy.find(dummy.id * 2).should be_nil
|
49
|
+
end
|
50
|
+
}.should raise_exception(Amfetamine::RecordNotFound)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context "#all" do
|
55
|
+
it "should find all if objects are present" do
|
56
|
+
dummies = []
|
57
|
+
dummy.instance_variable_set('@notsaved', false)
|
58
|
+
dummy2.instance_variable_set('@notsaved', false)
|
59
|
+
|
60
|
+
stub_all_response(dummy, dummy2) do
|
61
|
+
dummies = Dummy.all
|
62
|
+
end
|
63
|
+
|
64
|
+
dummies.should include(dummy)
|
65
|
+
dummies.should include(dummy2)
|
66
|
+
dummies.length.should eq(2)
|
67
|
+
end
|
68
|
+
|
69
|
+
it "should return empty array if objects are not present" do
|
70
|
+
dummies = []
|
71
|
+
stub_all_nil_response do
|
72
|
+
dummies = Dummy.all
|
73
|
+
end
|
74
|
+
|
75
|
+
dummies.should eq([])
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
context "#create" do
|
80
|
+
it "should create an object if data is correct" do
|
81
|
+
new_dummy = nil
|
82
|
+
stub_post_response do
|
83
|
+
new_dummy = Dummy.create({:title => 'test', :description => 'blabla'})
|
84
|
+
end
|
85
|
+
new_dummy.should be_a(Dummy)
|
86
|
+
new_dummy.should_not be_new
|
87
|
+
new_dummy.should be_cached
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should return errors if data is incorrect" do
|
91
|
+
new_dummy = nil
|
92
|
+
stub_post_errornous_response do
|
93
|
+
new_dummy = Dummy.create({:title => 'test'})
|
94
|
+
end
|
95
|
+
new_dummy.should be_new
|
96
|
+
new_dummy.errors.messages.should eq({:description => ['can\'t be blank']})
|
97
|
+
new_dummy.should_not be_cached
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
context "#update" do
|
102
|
+
before(:each) do
|
103
|
+
dummy.send(:notsaved=, false)
|
104
|
+
end
|
105
|
+
|
106
|
+
it "should update if response is succesful" do
|
107
|
+
stub_update_response do
|
108
|
+
dummy.update_attributes({:title => 'zomg'})
|
109
|
+
end
|
110
|
+
dummy.should_not be_new
|
111
|
+
dummy.title.should eq('zomg')
|
112
|
+
dummy.should be_cached
|
113
|
+
end
|
114
|
+
|
115
|
+
it "should show errors if response is not succesful" do
|
116
|
+
stub_update_errornous_response do
|
117
|
+
dummy.update_attributes({:title => ''})
|
118
|
+
end
|
119
|
+
dummy.should_not be_new
|
120
|
+
dummy.errors.messages.should eq({:title => ['can\'t be blank']})
|
121
|
+
end
|
122
|
+
|
123
|
+
it "should not do a request if the data doesn't change" do
|
124
|
+
# Assumes that dummy.update would raise if not within stubbed request.
|
125
|
+
dummy.update_attributes({:title => dummy.title})
|
126
|
+
dummy.errors.should be_empty
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
context "#save" do
|
131
|
+
before(:each) do
|
132
|
+
dummy.send(:notsaved=, true)
|
133
|
+
end
|
134
|
+
|
135
|
+
it "should update the id if data is received from post" do
|
136
|
+
old_id = dummy.id
|
137
|
+
stub_post_response(dummy) do
|
138
|
+
dummy.send(:id=, nil)
|
139
|
+
dummy.save
|
140
|
+
end
|
141
|
+
dummy.id.should == old_id
|
142
|
+
dummy.attributes[:id].should == old_id
|
143
|
+
end
|
144
|
+
|
145
|
+
it "should update attributes if data is received from update" do
|
146
|
+
dummy.send(:notsaved=, false)
|
147
|
+
old_id = dummy.id
|
148
|
+
dummy.title = "BLABLABLA"
|
149
|
+
stub_update_response(dummy) do
|
150
|
+
dummy.title = "BLABLABLA"
|
151
|
+
dummy.save
|
152
|
+
end
|
153
|
+
dummy.id.should == old_id
|
154
|
+
dummy.title.should == "BLABLABLA"
|
155
|
+
dummy.attributes[:title] = "BLABLABLA"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
context "#delete" do
|
160
|
+
before(:each) do
|
161
|
+
dummy.send(:notsaved=, false)
|
162
|
+
end
|
163
|
+
|
164
|
+
it "should delete the object if response is succesful" do
|
165
|
+
stub_delete_response do
|
166
|
+
dummy.destroy
|
167
|
+
end
|
168
|
+
dummy.should be_new
|
169
|
+
dummy.id.should be_nil
|
170
|
+
dummy.should_not be_cached
|
171
|
+
end
|
172
|
+
|
173
|
+
it "should return false if delete failed" do
|
174
|
+
stub_delete_errornous_response do
|
175
|
+
dummy.destroy
|
176
|
+
end
|
177
|
+
dummy.should_not be_new
|
178
|
+
dummy.should_not be_cached
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
describe "Features and bugs" do
|
184
|
+
it "should raise an exception if cached args are nil" do
|
185
|
+
lambda { Dummy.build_object(nil) }.should raise_exception(Amfetamine::InvalidCacheData)
|
186
|
+
end
|
187
|
+
|
188
|
+
it "should raise an exception if cached args do not contain an ID" do
|
189
|
+
lambda { Dummy.build_object(:no_id => 'present') }.should raise_exception(Amfetamine::InvalidCacheData)
|
190
|
+
end
|
191
|
+
|
192
|
+
it "should raise correct exception is data is not expected format" do
|
193
|
+
lambda { Dummy.build_object([]) }.should raise_exception(Amfetamine::InvalidCacheData)
|
194
|
+
end
|
195
|
+
|
196
|
+
it "should receive data when doing a post" do
|
197
|
+
Dummy.prevent_external_connections! do
|
198
|
+
dummy = build(:dummy)
|
199
|
+
Dummy.rest_client.should_receive(:post).with("/dummies", :body => dummy.to_json).
|
200
|
+
and_return(Amfetamine::FakeResponse.new('post', 201, lambda { dummy }))
|
201
|
+
dummy.save
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
|
207
|
+
end
|