brainstem 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +4 -0
- data/Gemfile.lock +51 -0
- data/Guardfile +8 -0
- data/LICENSE +22 -0
- data/README.md +181 -0
- data/Rakefile +6 -0
- data/brainstem.gemspec +28 -0
- data/lib/brainstem.rb +63 -0
- data/lib/brainstem/association_field.rb +35 -0
- data/lib/brainstem/controller_methods.rb +44 -0
- data/lib/brainstem/engine.rb +4 -0
- data/lib/brainstem/presenter.rb +210 -0
- data/lib/brainstem/presenter_collection.rb +279 -0
- data/lib/brainstem/time_classes.rb +14 -0
- data/lib/brainstem/version.rb +3 -0
- data/spec/brainstem/controller_methods_spec.rb +68 -0
- data/spec/brainstem/presenter_collection_spec.rb +486 -0
- data/spec/brainstem/presenter_spec.rb +252 -0
- data/spec/brainstem_spec.rb +25 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/spec_helpers/cleanup.rb +23 -0
- data/spec/spec_helpers/db.rb +79 -0
- data/spec/spec_helpers/presenters.rb +39 -0
- metadata +201 -0
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
brainstem (0.0.2)
|
5
|
+
activerecord (~> 3.0)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
activemodel (3.2.13)
|
11
|
+
activesupport (= 3.2.13)
|
12
|
+
builder (~> 3.0.0)
|
13
|
+
activerecord (3.2.13)
|
14
|
+
activemodel (= 3.2.13)
|
15
|
+
activesupport (= 3.2.13)
|
16
|
+
arel (~> 3.0.2)
|
17
|
+
tzinfo (~> 0.3.29)
|
18
|
+
activesupport (3.2.13)
|
19
|
+
i18n (= 0.6.1)
|
20
|
+
multi_json (~> 1.0)
|
21
|
+
arel (3.0.2)
|
22
|
+
builder (3.0.4)
|
23
|
+
diff-lcs (1.2.2)
|
24
|
+
i18n (0.6.1)
|
25
|
+
multi_json (1.7.2)
|
26
|
+
rake (10.0.4)
|
27
|
+
redcarpet (2.2.2)
|
28
|
+
rr (1.0.5)
|
29
|
+
rspec (2.13.0)
|
30
|
+
rspec-core (~> 2.13.0)
|
31
|
+
rspec-expectations (~> 2.13.0)
|
32
|
+
rspec-mocks (~> 2.13.0)
|
33
|
+
rspec-core (2.13.1)
|
34
|
+
rspec-expectations (2.13.0)
|
35
|
+
diff-lcs (>= 1.1.3, < 2.0)
|
36
|
+
rspec-mocks (2.13.1)
|
37
|
+
sqlite3 (1.3.7)
|
38
|
+
tzinfo (0.3.37)
|
39
|
+
yard (0.8.5.2)
|
40
|
+
|
41
|
+
PLATFORMS
|
42
|
+
ruby
|
43
|
+
|
44
|
+
DEPENDENCIES
|
45
|
+
brainstem!
|
46
|
+
rake
|
47
|
+
redcarpet
|
48
|
+
rr
|
49
|
+
rspec
|
50
|
+
sqlite3
|
51
|
+
yard
|
data/Guardfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Mavenlink, Inc.
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
# Brainstem
|
2
|
+
|
3
|
+
Brainstem is designed to power rich APIs in Rails. The Brainstem gem provides a presenter library that handles converting ActiveRecord objects into structured JSON and a set of API abstractions that allow users to request sorts, filters, and association loads, allowing for simpler implementations, fewer requests, and smaller responses.
|
4
|
+
|
5
|
+
## Why Brainstem?
|
6
|
+
|
7
|
+
* Seperate business and presentation logic with Presenters.
|
8
|
+
* Version your Presenters for consistency as your API evolves.
|
9
|
+
* Expose end-user selectable filters and sorts.
|
10
|
+
* Whitelist your existing scopes to act as API filters for your users.
|
11
|
+
* Allow users to side-load multiple objects, with their associations, in a single request, reducing the number of requests needed to get the job done. This is especially helpful for building speedy mobile applications.
|
12
|
+
* Prevent data duplication by pulling associations into top-level hashes, easily indexable by ID.
|
13
|
+
* Easy integration with Backbone.js. "It's like Ember Data for Backbone.js!"
|
14
|
+
|
15
|
+
## Installation
|
16
|
+
|
17
|
+
Add this line to your application's Gemfile:
|
18
|
+
|
19
|
+
gem 'brainstem'
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
Create a class that inherits from Brainstem::Presenter, named after the model that you want to present, and preferrably versioned in a module. For example:
|
24
|
+
|
25
|
+
module Api
|
26
|
+
module V1
|
27
|
+
class WidgetPresenter < Brainstem::Presenter
|
28
|
+
presents "Widget"
|
29
|
+
|
30
|
+
# Available sort orders to expose through the API
|
31
|
+
sort_order :updated_at, "widgets.updated_at"
|
32
|
+
sort_order :created_at, "widgets.created_at"
|
33
|
+
|
34
|
+
# Default sort order to apply
|
35
|
+
default_sort_order "updated_at:desc"
|
36
|
+
|
37
|
+
# Optional filter that delegates to the Widget model :popular scope,
|
38
|
+
# which should take one argument of true or false.
|
39
|
+
filter :popular
|
40
|
+
|
41
|
+
# Optional filter that applies a lambda.
|
42
|
+
filter :location_name do |scope, location_name|
|
43
|
+
scope.joins(:locations).where("locations.name = ?", location_name)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Filter with an overridable default that runs on all requests.
|
47
|
+
filter :include_legacy_widgets, :default => false do |scope, bool|
|
48
|
+
bool ? scope : scope.without_legacy_widgets
|
49
|
+
end
|
50
|
+
|
51
|
+
# Return a ruby hash that can be converted to JSON
|
52
|
+
def present(widget)
|
53
|
+
{
|
54
|
+
:name => widget.name,
|
55
|
+
:legacy => widget.legacy?,
|
56
|
+
:updated_at => widget.updated_at,
|
57
|
+
:created_at => widget.created_at,
|
58
|
+
# Associations can be included by request
|
59
|
+
:features => association(:features),
|
60
|
+
:location => association(:location)
|
61
|
+
}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
Once you've created a presenter like the one above, pass requests through from your controller.
|
68
|
+
|
69
|
+
class Api::WidgetsController < ActionController::Base
|
70
|
+
include Brainstem::ControllerMethods
|
71
|
+
|
72
|
+
def index
|
73
|
+
render :json => present("widgets") { Widgets.visible_to(current_user) }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
The scope passed to `present` could contain any starting conditions that you'd like. Requests can have includes, filters, and sort orders.
|
78
|
+
|
79
|
+
GET /api/widgets.json?include=features&sort_order=popularity:desc&location_name=san+francisco
|
80
|
+
|
81
|
+
Responses will look like the following:
|
82
|
+
|
83
|
+
{
|
84
|
+
# Total number of results that matched the query.
|
85
|
+
count: 5,
|
86
|
+
|
87
|
+
# A lookup table to top-level keys. Necessary
|
88
|
+
# because some objects can have associations of
|
89
|
+
# the same type as themselves.
|
90
|
+
results: [
|
91
|
+
{ key: "widgets", id: "2" },
|
92
|
+
{ key: "widgets", id: "10" }
|
93
|
+
],
|
94
|
+
|
95
|
+
# Serialized models with any requested associations, keyed by ID.
|
96
|
+
|
97
|
+
widgets: {
|
98
|
+
"10": {
|
99
|
+
id: "10",
|
100
|
+
name: "disco ball",
|
101
|
+
feature_ids: ["5"],
|
102
|
+
popularity: 85,
|
103
|
+
location_id: "2"
|
104
|
+
},
|
105
|
+
|
106
|
+
"2": {
|
107
|
+
id: "2",
|
108
|
+
name: "flubber",
|
109
|
+
feature_ids: ["6", "12"],
|
110
|
+
popularity: 100,
|
111
|
+
location_id: "2"
|
112
|
+
}
|
113
|
+
},
|
114
|
+
|
115
|
+
features: {
|
116
|
+
"5": { id: "5", name: "shiny" },
|
117
|
+
"6": { id: "6", name: "bouncy" },
|
118
|
+
"12": { id: "12", name: "physically impossible" }
|
119
|
+
}
|
120
|
+
}
|
121
|
+
|
122
|
+
You may want to setup an initializer in `config/initializers/brainstem.rb` like the following:
|
123
|
+
|
124
|
+
Brainstem.default_namespace = :v1
|
125
|
+
|
126
|
+
module Api
|
127
|
+
module V1
|
128
|
+
module Helper
|
129
|
+
def current_user
|
130
|
+
# However you get your current user.
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
Brainstem::Presenter.helper(Api::V1::Helper)
|
136
|
+
|
137
|
+
require 'api/v1/widget_presenter'
|
138
|
+
require 'api/v1/feature_presenter'
|
139
|
+
require 'api/v1/location_presenter'
|
140
|
+
# ...
|
141
|
+
|
142
|
+
# Or you could do something like this:
|
143
|
+
# Dir[Rails.root.join("lib/api/v1/*_presenter.rb").to_s].each { |p| require p }
|
144
|
+
|
145
|
+
For more detailed examples, please see the documentation for methods on {Brainstem::Presenter} and our detailed [Rails example application](https://github.com/mavenlink/brainstem-demo-rails).
|
146
|
+
|
147
|
+
## Consuming a Brainstem API
|
148
|
+
|
149
|
+
APIs presented with Brainstem are just JSON APIs, so they can be consumed with just about any language. As Brainstem evolves, we hope that people will contributed consumption libraries in various languages.
|
150
|
+
|
151
|
+
### The Results Array
|
152
|
+
|
153
|
+
{
|
154
|
+
results: [
|
155
|
+
{ key: "widgets", id: "2" }, { key: "widgets", id: "10" }
|
156
|
+
],
|
157
|
+
|
158
|
+
widgets: {
|
159
|
+
"10": {
|
160
|
+
id: "10",
|
161
|
+
name: "disco ball",
|
162
|
+
…
|
163
|
+
|
164
|
+
|
165
|
+
Brainstem returns objects as top-level hashes and provides a `results` array of `key` and `id` objects for finding the returned data in those hashes. The reason that we use the `results` array is two-fold: 1st) it provides order outside of the serialized objects so that we can provide objects keyed by ID, and 2nd) it allows for polymorphic responses and for objects that have associations of their own type (like posts and replies or tasks and sub-tasks).
|
166
|
+
|
167
|
+
### Brainstem and Backbone.js
|
168
|
+
|
169
|
+
If you're already using Backbone.js, integrating with a Brainstem API is super simple. Just use the [Brainstem.js](https://github.com/mavenlink/brainstem-js) gem (or its JavaScript contents) to access your relational Brainstem API from JavaScript.
|
170
|
+
|
171
|
+
## Contributing
|
172
|
+
|
173
|
+
1. Fork Brainstem or Brainstem.js
|
174
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
175
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
176
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
177
|
+
5. Create new Pull Request (`git pull-request`)
|
178
|
+
|
179
|
+
## License
|
180
|
+
|
181
|
+
Brainstem and Brainstem.js were created by Mavenlink, Inc. and are available under the MIT License.
|
data/Rakefile
ADDED
data/brainstem.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
|
3
|
+
require "brainstem/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |gem|
|
6
|
+
gem.name = "brainstem"
|
7
|
+
gem.authors = ["Sufyan Adam", "André Arko", "Andrew Cantino", "Katlyn Daniluk", "Reid Gillette"]
|
8
|
+
gem.email = ["dev@mavenlink.com"]
|
9
|
+
gem.description = %q{Brainstem allows you to create rich API presenters that know how to filter, sort, and include associations.}
|
10
|
+
gem.summary = %q{ActiveRecord presenters with a rich request API}
|
11
|
+
gem.homepage = "http://developer.mavenlink.com"
|
12
|
+
gem.license = "MIT"
|
13
|
+
|
14
|
+
gem.files = Dir["**/*"]
|
15
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
16
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
17
|
+
gem.require_paths = ["lib"]
|
18
|
+
gem.version = Brainstem::VERSION
|
19
|
+
|
20
|
+
gem.add_dependency "activerecord", "~> 3.0"
|
21
|
+
|
22
|
+
gem.add_development_dependency "rake"
|
23
|
+
gem.add_development_dependency "redcarpet" # for markdown in yard
|
24
|
+
gem.add_development_dependency "rr"
|
25
|
+
gem.add_development_dependency "rspec"
|
26
|
+
gem.add_development_dependency "sqlite3"
|
27
|
+
gem.add_development_dependency "yard"
|
28
|
+
end
|
data/lib/brainstem.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require "brainstem/version"
|
2
|
+
require "brainstem/presenter"
|
3
|
+
require "brainstem/presenter_collection"
|
4
|
+
require "brainstem/controller_methods"
|
5
|
+
|
6
|
+
# The Brainstem module itself contains a +default_namespace+ class attribute and a few helpers that make managing +PresenterCollections+ and their corresponding namespaces easier.
|
7
|
+
module Brainstem
|
8
|
+
|
9
|
+
# Sets {default_namespace} to a new value.
|
10
|
+
# @param [String] namespace
|
11
|
+
# @return [String] the new default namespace
|
12
|
+
def self.default_namespace=(namespace)
|
13
|
+
@default_namespace = namespace
|
14
|
+
end
|
15
|
+
|
16
|
+
# The namespace that will be used by {presenter_collection} and {add_presenter_class} if none is given or implied.
|
17
|
+
# @return [String] the default namespace
|
18
|
+
def self.default_namespace
|
19
|
+
@default_namespace || "none"
|
20
|
+
end
|
21
|
+
|
22
|
+
# @param [String] namespace
|
23
|
+
# @return [PresenterCollection] the {PresenterCollection} for the given namespace.
|
24
|
+
def self.presenter_collection(namespace = nil)
|
25
|
+
namespace ||= default_namespace
|
26
|
+
@presenter_collection ||= {}
|
27
|
+
@presenter_collection[namespace.to_s.downcase] ||= PresenterCollection.new
|
28
|
+
end
|
29
|
+
|
30
|
+
# Helper method to quickly add presenter classes that are in a namespace. For example, +add_presenter_class(Api::V1::UserPresenter, "User")+ would add +UserPresenter+ to the PresenterCollection for the +:v1+ namespace as the presenter for the +User+ class.
|
31
|
+
# @param [Brainstem::Presenter] presenter_class The presenter class that is being registered.
|
32
|
+
# @param [Array<String, Class>] klasses Classes that will be presented by the given presenter.
|
33
|
+
def self.add_presenter_class(presenter_class, *klasses)
|
34
|
+
presenter_collection(namespace_of(presenter_class)).add_presenter_class(presenter_class, *klasses)
|
35
|
+
end
|
36
|
+
|
37
|
+
# @param [Class] klass The Ruby class whose namespace we would like to know.
|
38
|
+
# @return [String] The name of the module containing the passed-in class.
|
39
|
+
def self.namespace_of(klass)
|
40
|
+
names = klass.to_s.split("::")
|
41
|
+
names[-2] ? names[-2] : default_namespace
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [Logger] The Brainstem logger. If Rails is loaded, defaults to the Rails logger. If Rails is not loaded, defaults to a STDOUT logger.
|
45
|
+
def self.logger
|
46
|
+
@logger ||= begin
|
47
|
+
if defined?(Rails)
|
48
|
+
Rails.logger
|
49
|
+
else
|
50
|
+
require "logger"
|
51
|
+
Logger.new(STDOUT)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Sets a new Brainstem logger.
|
57
|
+
# @param [Logger] logger A new Brainstem logger.
|
58
|
+
# @return [Logger] The new Brainstem logger.
|
59
|
+
def self.logger=(logger)
|
60
|
+
@logger = logger
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Brainstem
|
2
|
+
# AssociationField acts as a standin for associations.
|
3
|
+
# @api private
|
4
|
+
class AssociationField
|
5
|
+
# @!attribute [r] method_name
|
6
|
+
# @return [Symbol] The name of the method that is being proxied.
|
7
|
+
attr_reader :method_name
|
8
|
+
|
9
|
+
# @!attribute [r] json_name
|
10
|
+
# @return [Symbol] The name of the top-level JSON key for objects provided by this association.
|
11
|
+
attr_accessor :json_name
|
12
|
+
|
13
|
+
# @param method_name The name of the method being proxied. Not required if
|
14
|
+
# a block is passed instead.
|
15
|
+
# @option options [Boolean] :json_name The name of the top-level JSON key for objects provided by this association.
|
16
|
+
def initialize(method_name = nil, options = {}, &block)
|
17
|
+
@json_name = options[:json_name]
|
18
|
+
if block_given?
|
19
|
+
raise ArgumentError, "Method name is invalid with a block" if method_name
|
20
|
+
@block = block
|
21
|
+
elsif method_name
|
22
|
+
@method_name = method_name
|
23
|
+
else
|
24
|
+
raise ArgumentError, "Method name or block is required"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Call the method or block being proxied.
|
29
|
+
# @param model The object to call the proxied method on.
|
30
|
+
# @return The value returned by calling the method or block being proxied.
|
31
|
+
def call(model)
|
32
|
+
@block ? @block.call : model.send(@method_name)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Brainstem
|
2
|
+
|
3
|
+
# ControllerMethods are intended to be included into controllers that will be handling requests for presented objects.
|
4
|
+
# The present method will pass through +params+, so that any allowed and requested includes, filters, sort orders
|
5
|
+
# will be applied to the presented data.
|
6
|
+
module ControllerMethods
|
7
|
+
|
8
|
+
# Return a Ruby hash that contains models requested by the user's params and allowed
|
9
|
+
# by the +name+ presenter's configuration.
|
10
|
+
#
|
11
|
+
# Pass the returned hash to the render method to convert it into a useful format.
|
12
|
+
# For example:
|
13
|
+
# render :json => present("post"){ Post.where(:draft => false) }
|
14
|
+
# @param (see PresenterCollection#presenting)
|
15
|
+
# @option options [String] :namespace ("none") the namespace to be presented from
|
16
|
+
# @yield (see PresenterCollection#presenting)
|
17
|
+
# @return (see PresenterCollection#presenting)
|
18
|
+
def present(name, options = {}, &block)
|
19
|
+
Brainstem.presenter_collection(options[:namespace]).presenting(name, options.reverse_merge(:params => params), &block)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Similar to ControllerMethods#present, but always returns all of the given objects, not just those that match any provided
|
23
|
+
# filters.
|
24
|
+
# @option options [String] :namespace ("none") the namespace to be presented from
|
25
|
+
# @option options [Hash] :key_map a Hash from Class name to json key name, if desired.
|
26
|
+
# e.g., map 'SystemWidgets' objects to the 'widgets' key in the JSON. This is
|
27
|
+
# only required if the name cannot be inferred.
|
28
|
+
# @return (see PresenterCollection#presenting)
|
29
|
+
def present_object(objects, options = {})
|
30
|
+
options.reverse_merge(:params => params)
|
31
|
+
if objects.is_a?(ActiveRecord::Relation) || objects.is_a?(Array)
|
32
|
+
raise ActiveRecord::RecordNotFound if objects.empty?
|
33
|
+
klass = objects.first.class
|
34
|
+
ids = objects.map(&:id)
|
35
|
+
else
|
36
|
+
klass = objects.class
|
37
|
+
ids = objects.id
|
38
|
+
end
|
39
|
+
json_key = (options[:key_map] || {})[klass.to_s] || klass.table_name
|
40
|
+
present(klass, :as => json_key, :apply_default_filters => false){ klass.where(:id => ids) }
|
41
|
+
end
|
42
|
+
alias_method :present_objects, :present_object
|
43
|
+
end
|
44
|
+
end
|