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,66 @@
|
|
1
|
+
module Amfetamine
|
2
|
+
# This adapter wraps methods around memcached (dalli) methods
|
3
|
+
module CachingAdapter
|
4
|
+
def self.included(base)
|
5
|
+
base.extend ClassAndInstanceMethods
|
6
|
+
base.extend CacheServer
|
7
|
+
base.send(:include,ClassAndInstanceMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
|
11
|
+
def initialize(server, options={})
|
12
|
+
@cache_server ||= Dalli::Client.new(server, options)
|
13
|
+
end
|
14
|
+
|
15
|
+
def cache_server
|
16
|
+
@cache_server
|
17
|
+
end
|
18
|
+
|
19
|
+
private :cache_server
|
20
|
+
|
21
|
+
module ClassAndInstanceMethods
|
22
|
+
def get(key)
|
23
|
+
cache_server.get(key)
|
24
|
+
end
|
25
|
+
|
26
|
+
def set(key,data)
|
27
|
+
cache_server.set(key, data)
|
28
|
+
end
|
29
|
+
|
30
|
+
def add(key, data)
|
31
|
+
cache_server.add(key,data)
|
32
|
+
end
|
33
|
+
|
34
|
+
def delete(key)
|
35
|
+
cache_server.delete(key)
|
36
|
+
end
|
37
|
+
|
38
|
+
def flush
|
39
|
+
cache_server.flush
|
40
|
+
end
|
41
|
+
|
42
|
+
def fetch(key)
|
43
|
+
#cache_server.fetch(key,&block)
|
44
|
+
val = get(key)
|
45
|
+
if val.nil? && block_given?
|
46
|
+
val = yield
|
47
|
+
add(key,val)
|
48
|
+
else
|
49
|
+
Amfetamine.logger.info "Hit! #{key}"
|
50
|
+
end
|
51
|
+
val
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
module CacheServer
|
56
|
+
private
|
57
|
+
def cache_server
|
58
|
+
Amfetamine::Config.memcached_instance
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Amfetamine
|
2
|
+
class Config
|
3
|
+
class << self
|
4
|
+
|
5
|
+
attr_reader :memcached_instance, :rest_client, :base_uri, :resource_suffix, :logger
|
6
|
+
|
7
|
+
def configure
|
8
|
+
yield(self)
|
9
|
+
@base_uri ||= ""
|
10
|
+
end
|
11
|
+
|
12
|
+
def memcached_instance=(value, options={})
|
13
|
+
raise ConfigurationInvalid, 'Invalid value for memcached_instance' if !value.is_a?(String)
|
14
|
+
@memcached_instance ||= Dalli::Client.new(value, options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def rest_client=(value)
|
18
|
+
raise ConfigurationInvalid, 'Invalid value for rest_client' if ![:get,:put,:delete,:post].all? { |m| value.respond_to?(m) }
|
19
|
+
@rest_client ||= value
|
20
|
+
end
|
21
|
+
|
22
|
+
# Shouldn't be needed as our favourite rest clients are based on httparty, still, added it for opensource reasons
|
23
|
+
def base_uri=(value)
|
24
|
+
raise ConfigurationInvalid, "Invalid value for base uri, should be a string" if !value.is_a?(String)
|
25
|
+
@base_uri ||= value
|
26
|
+
end
|
27
|
+
|
28
|
+
def resource_suffix=(value)
|
29
|
+
raise ConfigurationInvalid, "Invalid value for resource suffix, should be a string" if !value.is_a?(String)
|
30
|
+
@resource_suffix ||= value
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
module Amfetamine
|
2
|
+
class InvalidPath < Exception; end; # Only used for paths not having parent ids
|
3
|
+
class ConfigurationInvalid < Exception;end; # -red
|
4
|
+
class UnknownRESTMethod < Exception; end; # Only used if rest method is not handled by amfetamine
|
5
|
+
class RecordNotFound < Exception; end;
|
6
|
+
class InvalidCacheData < Exception; end;
|
7
|
+
#class MatcherNotImplemented < Exception; end;
|
8
|
+
class ExternalConnectionsNotAllowed < Exception; end; # Used for test helpers if connection is not allowed and attempt is made to connect
|
9
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
module Amfetamine
|
2
|
+
module TestHelpers
|
3
|
+
# Uses fakeweb to block all connections
|
4
|
+
def self.included(base)
|
5
|
+
base.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
# Allows for preventing external connections, also in a block
|
10
|
+
def prevent_external_connections!
|
11
|
+
save_rest_client
|
12
|
+
self.rest_client = NeinNeinNein.new
|
13
|
+
|
14
|
+
if block_given?
|
15
|
+
yield self.rest_client
|
16
|
+
restore_rest_client
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def save_rest_client
|
21
|
+
@_old_rest_client = self.rest_client || @_old_rest_client
|
22
|
+
end
|
23
|
+
|
24
|
+
def restore_rest_client
|
25
|
+
self.rest_client = @_old_rest_client || self.rest_client
|
26
|
+
end
|
27
|
+
|
28
|
+
# Prevents external connections and provides a simple dsl
|
29
|
+
#
|
30
|
+
# Dummy.stub_responses! do |r|
|
31
|
+
# r.get(code: 200, path: '/dummies') { dummy }
|
32
|
+
# end
|
33
|
+
def stub_responses!
|
34
|
+
prevent_external_connections!
|
35
|
+
yield rest_client
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Annoying stub for http responses
|
41
|
+
class NeinNeinNein
|
42
|
+
def respond_to?(method)
|
43
|
+
if [:get,:post,:delete, :put].include?(method)
|
44
|
+
true
|
45
|
+
else
|
46
|
+
super
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def method_missing(method, *args, &block)
|
51
|
+
if [:get,:post,:delete,:put].include?(method)
|
52
|
+
# TODO: Dump and rewrite
|
53
|
+
|
54
|
+
# If this is the dsl calling
|
55
|
+
if args.first.is_a?(Hash) || args.empty?
|
56
|
+
opts = args.first || {}
|
57
|
+
path = opts[:path] || 'default'
|
58
|
+
code = opts[:code] || 200
|
59
|
+
query = opts[:query]
|
60
|
+
else # Else this is a request
|
61
|
+
path = args[0] || 'default'
|
62
|
+
query = args[1][:query]
|
63
|
+
end
|
64
|
+
|
65
|
+
paths_with_values = instance_variable_get("@#{method.to_s}") || {}
|
66
|
+
|
67
|
+
path.gsub!(/\/$/,'') #remove trailing slash
|
68
|
+
old_path = path
|
69
|
+
path += query.to_s.strip
|
70
|
+
|
71
|
+
if block_given?
|
72
|
+
paths_with_values[path]= FakeResponse.new(method, code, block)
|
73
|
+
instance_variable_set("@#{method.to_s}", paths_with_values)
|
74
|
+
end
|
75
|
+
|
76
|
+
response = paths_with_values ? (paths_with_values[path] || paths_with_values[old_path] || paths_with_values['default']) : nil
|
77
|
+
|
78
|
+
return response if response
|
79
|
+
|
80
|
+
raise Amfetamine::ExternalConnectionsNotAllowed, "Tried to do #{method} with #{args}\n Allowed paths: \n #{paths_with_values.keys.join("\n")}"
|
81
|
+
else
|
82
|
+
super
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
class FakeResponse
|
89
|
+
def initialize(method, code2, block)
|
90
|
+
@method = method
|
91
|
+
@response_code = code2 || 200
|
92
|
+
@inner_body = block.call || {}
|
93
|
+
end
|
94
|
+
|
95
|
+
def code
|
96
|
+
@response_code
|
97
|
+
end
|
98
|
+
|
99
|
+
def body
|
100
|
+
MultiJson.encode(@inner_body) if @inner_body
|
101
|
+
end
|
102
|
+
|
103
|
+
def parsed_response
|
104
|
+
if body
|
105
|
+
MultiJson.decode(body)
|
106
|
+
else
|
107
|
+
{}
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
|
3
|
+
module Amfetamine
|
4
|
+
class Logger
|
5
|
+
include Singleton
|
6
|
+
|
7
|
+
def method_missing(method, args)
|
8
|
+
args = "[Amfetamine] #{args.to_s}"
|
9
|
+
if defined?(Rails)
|
10
|
+
Rails.logger.send(method,args)
|
11
|
+
elsif defined?(Merb)
|
12
|
+
Merb.logger.send(method,args)
|
13
|
+
else
|
14
|
+
puts args
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
require 'active_support/core_ext' # For to_query
|
2
|
+
|
3
|
+
module Amfetamine
|
4
|
+
module QueryMethods
|
5
|
+
# Base method for finding objects
|
6
|
+
# Should this be refactored to a different class that checks if cached and returns object?
|
7
|
+
# Caching methods are called here ONLY.
|
8
|
+
def self.included(base)
|
9
|
+
base.extend ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def find(id, opts={})
|
14
|
+
begin
|
15
|
+
key = opts[:nested_path] || self.find_path(id)
|
16
|
+
data = get_data(key, opts[:conditions])
|
17
|
+
if data[:status] == :success
|
18
|
+
build_object(data[:body])
|
19
|
+
else
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
rescue
|
23
|
+
clean_cache!
|
24
|
+
raise
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def all(opts={})
|
29
|
+
begin
|
30
|
+
key = opts[:nested_path] || self.rest_path
|
31
|
+
data = get_data(key, opts[:conditions])
|
32
|
+
|
33
|
+
if data[:status] == :success
|
34
|
+
data[:body].compact.map { |d| build_object(d) }
|
35
|
+
else
|
36
|
+
[]
|
37
|
+
end
|
38
|
+
rescue
|
39
|
+
clean_cache!
|
40
|
+
raise
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def cache_conditions(key, condition=nil)
|
45
|
+
return nil unless condition
|
46
|
+
conditions = cache.get("#{key}_conditions") || []
|
47
|
+
q_condition = condition.to_query
|
48
|
+
|
49
|
+
if !conditions.include?(q_condition)
|
50
|
+
conditions << condition.to_query
|
51
|
+
cache.set("#{key}_conditions", conditions)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def create(args={})
|
56
|
+
self.new(args).tap do |obj|
|
57
|
+
obj.run_callbacks(:create) { obj.save }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def get_data(key, conditions=nil, method=:get)
|
62
|
+
if cacheable?
|
63
|
+
if conditions
|
64
|
+
cache_key = key + conditions.to_query
|
65
|
+
cache_conditions(key, conditions)
|
66
|
+
else
|
67
|
+
cache_key = key
|
68
|
+
end
|
69
|
+
|
70
|
+
Amfetamine.logger.info "Fetching object from cache: #{cache_key}"
|
71
|
+
cache.fetch(cache_key) do
|
72
|
+
Amfetamine.logger.info "Miss! #{cache_key}"
|
73
|
+
handle_request(method, key, { :query => conditions } )
|
74
|
+
end
|
75
|
+
else
|
76
|
+
handle_request(method,key, { :query => conditions })
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def clean_cache!
|
81
|
+
if cacheable?
|
82
|
+
cache.delete(rest_path)
|
83
|
+
condition_keys = cache.get("#{rest_path}_conditions") || []
|
84
|
+
condition_keys.each do |cc|
|
85
|
+
cache.delete(rest_path + cc)
|
86
|
+
end
|
87
|
+
Amfetamine.logger.info "Cleaned cache for #{self.model_name}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def save
|
93
|
+
if !valid?
|
94
|
+
return false
|
95
|
+
end
|
96
|
+
|
97
|
+
run_callbacks(:save) do
|
98
|
+
response = if self.new?
|
99
|
+
path = self.belongs_to_relationship? ? belongs_to_relationships.first.rest_path : rest_path
|
100
|
+
self.class.handle_request(:post, path, {:body => self.to_json })
|
101
|
+
else
|
102
|
+
# Needs cleaning up, also needs to work with multiple belongs_to relationships (optional, I guess)
|
103
|
+
path = self.belongs_to_relationship? ? belongs_to_relationships.first.singular_path : singular_path
|
104
|
+
self.class.handle_request(:put, path, {:body => self.to_json})
|
105
|
+
end
|
106
|
+
|
107
|
+
if handle_response(response)
|
108
|
+
begin
|
109
|
+
update_attributes_from_response(response[:body])
|
110
|
+
ensure
|
111
|
+
clean_cache!
|
112
|
+
end
|
113
|
+
cache.set(singular_path, self.to_cacheable) if cacheable?
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def destroy
|
119
|
+
if self.new?
|
120
|
+
return false
|
121
|
+
end
|
122
|
+
|
123
|
+
path = self.belongs_to_relationship? ? belongs_to_relationships.first.singular_path : singular_path
|
124
|
+
response = self.class.handle_request(:delete, path)
|
125
|
+
|
126
|
+
if handle_response(response)
|
127
|
+
clean_cache!
|
128
|
+
self.notsaved = true # Because its a new object if the server side got deleted
|
129
|
+
self.id = nil # Not saved? No ID.
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def clean_cache!
|
134
|
+
if cacheable?
|
135
|
+
cache.delete(singular_path)
|
136
|
+
cache.delete(rest_path)
|
137
|
+
belongs_to_relationships.each do |r|
|
138
|
+
cache.delete(r.singular_path)
|
139
|
+
cache.delete(r.rest_path)
|
140
|
+
condition_keys = cache.get("#{r.rest_path}_conditions") || []
|
141
|
+
condition_keys.each do |cc|
|
142
|
+
cache.delete(r.rest_path + cc)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
condition_keys = cache.get("#{rest_path}_conditions") || []
|
147
|
+
condition_keys.each do |cc|
|
148
|
+
cache.delete(rest_path + cc)
|
149
|
+
end
|
150
|
+
Amfetamine.logger.info "Cleaned cache for #{self.class_name} with ID #{self.id}"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
|
155
|
+
|
156
|
+
|
157
|
+
def update_attributes(attrs)
|
158
|
+
return true if attrs.all? { |k,v| self.send(k) == v } # Don't update if no attributes change
|
159
|
+
attrs.each { |k,v| self.send("#{k}=", v) }
|
160
|
+
self.save
|
161
|
+
end
|
162
|
+
|
163
|
+
|
164
|
+
def new?
|
165
|
+
@notsaved
|
166
|
+
end
|
167
|
+
|
168
|
+
def to_cacheable
|
169
|
+
{
|
170
|
+
:status => :success,
|
171
|
+
:body => {
|
172
|
+
self.class.name.downcase.to_sym => self.attributes
|
173
|
+
}
|
174
|
+
}
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def id=(id)
|
180
|
+
@id = id
|
181
|
+
end
|
182
|
+
|
183
|
+
def notsaved=(bool)
|
184
|
+
@notsaved = bool
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|