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
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm use ruby-1.9.3-p0
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,196 @@
|
|
1
|
+
Amfetamine, A REST object abstractavaganza
|
2
|
+
====================================
|
3
|
+
|
4
|
+
> Makes your API calls f-f-f-ast!
|
5
|
+
|
6
|
+
Amfetamine adds an ActiveModel like interface to your REST services with support for any HTTParty client and caching using memcached.
|
7
|
+
|
8
|
+
Features
|
9
|
+
--------
|
10
|
+
|
11
|
+
It is still in beta and under heavy development. Some features:
|
12
|
+
|
13
|
+
* Mapping of REST services to objects.
|
14
|
+
* HTTP configuration agnostic; it works with any configuration of HTTParty. It should work with any object that has a similar syntax.
|
15
|
+
* Reasonably effective object caching with memcached.
|
16
|
+
* It has a lot of methods that you expect from ActiveModel objects. Find, all, save, create, new, destroy, update_attributes, update.
|
17
|
+
* It supports all validation methods ActiveModel provides, thanks to ActiveModel -red.
|
18
|
+
* You can fully configure your memcached client, it uses Dalli.
|
19
|
+
* Although the client has been tested and build with JSON, it should support XML as well.
|
20
|
+
* It supports single nested resources for now.
|
21
|
+
* It supports conditions in the all method (find method asap), just provice a :conditions hash like you're used to.
|
22
|
+
* Supports global HTTP and Memcached client as well as per object overriding.
|
23
|
+
* If a request passes validation on client side and not on service side, the client properly sets error messages from the service.
|
24
|
+
* Provides testing helpers
|
25
|
+
* Amfetamine supports some basic callbacks: before_save, after_save, around_save and before_create. More coming as needed.
|
26
|
+
|
27
|
+
Setup
|
28
|
+
=====
|
29
|
+
|
30
|
+
### 1)
|
31
|
+
Add it to your gemfile (not released yet):
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
gem 'amfetamine'
|
35
|
+
```
|
36
|
+
|
37
|
+
### 2)
|
38
|
+
Create an initializer amfetamine_initializer.rb:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
Amfetamine::Config.configure do |config|
|
42
|
+
config.memcached_instance = [HOST:PORT, OPTION1,OPTION2] || HOST:PORT
|
43
|
+
config.rest_client = REST_CLIENT
|
44
|
+
|
45
|
+
# Optional
|
46
|
+
config.resource_suffix = '.json' # The suffix to the path
|
47
|
+
config.base_uri = 'http://bla.bla/bla/' # If you either need a path or domain infront of your URI, you can do it here. Its advised to use httparty for this.
|
48
|
+
end
|
49
|
+
```
|
50
|
+
|
51
|
+
### 3)
|
52
|
+
Configure your object:
|
53
|
+
|
54
|
+
```ruby
|
55
|
+
class Banana < Amfetamine::Base
|
56
|
+
# You need to setup an attribute for each attribute your object has, apart from id (thats _mandatory_)
|
57
|
+
amfetamine_attributes :name, :shape, :color, :created_at, :updated_at
|
58
|
+
|
59
|
+
# OPTIONAL: Per object configuration
|
60
|
+
amfetamine_configure memcached_instance: 'localhost:11211',
|
61
|
+
rest_client: BananaRestclient
|
62
|
+
|
63
|
+
end
|
64
|
+
```
|
65
|
+
|
66
|
+
|
67
|
+
### 4)
|
68
|
+
Lastly, because I think its more semantic, you need to configure both your service and client to include the root element in JSON. However, Amfetamine will work fine without this.
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
# config/initializers/wrap_parameters.rb
|
72
|
+
ActiveSupport.on_load(:action_controller) do
|
73
|
+
wrap_parameters :format => [:json]
|
74
|
+
end
|
75
|
+
```
|
76
|
+
|
77
|
+
Usage
|
78
|
+
=====
|
79
|
+
|
80
|
+
### Relationships
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
has_many_resources PLURAL_OBJECT_NAME_SYMBOLS
|
84
|
+
belongs_to_resource SINGULAR_OBJECT_NAME_SYMBOL
|
85
|
+
|
86
|
+
parent.children.all # => Returns all nested resources, you can enumarate it with each, include? and several other helpers are available
|
87
|
+
parent.children.all(:conditions => SOMETHING) # Works as expected
|
88
|
+
parent.children << child # Sets a child to a parent, child still needs to be saved. This will append it to the current all array and set the parent_id, accessing #all will overwrite that array.
|
89
|
+
parent.children.find(ID) # => Returns the nested child with ID
|
90
|
+
children.parent # => returns a Amfetamine::Relationship with only the parent
|
91
|
+
```
|
92
|
+
|
93
|
+
### Querying
|
94
|
+
|
95
|
+
```ruby
|
96
|
+
Object.all # => returns all objects, request: /objects
|
97
|
+
Object.all(conditions: {:other_parent_id => 2} ) # => request: objects?other_parent_id=2
|
98
|
+
Object.find(ID) # => returns object with ID, request: objects/ID
|
99
|
+
```
|
100
|
+
|
101
|
+
### Modifying data
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
object.save
|
105
|
+
object.destroy
|
106
|
+
object.update_attributes(HASH)
|
107
|
+
Object.create(HASH)
|
108
|
+
```
|
109
|
+
|
110
|
+
Cache Invalidation
|
111
|
+
=================
|
112
|
+
|
113
|
+
Objects are cached by request with the body as value. Request status codes are also cached. Every time an object is created, destroyed or updated, the plural cache is also invalidated.
|
114
|
+
|
115
|
+
You can invalidate an object's cache any time by calling `clean_cache!` on an object. You can flush the whole cache by calling flush on either a class or Amfetamine::Cache.
|
116
|
+
|
117
|
+
Testing
|
118
|
+
=======
|
119
|
+
|
120
|
+
Amfetamine provides a testing helper to easilly stub out responses from external services, so you can better control what response you get.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
# Rspec:
|
124
|
+
before do
|
125
|
+
AmfetamineObject.stub_responses! do |r|
|
126
|
+
# Setting the code / path is optional. If amfetamine picks the wrong path, this will give you some weird errors.
|
127
|
+
r.post(path: '/bananas/', code: 201) { some_object }
|
128
|
+
r.get { some_object } # this sets to the default for gets on the rest_client this object uses
|
129
|
+
end
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
Also, if you're using a cache, you should flush the cache before each test to avoid confusion.
|
134
|
+
|
135
|
+
Building Custom Methods
|
136
|
+
=======================
|
137
|
+
|
138
|
+
Its important to note that caching might not work as expected when building custom methods. For now, please refer to the code.
|
139
|
+
|
140
|
+
Testing
|
141
|
+
=======
|
142
|
+
|
143
|
+
Amfetamine provides several testing helpers to make testing easier. I didn't think it would be wise to allow external connections, but I didn't want you to have to stub out all methods either.
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
Object.prevent_external_connections! # Raises an error if any external connections are made on this object
|
147
|
+
|
148
|
+
# You can provide a block as well, after the block the rest_client is set back to the default:
|
149
|
+
Object.prevent_external_connections! do |rest_client|
|
150
|
+
rest_client.should_receive('get').with('/objects').and_return(objects.to_json)
|
151
|
+
Object.all
|
152
|
+
end
|
153
|
+
|
154
|
+
# You can also use a dsl to predefine responses up front on a per object basis. You can use rspec 'let' objects in the response as well.
|
155
|
+
Object.stub_external_responses! do |r|
|
156
|
+
r.get {object}
|
157
|
+
end
|
158
|
+
|
159
|
+
r.all # => object, also will yield an error, expects an array
|
160
|
+
r.find(1) # => object
|
161
|
+
|
162
|
+
# You can go wilder with this as well so you can allow multiple requests. You can also use this dsl on the rest_client in #prevent_external_connections!
|
163
|
+
Object.stub_external_responses! do |r|
|
164
|
+
r.get(path: '/objects') { [object] } # Returns [object].to_json
|
165
|
+
r.get(path: "/objects/#{object.id}", code: 404) {} # Returns a resource not found
|
166
|
+
end
|
167
|
+
```
|
168
|
+
|
169
|
+
TODO
|
170
|
+
|
171
|
+
Future Features
|
172
|
+
===============
|
173
|
+
|
174
|
+
* Smarter caching
|
175
|
+
* Better support for custom methods
|
176
|
+
* Support for Reddis
|
177
|
+
* Automatic determining of attributes and validations
|
178
|
+
* Supporting any amount of nested relationships
|
179
|
+
* Supporting interobject relationship (database versus service)
|
180
|
+
* More callbacks
|
181
|
+
* Better typecasting, it doesn't always work.
|
182
|
+
|
183
|
+
Licence
|
184
|
+
=======
|
185
|
+
|
186
|
+
TBD
|
187
|
+
|
188
|
+
Contributing
|
189
|
+
============
|
190
|
+
|
191
|
+
Please do a pull request and test your code!
|
192
|
+
|
193
|
+
Contributors
|
194
|
+
============
|
195
|
+
|
196
|
+
* Timon Vonk
|
data/Rakefile
ADDED
data/amfetamine.gemspec
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "amfetamine/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "amfetamine"
|
7
|
+
s.version = Amfetamine::VERSION
|
8
|
+
s.authors = ["Timon Vonk"]
|
9
|
+
s.email = ["timon@exvo.com"]
|
10
|
+
s.homepage = "http://www.github.com/exvo/amfetamine"
|
11
|
+
s.summary = %q{Wraps REST to objects}
|
12
|
+
s.description = %q{Wraps REST to objects, provides caching and makes your app go Bzzz!}
|
13
|
+
|
14
|
+
s.rubyforge_project = "amfetamine"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
# Development dependencies
|
22
|
+
s.add_development_dependency "rspec"
|
23
|
+
s.add_development_dependency "guard"
|
24
|
+
s.add_development_dependency "guard-rspec"
|
25
|
+
#s.add_development_dependency "growl_notify"
|
26
|
+
#s.add_development_dependency "rb-fsevent"
|
27
|
+
s.add_development_dependency "growl"
|
28
|
+
s.add_development_dependency "httparty"
|
29
|
+
s.add_development_dependency "fakeweb"
|
30
|
+
s.add_development_dependency "simplecov"
|
31
|
+
s.add_development_dependency "simplecov-rcov"
|
32
|
+
|
33
|
+
|
34
|
+
# Runtime dependencies
|
35
|
+
s.add_runtime_dependency "dalli"
|
36
|
+
s.add_runtime_dependency "activesupport" # For helper methods
|
37
|
+
s.add_runtime_dependency "activemodel" # For validations and AM like behaviour
|
38
|
+
s.add_runtime_dependency "json"
|
39
|
+
s.add_runtime_dependency "rake"
|
40
|
+
end
|
data/lib/amfetamine.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require "amfetamine/version"
|
2
|
+
require "amfetamine/helpers/test_helpers" # Testing helper methods
|
3
|
+
require "amfetamine/exceptions"
|
4
|
+
require "amfetamine/logger"
|
5
|
+
require 'amfetamine/relationship'
|
6
|
+
require "amfetamine/relationships"
|
7
|
+
require "amfetamine/caching_adapter" # Adapter that wraps memcache methods
|
8
|
+
require "amfetamine/cache" # Common caching methods
|
9
|
+
require "amfetamine/rest_helpers" # Methods for determining REST paths
|
10
|
+
require "amfetamine/query_methods" # Methods for interfacing with the classs
|
11
|
+
require "amfetamine/base" # Basics
|
12
|
+
require "amfetamine/config" # Configuration class
|
13
|
+
|
14
|
+
module Amfetamine
|
15
|
+
|
16
|
+
def self.logger
|
17
|
+
Amfetamine::Logger.instance
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
|
3
|
+
module Amfetamine
|
4
|
+
class Base
|
5
|
+
# Activemodel
|
6
|
+
extend ActiveModel::Naming
|
7
|
+
extend ActiveModel::Callbacks
|
8
|
+
include ActiveModel::Validations
|
9
|
+
include ActiveModel::Serialization
|
10
|
+
include ActiveModel::Serializers::JSON
|
11
|
+
|
12
|
+
#Callbacks
|
13
|
+
define_model_callbacks :create, :save
|
14
|
+
|
15
|
+
|
16
|
+
# amfetamine
|
17
|
+
include Amfetamine::RestHelpers
|
18
|
+
include Amfetamine::QueryMethods
|
19
|
+
include Amfetamine::Relationships
|
20
|
+
|
21
|
+
# Testing
|
22
|
+
include Amfetamine::TestHelpers
|
23
|
+
|
24
|
+
|
25
|
+
attr_reader :attributes
|
26
|
+
|
27
|
+
def id=(val)
|
28
|
+
@attributes['id'] = val
|
29
|
+
end
|
30
|
+
|
31
|
+
def id
|
32
|
+
@attributes['id']
|
33
|
+
end
|
34
|
+
|
35
|
+
private :'id='
|
36
|
+
|
37
|
+
|
38
|
+
|
39
|
+
def self.amfetamine_attributes(*attrs)
|
40
|
+
attrs.each do |attr|
|
41
|
+
define_method("#{attr}=") do |arg|
|
42
|
+
@attributes[attr.to_s] = arg
|
43
|
+
end
|
44
|
+
|
45
|
+
define_method("#{attr}") do
|
46
|
+
@attributes[attr.to_s]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.amfetamine_configure(hash)
|
52
|
+
hash.each do |k,v|
|
53
|
+
self.send("#{k.to_s}=", v)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Builds an object from JSON, later on will need more (maybe object id? Or should that go in find?)
|
58
|
+
# It parses the hash, builds the objects and sets new to false
|
59
|
+
def self.build_object(args)
|
60
|
+
# Cache corruption guard
|
61
|
+
args = normalize_cache_data(args)
|
62
|
+
|
63
|
+
obj = self.new(args)
|
64
|
+
obj.tap { |obj| obj.instance_variable_set('@notsaved',false) } # because I don't want a global writer
|
65
|
+
end
|
66
|
+
|
67
|
+
def update_attributes_from_response(args)
|
68
|
+
# We need to check this. If an api provides new data after an update, it will be set :-)
|
69
|
+
# Some apis return "nil" or something like that, so we need to double check its a hash
|
70
|
+
|
71
|
+
# TODO: Remove if statement because validation has been added
|
72
|
+
if args && args.is_a?(Hash) && args.has_key?(self.class_name)
|
73
|
+
args = args[self.class_name]
|
74
|
+
args.each { |k,v| self.send("#{k}=", v); self.attributes[k.to_sym] = v }
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Allows you to override the global caching server
|
79
|
+
def self.memcached_instance=(value, options={})
|
80
|
+
if value.is_a?(Array)
|
81
|
+
@cache_server = Amfetamine::Cache.new(value.shift, value.first) # First element is the server, second must be the options
|
82
|
+
else
|
83
|
+
@cache_server = Amfetamine::Cache.new(value, options)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Base method for creating objects
|
88
|
+
def initialize(args={})
|
89
|
+
super
|
90
|
+
@attributes = {}
|
91
|
+
args.each { |k,v| self.send("#{k}=", v) }
|
92
|
+
@notsaved = true
|
93
|
+
self
|
94
|
+
end
|
95
|
+
|
96
|
+
def is_attribute?(attr)
|
97
|
+
@attributes.keys.include?(attr.to_sym)
|
98
|
+
end
|
99
|
+
|
100
|
+
def persisted?
|
101
|
+
!new?
|
102
|
+
end
|
103
|
+
|
104
|
+
def to_model
|
105
|
+
self
|
106
|
+
end
|
107
|
+
|
108
|
+
def to_json(*gen)
|
109
|
+
options = {}
|
110
|
+
options.merge!(:root => self.class.model_name.element)
|
111
|
+
super(self.as_json(options))
|
112
|
+
end
|
113
|
+
|
114
|
+
def to_key
|
115
|
+
persisted? ? [id] : nil
|
116
|
+
end
|
117
|
+
|
118
|
+
def to_param
|
119
|
+
persisted? ? id.to_s : nil
|
120
|
+
end
|
121
|
+
|
122
|
+
# Checks if object is cached
|
123
|
+
# TODO this is not very efficient, but dalli doesn't provide a polling function :(
|
124
|
+
def cached?
|
125
|
+
keys = belongs_to_relationships.collect { |r| r.singular_path } << self.singular_path
|
126
|
+
keys.any? { |k| cache.get(k) }
|
127
|
+
end
|
128
|
+
|
129
|
+
# Checks if object is cachable
|
130
|
+
# TODO implement
|
131
|
+
def self.cacheable?
|
132
|
+
true
|
133
|
+
end
|
134
|
+
|
135
|
+
def cacheable?
|
136
|
+
self.class.cacheable?
|
137
|
+
end
|
138
|
+
|
139
|
+
# Checks to see if an object is valid or not
|
140
|
+
def valid?
|
141
|
+
errors.clear
|
142
|
+
run_validations!
|
143
|
+
end
|
144
|
+
|
145
|
+
# We need to redefine this so it doesn't check on object_id
|
146
|
+
def ==(other)
|
147
|
+
return false unless self.id == other.id # Some APIs dont ALWAYS return an ID
|
148
|
+
|
149
|
+
self.attributes.all? do |k,v|
|
150
|
+
self.attributes[k] == other.attributes[k]
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def errors
|
155
|
+
@errors ||= ActiveModel::Errors.new(self)
|
156
|
+
end
|
157
|
+
|
158
|
+
|
159
|
+
|
160
|
+
def class_name
|
161
|
+
self.class.class_name
|
162
|
+
end
|
163
|
+
|
164
|
+
def self.class_name
|
165
|
+
self.name.downcase
|
166
|
+
end
|
167
|
+
|
168
|
+
protected
|
169
|
+
def self.cache
|
170
|
+
@cache_server || Amfetamine::Cache
|
171
|
+
end
|
172
|
+
|
173
|
+
def cache
|
174
|
+
self.class.cache
|
175
|
+
end
|
176
|
+
|
177
|
+
# TODO: Refactor > cache, only cache should know if data is valid.
|
178
|
+
def self.normalize_cache_data(args)
|
179
|
+
# Validation predicates
|
180
|
+
raise InvalidCacheData, "Empty data" if args.nil?
|
181
|
+
raise InvalidCacheData, "Invalid data: #{args.to_s}" if !args.is_a?(Hash)
|
182
|
+
args.stringify_keys!
|
183
|
+
args = args[class_name] || args
|
184
|
+
# TODO remove [:id], stringify_keys! _should_ nail this.
|
185
|
+
raise InvalidCacheData, "No object or ID #{args}" unless args.present? && (args["id"] || args[:id])
|
186
|
+
args
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|