brainstem 0.2.6.1 → 1.0.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +5 -13
  2. data/CHANGELOG.md +16 -2
  3. data/Gemfile.lock +51 -36
  4. data/README.md +531 -110
  5. data/brainstem.gemspec +6 -2
  6. data/lib/brainstem.rb +25 -9
  7. data/lib/brainstem/concerns/controller_param_management.rb +22 -0
  8. data/lib/brainstem/concerns/error_presentation.rb +58 -0
  9. data/lib/brainstem/concerns/inheritable_configuration.rb +29 -0
  10. data/lib/brainstem/concerns/lookup.rb +30 -0
  11. data/lib/brainstem/concerns/presenter_dsl.rb +111 -0
  12. data/lib/brainstem/controller_methods.rb +17 -8
  13. data/lib/brainstem/dsl/association.rb +55 -0
  14. data/lib/brainstem/dsl/associations_block.rb +12 -0
  15. data/lib/brainstem/dsl/base_block.rb +31 -0
  16. data/lib/brainstem/dsl/conditional.rb +25 -0
  17. data/lib/brainstem/dsl/conditionals_block.rb +15 -0
  18. data/lib/brainstem/dsl/configuration.rb +112 -0
  19. data/lib/brainstem/dsl/field.rb +68 -0
  20. data/lib/brainstem/dsl/fields_block.rb +25 -0
  21. data/lib/brainstem/preloader.rb +98 -0
  22. data/lib/brainstem/presenter.rb +325 -134
  23. data/lib/brainstem/presenter_collection.rb +82 -286
  24. data/lib/brainstem/presenter_validator.rb +96 -0
  25. data/lib/brainstem/query_strategies/README.md +107 -0
  26. data/lib/brainstem/query_strategies/base_strategy.rb +62 -0
  27. data/lib/brainstem/query_strategies/filter_and_search.rb +50 -0
  28. data/lib/brainstem/query_strategies/filter_or_search.rb +103 -0
  29. data/lib/brainstem/test_helpers.rb +5 -1
  30. data/lib/brainstem/version.rb +1 -1
  31. data/spec/brainstem/concerns/controller_param_management_spec.rb +42 -0
  32. data/spec/brainstem/concerns/error_presentation_spec.rb +113 -0
  33. data/spec/brainstem/concerns/inheritable_configuration_spec.rb +210 -0
  34. data/spec/brainstem/concerns/presenter_dsl_spec.rb +412 -0
  35. data/spec/brainstem/controller_methods_spec.rb +15 -27
  36. data/spec/brainstem/dsl/association_spec.rb +123 -0
  37. data/spec/brainstem/dsl/conditional_spec.rb +93 -0
  38. data/spec/brainstem/dsl/configuration_spec.rb +1 -0
  39. data/spec/brainstem/dsl/field_spec.rb +212 -0
  40. data/spec/brainstem/preloader_spec.rb +137 -0
  41. data/spec/brainstem/presenter_collection_spec.rb +565 -244
  42. data/spec/brainstem/presenter_spec.rb +726 -167
  43. data/spec/brainstem/presenter_validator_spec.rb +209 -0
  44. data/spec/brainstem/query_strategies/filter_and_search_spec.rb +46 -0
  45. data/spec/brainstem/query_strategies/filter_or_search_spec.rb +45 -0
  46. data/spec/spec_helper.rb +11 -3
  47. data/spec/spec_helpers/db.rb +32 -65
  48. data/spec/spec_helpers/presenters.rb +124 -29
  49. data/spec/spec_helpers/rr.rb +11 -0
  50. data/spec/spec_helpers/schema.rb +115 -0
  51. metadata +126 -30
  52. data/lib/brainstem/association_field.rb +0 -53
  53. data/lib/brainstem/engine.rb +0 -4
  54. data/pkg/brainstem-0.2.5.gem +0 -0
  55. data/pkg/brainstem-0.2.6.gem +0 -0
  56. data/spec/spec_helpers/cleanup.rb +0 -23
checksums.yaml CHANGED
@@ -1,15 +1,7 @@
1
1
  ---
2
- !binary "U0hBMQ==":
3
- metadata.gz: !binary |-
4
- ZjgxZDgwYTRkN2Y0NGU0Y2NhMmFiYjMxZmJjNWE0ZmNjOWY3NWQ4Yw==
5
- data.tar.gz: !binary |-
6
- ZDY4MmYyNjBlZTNlMDJmMmU3Yjk1MDZhYjJhNTY0MDQ3NmY3MjRmMg==
2
+ SHA1:
3
+ metadata.gz: a8bd5fc16e1e1886466bd5d575c4ea7217a7bf0d
4
+ data.tar.gz: 0cc3b8df6885253815b47108273153e67105bfd2
7
5
  SHA512:
8
- metadata.gz: !binary |-
9
- NzZkOGU5Y2FlN2I0OTY0ZTkwMzkxYzkyYzMyOTc2MDU5MTA4ODk2NTI0ODEy
10
- NzhmYzViODBmN2M2NmFkODk4NTdiM2U4YzBhNTZjODQ1NTllZmUxOWUzZWM3
11
- MzFiMmEzMDViNGJkNjkwMmM0NzE1MTg0N2JhYjJkMzVmYzM1NTk=
12
- data.tar.gz: !binary |-
13
- Mzk5ZmNlMmQ4ZGMxODI0OTY0ZjU1Y2FlYjQ1MDAwOWJiZjk0MDNlMzJhNjVi
14
- N2RmMjkwZTUzZjJmM2UyOWE3OWQ1MTMwMzBhMWEyMjZjNjY1MDgwNGY5MTJj
15
- YmI2NDBjZTA1N2EzM2Q3ZjFkZWRkNzk3NGYyZDBjZjdjNzY5MGM=
6
+ metadata.gz: 3aa58b60f630f1f89875a7afce69e51faf6745c2b8a1f93495d57f754a208b17d80736aba4fd2ca2e08612bd41e570441e6faf19bca5cb70204a0e7678d2c1ad
7
+ data.tar.gz: 9b090b4841129d96d5581ee116eba190e40cfcd214fd1893845e8019a563f8c90c9214c298ba40e7e6047cb8501093162c2fce74b2517a18492f5752be796167
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ + **1.0.0.pre.1** - _03/07/2017_
4
+ - Implemented new presenter DSL.
5
+ - Added controller helpers for presenting errors.
6
+ - Added support for optional fields.
7
+ - Added support for filtering in conjunction with your search implementation.
8
+ - Added support for defining lookup caches for dynamic fields.
9
+ - Fixed: documentation for default filters.
10
+ - Fixed: ambiguity of `brainstem_key` for presenters that present multiple classes.
11
+ - Fixed: non-deterministic order when sorting records with identical sortable fields (`updated_at`, for instance).
12
+
13
+ + **1.0.0.pre** - _10/5/2015_
14
+
15
+ + Complete rewrite of the Presenter DSL allowing for introspection and (soon) automatic API documentation.
16
+
3
17
  + **0.2.5** - _07/22/2014_
4
18
 
5
19
  + `Brainstem::Presenter#load_associations!` now:
@@ -14,7 +28,7 @@
14
28
  + **0.2.3** - _11/21/2013_
15
29
 
16
30
  + `Brainstem::ControllerMethods#present_object` now runs the default filters that are defined in the presenter.
17
-
31
+
18
32
  + `Brainstem.presenter_collection` now takes two optional options:
19
33
  + `raise_on_empty` - Boolean that defaults to false and when set to true will raise an exception (default: `ActiveRecord::RecordNotFound`) when the result set is empty.
20
- + `empty_error_class` - Exception class to raise when `raise_on_empty` is true.
34
+ + `empty_error_class` - Exception class to raise when `raise_on_empty` is true.
data/Gemfile.lock CHANGED
@@ -1,60 +1,75 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainstem (0.2.6.1)
5
- activerecord (>= 3.2)
4
+ brainstem (1.0.0.pre.1)
5
+ activerecord (>= 4.1)
6
+ activesupport (>= 4.1)
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
9
10
  specs:
10
- activemodel (4.2.0)
11
- activesupport (= 4.2.0)
12
- builder (~> 3.1)
13
- activerecord (4.2.0)
14
- activemodel (= 4.2.0)
15
- activesupport (= 4.2.0)
16
- arel (~> 6.0)
17
- activesupport (4.2.0)
11
+ activemodel (5.0.2)
12
+ activesupport (= 5.0.2)
13
+ activerecord (5.0.2)
14
+ activemodel (= 5.0.2)
15
+ activesupport (= 5.0.2)
16
+ arel (~> 7.0)
17
+ activesupport (5.0.2)
18
+ concurrent-ruby (~> 1.0, >= 1.0.2)
18
19
  i18n (~> 0.7)
19
- json (~> 1.7, >= 1.7.7)
20
20
  minitest (~> 5.1)
21
- thread_safe (~> 0.3, >= 0.3.4)
22
21
  tzinfo (~> 1.1)
23
- arel (6.0.0)
24
- builder (3.2.2)
25
- diff-lcs (1.2.5)
26
- i18n (0.7.0)
27
- json (1.8.1)
28
- minitest (5.5.0)
29
- rake (10.4.2)
30
- redcarpet (3.2.2)
31
- rr (1.1.2)
32
- rspec (3.1.0)
33
- rspec-core (~> 3.1.0)
34
- rspec-expectations (~> 3.1.0)
35
- rspec-mocks (~> 3.1.0)
36
- rspec-core (3.1.7)
37
- rspec-support (~> 3.1.0)
38
- rspec-expectations (3.1.2)
22
+ arel (7.1.4)
23
+ coderay (1.1.1)
24
+ concurrent-ruby (1.0.5)
25
+ database_cleaner (1.5.3)
26
+ diff-lcs (1.3)
27
+ i18n (0.8.1)
28
+ method_source (0.8.2)
29
+ minitest (5.10.1)
30
+ pry (0.10.4)
31
+ coderay (~> 1.1.0)
32
+ method_source (~> 0.8.1)
33
+ slop (~> 3.4)
34
+ pry-nav (0.2.4)
35
+ pry (>= 0.9.10, < 0.11.0)
36
+ rake (12.0.0)
37
+ redcarpet (3.4.0)
38
+ rr (1.2.0)
39
+ rspec (3.5.0)
40
+ rspec-core (~> 3.5.0)
41
+ rspec-expectations (~> 3.5.0)
42
+ rspec-mocks (~> 3.5.0)
43
+ rspec-core (3.5.4)
44
+ rspec-support (~> 3.5.0)
45
+ rspec-expectations (3.5.0)
39
46
  diff-lcs (>= 1.2.0, < 2.0)
40
- rspec-support (~> 3.1.0)
41
- rspec-mocks (3.1.3)
42
- rspec-support (~> 3.1.0)
43
- rspec-support (3.1.2)
44
- sqlite3 (1.3.10)
45
- thread_safe (0.3.4)
47
+ rspec-support (~> 3.5.0)
48
+ rspec-mocks (3.5.0)
49
+ diff-lcs (>= 1.2.0, < 2.0)
50
+ rspec-support (~> 3.5.0)
51
+ rspec-support (3.5.0)
52
+ slop (3.6.0)
53
+ sqlite3 (1.3.13)
54
+ thread_safe (0.3.6)
46
55
  tzinfo (1.2.2)
47
56
  thread_safe (~> 0.1)
48
- yard (0.8.7.6)
57
+ yard (0.9.8)
49
58
 
50
59
  PLATFORMS
51
60
  ruby
52
61
 
53
62
  DEPENDENCIES
54
63
  brainstem!
64
+ database_cleaner
65
+ pry
66
+ pry-nav
55
67
  rake
56
68
  redcarpet
57
69
  rr
58
- rspec
70
+ rspec (~> 3.5)
59
71
  sqlite3
60
72
  yard
73
+
74
+ BUNDLED WITH
75
+ 1.13.6
data/README.md CHANGED
@@ -1,8 +1,14 @@
1
+ If you're upgrading from an older version of Brainstem, please see [Upgrading From The Pre 1.0 Brainstem](https://github.com/mavenlink/brainstem#upgrading-from-the-pre-10-brainstem) and the rest of this README.
2
+
1
3
  # Brainstem
2
4
 
5
+ [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/mavenlink/brainstem?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
6
+
3
7
  [![Build Status](https://travis-ci.org/mavenlink/brainstem.png)](https://travis-ci.org/mavenlink/brainstem)
4
8
 
5
- 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.
9
+ Brainstem is designed to power rich APIs in Rails. The Brainstem gem provides a presenter library that handles
10
+ converting ActiveRecord objects into structured JSON and a set of API abstractions that allow users to request sorts,
11
+ filters, and association loads, allowing for simpler implementations, fewer requests, and smaller responses.
6
12
 
7
13
  ## Why Brainstem?
8
14
 
@@ -10,11 +16,12 @@ Brainstem is designed to power rich APIs in Rails. The Brainstem gem provides a
10
16
  * Version your Presenters for consistency as your API evolves.
11
17
  * Expose end-user selectable filters and sorts.
12
18
  * Whitelist your existing scopes to act as API filters for your users.
13
- * 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.
19
+ * Allow users to side-load multiple objects, with their associations, in a single request, reducing the number of
20
+ requests needed to get the job done. This is especially helpful for building speedy mobile applications.
14
21
  * Prevent data duplication by pulling associations into top-level hashes, easily indexable by ID.
15
- * Easy integration with Backbone.js. "It's like Ember Data for Backbone.js!"
22
+ * Easy integration with Backbone.js via [brainstem-js](https://github.com/mavenlink/brainstem-js). "It's like Ember Data for Backbone.js!"
16
23
 
17
- Please [watch our talk about Brainstem from RailsConf 2013](http://www.confreaks.com/videos/2457-railsconf2013-introducing-brainstem-your-companion-for-rich-rails-apis).
24
+ [Watch our talk about Brainstem from RailsConf 2013](http://www.confreaks.com/videos/2457-railsconf2013-introducing-brainstem-your-companion-for-rich-rails-apis)
18
25
 
19
26
  ## Installation
20
27
 
@@ -24,13 +31,16 @@ Add this line to your application's Gemfile:
24
31
 
25
32
  ## Usage
26
33
 
27
- 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:
34
+ ### Make a Presenter
35
+
36
+ Create a class that inherits from Brainstem::Presenter, named after the model that you want to present, and preferrably
37
+ versioned in a module. For example `lib/api/v1/widget_presenter.rb`:
28
38
 
29
39
  ```ruby
30
40
  module Api
31
41
  module V1
32
42
  class WidgetPresenter < Brainstem::Presenter
33
- presents "Widget"
43
+ presents Widget
34
44
 
35
45
  # Available sort orders to expose through the API
36
46
  sort_order :updated_at, "widgets.updated_at"
@@ -39,140 +49,244 @@ module Api
39
49
  # Default sort order to apply
40
50
  default_sort_order "updated_at:desc"
41
51
 
42
- # Optional filter that delegates to the Widget model :popular scope,
43
- # which should take one argument of true or false.
44
- filter :popular
45
-
46
52
  # Optional filter that applies a lambda.
47
53
  filter :location_name do |scope, location_name|
48
54
  scope.joins(:locations).where("locations.name = ?", location_name)
49
55
  end
50
56
 
51
- # Filter with an overridable default that runs on all requests.
52
- filter :include_legacy_widgets, :default => false do |scope, bool|
57
+ # Filter with an overridable default. This will run on every request,
58
+ # passing in `bool` as `false` unless a user has specified otherwise.
59
+ filter :include_legacy_widgets, default: false do |scope, bool|
53
60
  bool ? scope : scope.without_legacy_widgets
54
61
  end
55
62
 
56
- # Return a ruby hash that can be converted to JSON
57
- def present(widget)
58
- {
59
- :name => widget.name,
60
- :legacy => widget.legacy?,
61
- :updated_at => widget.updated_at,
62
- :created_at => widget.created_at,
63
- # Associations can be included by request
64
- :features => association(:features),
65
- :location => association(:location)
66
- }
63
+ # The top-level JSON key in which these presented records will be returned.
64
+ # This is optional and defaults to the model's table name.
65
+ brainstem_key :widgets
66
+
67
+ # Specify the fields to be present in the returned JSON.
68
+ fields do
69
+ field :name, :string, "the Widget's name"
70
+ field :legacy, :boolean, "true for legacy Widgets, false otherwise", via: :legacy?
71
+ field :longform_description, :string, "feature-length description of this Widget", optional: true
72
+ field :updated_at, :datetime, "the time of this Widget's last update"
73
+ field :created_at, :datetime, "the time at which this Widget was created"
74
+ end
75
+
76
+ # Associations can be included by providing include=association_name in the URL.
77
+ # IDs for belongs_to associations will be returned for free if they're native
78
+ # columns on the model, otherwise the user must explicitly request associations
79
+ # to avoid unnecessary loads.
80
+ associations do
81
+ association :features, Feature, "features associated with this Widget"
82
+ association :location, Location, "the location of this Widget"
67
83
  end
68
84
  end
69
85
  end
70
86
  end
71
87
  ```
72
88
 
73
- Once you've created a presenter like the one above, pass requests through from your controller.
89
+ ### Setup your Controller
90
+
91
+ Once you've created a presenter like the one above, pass requests through from your Controller.
74
92
 
75
93
  ```ruby
76
94
  class Api::WidgetsController < ActionController::Base
77
95
  include Brainstem::ControllerMethods
78
96
 
79
97
  def index
80
- render :json => present("widgets") { Widgets.visible_to(current_user) }
98
+ render json: brainstem_present("widgets") { Widgets.visible_to(current_user) }
99
+ end
100
+
101
+ def show
102
+ widget = Widget.find(params[:id])
103
+ render json: brainstem_present_object(widget)
104
+ end
105
+
106
+ def create
107
+ # Note: you are in charge of sanitizing params[brainstem_model_name], likely with strong parameters.
108
+ widget = Widget.new(params[brainstem_model_name])
109
+ if widget.save
110
+ render json: brainstem_present_object(widget)
111
+ else
112
+ render json: brainstem_model_error(widget), status: :unprocessable_entity
113
+ end
81
114
  end
82
115
  end
83
116
  ```
84
117
 
85
- The scope passed to `present` could contain any starting conditions that you'd like. Requests can have includes, filters, and sort orders.
118
+ The `Brainstem::ControllerMethods` concern provides:
119
+ * `brainstem_model_name` which is inferred from your controller name or settable with `self.brainstem_model_name = :thing`.
120
+ * `brainstem_present` and `brainstem_present_object` for presenting a scope of models or a single model.
121
+ * `brainstem_model_error` and `brainstem_system_error` for presenting model and system error messages.
86
122
 
87
- GET /api/widgets.json?include=features&order=popularity:desc&location_name=san+francisco
123
+ ### Controller Best Practices
88
124
 
89
- Responses will look like the following:
125
+ We recommend that your base API controller look something like the following.
90
126
 
91
- {
92
- # Total number of results that matched the query.
93
- count: 5,
127
+ ```ruby
128
+ module Api
129
+ module V1
130
+ class ApiController < ApplicationController
131
+ include Brainstem::ControllerMethods
94
132
 
95
- # A lookup table to top-level keys. Necessary
96
- # because some objects can have associations of
97
- # the same type as themselves.
98
- results: [
99
- { key: "widgets", id: "2" },
100
- { key: "widgets", id: "10" }
101
- ],
133
+ before_filter :api_authenticate
102
134
 
103
- # Serialized models with any requested associations, keyed by ID.
135
+ rescue_from StandardError, with: :server_error
136
+ rescue_from Brainstem::SearchUnavailableError, with: :search_unavailable
137
+ rescue_from ActiveRecord::RecordNotDestroyed, with: :record_not_destroyed
138
+ rescue_from ActiveRecord::RecordNotFound,
139
+ ActionController::RoutingError, with: :page_not_found
104
140
 
105
- widgets: {
106
- "10": {
107
- id: "10",
108
- name: "disco ball",
109
- feature_ids: ["5"],
110
- popularity: 85,
111
- location_id: "2"
112
- },
113
-
114
- "2": {
115
- id: "2",
116
- name: "flubber",
117
- feature_ids: ["6", "12"],
118
- popularity: 100,
119
- location_id: "2"
120
- }
121
- },
122
-
123
- features: {
124
- "5": { id: "5", name: "shiny" },
125
- "6": { id: "6", name: "bouncy" },
126
- "12": { id: "12", name: "physically impossible" }
127
- }
128
- }
141
+ private
129
142
 
130
- You may want to setup an initializer in `config/initializers/brainstem.rb` like the following:
143
+ def api_authenticate
144
+ # Implement your authentication here. We recommend Doorkeeper.
145
+ end
131
146
 
132
- ```ruby
133
- Brainstem.default_namespace = :v1
147
+ def server_error(exception)
148
+ render json: brainstem_system_error("A server error has occurred."), status: 500
149
+ end
134
150
 
135
- module Api
136
- module V1
137
- module Helper
138
- def current_user
139
- # However you get your current user.
151
+ def search_unavailable
152
+ render json: brainstem_system_error('Search is currently unavailable'), status: 503
153
+ end
154
+
155
+ def page_not_found
156
+ render json: brainstem_system_error('Record not found'), status: 404
157
+ end
158
+
159
+ def record_not_destroyed
160
+ render json: brainstem_model_error("Could not delete the #{brainstem_model_name.humanize.downcase.singularize}"), status: :unprocessable_entity
140
161
  end
141
162
  end
142
163
  end
143
164
  end
144
- Brainstem::Presenter.helper(Api::V1::Helper)
165
+ ```
166
+
167
+ ### Setup Rails to load Brainstem
168
+
169
+ To configure Brainstem for development and production, we do the following:
170
+
171
+ 1) We add `lib` to our Rails autoload_paths in application.rb with `config.autoload_paths += "#{config.root}/lib"`
145
172
 
146
- require 'api/v1/widget_presenter'
147
- require 'api/v1/feature_presenter'
148
- require 'api/v1/location_presenter'
149
- # ...
173
+ 2) We setup an initializer in `config/initializers/brainstem.rb`, similar to the following:
150
174
 
151
- # Or you could do something like this:
152
- # Dir[Rails.root.join("lib/api/v1/*_presenter.rb").to_s].each { |p| require p }
175
+ ```ruby
176
+ # In order to support live code reload in the development environment, we register a `to_prepare` callback. This
177
+ # runs once in production (before the first request) and whenever a file has changed in development.
178
+ Rails.application.config.to_prepare do
179
+ # Forget all Brainstem configuration.
180
+ Brainstem.reset!
181
+
182
+ # Set the current default API namespace.
183
+ Brainstem.default_namespace = :v1
184
+
185
+ # (Optional) Load a default base helper into all presenters. You could use this to bring in a concept like `current_user`.
186
+ # While not necessarily the best approach, something like http://stackoverflow.com/a/11670283 can currently be used to
187
+ # access the requesting user inside of a Brainstem presenter. We hope to clean this up by allowing a user to be passed in
188
+ # when presenting in the future.
189
+ module ApiHelper
190
+ def current_user
191
+ Thread.current[:current_user]
192
+ end
193
+ end
194
+ Brainstem::Presenter.helper(ApiHelper)
195
+
196
+ # Load the presenters themselves.
197
+ Dir[Rails.root.join("lib/api/v1/*_presenter.rb").to_s].each { |presenter_path| require_dependency(presenter_path) }
198
+ end
153
199
  ```
154
200
 
155
- ### A note on Rails 4 Style Scopes
201
+ ### Make an API request
156
202
 
157
- In Rails 3 it was acceptable to write scopes like this: `scope :popular, where(:popular => true)`. This was deprecated in Rails 4 in preference of scopes that include a callable object: `scope :popular, lambda { where(:popular) => true }`.
203
+ The scope passed to `brainstem_present` can contain any starting scope conditions that you'd like. Requests can have
204
+ includes, filters, and sort orders specified in the params and automatically parsed by Brainstem.
158
205
 
159
- If your scope does not take any parameters, this can cause a problem with Brainstem if you use a filter that delegates to that scope in your presenter. (e.g., `filter :popular`). The preferable way to handle this is to write a Brainstem scope that delegates to your model scope:
206
+ GET /api/widgets.json?include=features&order=created_at:desc&location_name=san+francisco
160
207
 
161
- ```ruby
162
- filter :popular do |scope|
163
- scope.popular
164
- end
208
+ Responses will look like the following:
209
+
210
+ ```js
211
+ {
212
+ # Total number of results that matched the query.
213
+ count: 5,
214
+
215
+ # A lookup table to top-level keys. Necessary
216
+ # because some objects can have associations of
217
+ # the same type as themselves. Also helps to
218
+ # support polymorphic requests.
219
+ results: [
220
+ { key: "widgets", id: "2" },
221
+ { key: "widgets", id: "10" }
222
+ ],
223
+
224
+ # Serialized models with any requested associations, keyed by ID.
225
+
226
+ widgets: {
227
+ "10": {
228
+ id: "10",
229
+ name: "disco ball",
230
+ feature_ids: ["5"],
231
+ popularity: 85,
232
+ location_id: "2"
233
+ },
234
+
235
+ "2": {
236
+ id: "2",
237
+ name: "flubber",
238
+ feature_ids: ["6", "12"],
239
+ popularity: 100,
240
+ location_id: "2"
241
+ }
242
+ },
243
+
244
+ features: {
245
+ "5": { id: "5", name: "shiny" },
246
+ "6": { id: "6", name: "bouncy" },
247
+ "12": { id: "12", name: "physically impossible" }
248
+ }
249
+ }
165
250
  ```
166
251
 
252
+ #### Valid URL params
253
+
254
+ Brainstem parses the request params and supports the following:
255
+
256
+ * Use `order` to select a `sort_order`. Seperate the `sort_order` name and direction with a colon, like `"order=created_at:desc"`.
257
+ * Perform a search with `search`. See the `search` block definition in the Presenter DSL section at the bottom of this README.
258
+ * To request associations, use the `include` option with a comma-seperated list of association names, for example `"include=features,location"`.
259
+ * Pagination is supported by providing either the `page` and `per_page` or `limit` and `offset` URL params. You can set
260
+ legal ranges for these by passing in the `:per_page` and `:max_per_page` options when presenting. The default
261
+ `per_page` is 20 and the default `:max_per_page` is 200.
262
+ * Brainstem supports a concept called "only queries" which allow you to request a specific set of records by ID, kind of like
263
+ a batch show request. These queries are triggered by the presence of the URL param `"only"` with a comma-seperated set
264
+ of one or more IDs, for example `"only=1,5,7"`. Please note that default filters are still applied to `only` queries, so you will receive
265
+ only the subset of the requested objects that pass any default filters. To prevent this, you can provide `apply_default_filters=false`
266
+ as a query param.
267
+ * Filters are standard URL parameters. To pass an option to a filter named `:location_name`, provide a request param like
268
+ `location_name=san+francisco`. Because filters are top-level params, avoid naming them after any of the other Brainstem
269
+ keywords, such as `search`, `page`, `per_page`, `limit`, `offset`, `order`, `only`, or `include`.
270
+ * Brainstem supports optional fields which will only be returned when requested, for example: `optional_fields=field1,field2`
271
+
167
272
  --
168
273
 
169
- 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).
274
+ For more detailed examples, please see the rest of this README and our detailed
275
+ [Rails example application](https://github.com/mavenlink/brainstem-demo-rails).
170
276
 
171
277
  ## Consuming a Brainstem API
172
278
 
173
- 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.
279
+ APIs presented with Brainstem are just JSON APIs, so they can be consumed with just about any language. As Brainstem
280
+ evolves, we hope that people will contribute client libraries in many languages.
174
281
 
175
- ### The Results Array
282
+ Existing libraries:
283
+
284
+ * If you're already using Backbone.js, integrating with a Brainstem API is super simple. Just use the
285
+ [brainstem-js](https://github.com/mavenlink/brainstem-js) gem (or its JavaScript contents) to access your relational
286
+ Brainstem API from JavaScript.
287
+ * For consuming Brainstem APIs in Ruby, take a look at the [brainstem-adaptor](https://github.com/mavenlink/brainstem-adaptor) gem.
288
+
289
+ ### The Brainstem Results Array
176
290
 
177
291
  {
178
292
  results: [
@@ -185,11 +299,86 @@ APIs presented with Brainstem are just JSON APIs, so they can be consumed with j
185
299
  name: "disco ball",
186
300
 
187
301
 
188
- 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).
302
+ Brainstem returns objects as top-level hashes and provides a `results` array of `key` and `id` objects for finding the
303
+ returned data in those hashes. The reason that we use the `results` array is two-fold: 1st) it provides order outside
304
+ of the serialized objects so that we can provide objects keyed by ID, and 2nd) it allows for polymorphic responses and
305
+ for objects that have associations of their own type (like posts and replies or tasks and sub-tasks).
189
306
 
190
- ### Test helpers
307
+ ## Testing your Brainstem API
191
308
 
192
- Brainstem includes some spec helpers for controller specs. In order to use them, you need to include Brainstem in your controller specs by adding the following to `spec/support/brainstem.rb` or in your `spec/spec_helper.rb`:
309
+ We recommend writing specs for your Presenters and validating them with the `Brainstem::PresenterValidator`. Here is an
310
+ example RSpec shared behavior that you might want to use:
311
+
312
+ ```ruby
313
+ shared_examples_for "a Brainstem api presenter" do |presenter_class|
314
+ it 'passes Brainstem::PresenterValidator' do
315
+ validator = Brainstem::PresenterValidator.new(presenter_class)
316
+ validator.valid?
317
+ validator.should be_valid, "expected a valid presenter, got: #{validator.errors.full_messages}"
318
+ end
319
+ end
320
+ ```
321
+
322
+ And then use it in your presenter specs (e.g., in `spec/lib/api/v1/widget_presenter_spec.rb`:
323
+
324
+ ```ruby
325
+ require 'spec_helper'
326
+
327
+ describe Api::V1::WidgetPresenter do
328
+ it_should_behave_like "a Brainstem api presenter", described_class
329
+
330
+ describe 'presented fields' do
331
+ let(:loaded_associations) { { } }
332
+ let(:user_requested_associations) { %w[features location] }
333
+ let(:model) { some_widget } # load from a fixture or create with a factory
334
+ let(:presented_data) {
335
+ # `present_model` will return the representation of a single model. As an optional
336
+ # side effect, it will store any requested associations in the Hash provided
337
+ # to `load_associations_into`.
338
+ described_class.new.present_model(model, user_requested_associations,
339
+ load_associations_into: loaded_associations)
340
+ }
341
+
342
+ describe 'attributes' do
343
+ it 'presents the attributes' do
344
+ presented_data['name'].should == model.name
345
+ end
346
+
347
+ describe 'something conditional on the presenter' do
348
+ describe 'for widgets with this behavior' do
349
+ let(:model) { widget_with_permissions }
350
+
351
+ it 'should be true' do
352
+ presented_data['conditional_thing'].should be_truthy
353
+ end
354
+ end
355
+
356
+ describe 'for widgets without this behavior' do
357
+ let(:model) { widget_without_permissions }
358
+
359
+ it 'should be missing' do
360
+ presented_data.should_not have_key('conditional_thing')
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ describe 'associations' do
367
+ it 'should load the associations' do
368
+ presented_data
369
+ loaded_associations.keys.should == %w[features location]
370
+ end
371
+ end
372
+ end
373
+ end
374
+ ```
375
+
376
+ You can also write a spec that validates all presenters simultaniously by calling `Brainstem.presenter_collection.validate!`.
377
+
378
+ ---
379
+
380
+ Brainstem also includes some spec helpers for controller specs. In order to use them, you need to include Brainstem in
381
+ your controller specs by adding the following to `spec/support/brainstem.rb` or in your `spec/spec_helper.rb`:
193
382
 
194
383
  ```ruby
195
384
  require 'brainstem/test_helpers'
@@ -202,35 +391,267 @@ end
202
391
  Now you are ready to use the `brainstem_data` method.
203
392
 
204
393
  ```ruby
205
- # Assume user is the model and name is an attribute
394
+ # Access the request results:
395
+ expect(brainstem_data.results.first.name).to eq('name')
206
396
 
207
- # Selecting an item from a collection by it's id
397
+ # View the resulting IDs
398
+ expect(brainstem_data.results.ids).to eq(['1', '2', '3'])
399
+
400
+ Selecting an item from a top-level collection by it's id
208
401
  expect(brainstem_data.users.by_id(235).name).to eq('name')
209
402
 
210
- # Getting an array of all ids of in a collection without map
211
- expect(brainstem_data.users.ids).to include(1)
403
+ # Accessing the keys of presented model
404
+ expect(brainstem_data.results.first.keys).to =~ %w(id name email address)
405
+ ```
212
406
 
213
- # Accessing the keys of a collection
214
- expect(brainstem_data.users.first.keys).to =~ %w(id name email address)
407
+ ## Upgrading from the pre-1.0 Brainstem
215
408
 
216
- # Using standard array methods on a collection to get by index
217
- expect(brainstem_data.users.first.name).to eq('name')
218
- expect(brainstem_data.users[2].name).to eq('name')
219
- ```
409
+ If you're upgrading from the previous version of Brainstem to 1.0, there are some key changes that you'll want to know about:
220
410
 
221
- An alternate syntax for readability might be:
411
+ * The Presenter DSL has been rebuilt. Filters and sorts are the same, but the `present` method has been completely replaced
412
+ by a class-level DSL. Please see the documentation above and below.
413
+ * You can use `preload` instead of `custom_preload` now, although `custom_preload` still exists for complex cases.
414
+ * `present_objects` and `present` have been renamed to `brainstem_present_objects` and `brainstem_present`.
415
+ * `brainstem_key` is now an annotation on presenters and not needed when declaring associations. It should always be plural.
416
+ * `key_map` has been supplanted by `brainstem_key` in the presenter and has been removed.
417
+ * `options[:as]` is no longer used with `brainstem_present` / `PresenterCollection#presenting`. Use the `brainstem_key`
418
+ annotation in your presenters instead.
419
+ * `helper` can now take a block or module.
222
420
 
223
- ```ruby
224
- describe 'brainstem_data' do
225
- subject { brainstem_data }
421
+ ## Advanced Topics
226
422
 
227
- its('users.ids') { should include(1) }
228
- end
229
- ```
423
+ ### The presenter DSL
424
+
425
+ Brainstem provides a rich DSL for building presenters. This section details the methods available to you.
426
+
427
+ * `presents` - Accepts a list of classes that this specific presenter knows how to present. These are not inherited.
428
+
429
+ * `brainstem_key` - The name of the top-level JSON key in which these presented models will be returned. Defaults to the model's
430
+ table name. This annotation is useful when returning data under a different external name than you use for your internal
431
+ models, or when presenting data from STI tables that you want to have use the subclass's name.
432
+
433
+ * `sort_order` - Give `sort_order` a sort name (as a symbol) and either a string of SQL to be used for ordering
434
+ (like `"widgets.updated_at"`) or a lambda that accepts a scope and an order, like the following:
435
+
436
+ ```ruby
437
+ sort_order :composite do |scope, direction|
438
+ # Be careful to avoid a SQL injection!
439
+ sanitized_direction = direction == "desc" ? "desc" : "asc"
440
+ scope.reorder("widgets.created_at #{sanitized_direction}, widgets.id #{sanitized_direction}")
441
+ end
442
+ ```
443
+
444
+ * `default_sort_order` - The name and direction of the default sort for this presenter. The format is the same as is expected
445
+ in the URL parameter, for example `"name:desc"` or `"name:asc"`. The default value is `"updated_at:desc"`.
230
446
 
231
- ### Brainstem and Backbone.js
447
+ * `helper` - Provide a Module or block of helper methods to make available in filter, sort, conditional, association,
448
+ and field lambdas. Any instance variables defined in the helpers will only be available for a single model presentation.
232
449
 
233
- 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.
450
+ ```ruby
451
+ # Provide a global helper Module for all presenters.
452
+ Brainstem::Presenter.helper(ApiHelper)
453
+
454
+ # Inside of a Presenter, provide local helpers.
455
+ helper do
456
+ def some_widget_helper(widget)
457
+ widget.some_widget_method
458
+ end
459
+ end
460
+ ```
461
+
462
+ * `filter` - Declare an available filter for this Presenter. Filters have a name, some options, and a block to run when
463
+ they're requested by a user. When a user provides either `"true"` or `"false"`, as in `include_legacy_widgets=true`,
464
+ they will be coerced into booleans. All other input formats are left as strings. Here are some examples:
465
+
466
+ ```ruby
467
+ # Optional filter that applies a lambda.
468
+ filter :location_name do |scope, location_name|
469
+ scope.joins(:locations).where("locations.name = ?", location_name)
470
+ end
471
+
472
+ # Filter with an overridable default. This will run on every request,
473
+ # passing in `bool` as `false` unless a user has specified otherwise.
474
+ filter :include_legacy_widgets, default: false do |scope, bool|
475
+ bool ? scope : scope.without_legacy_widgets
476
+ end
477
+ ```
478
+
479
+ * `search` - This annotation allows you to create a block that is run when your users provide the special `search` URL param.
480
+ When in "search" mode, Brainstem delegates entirely to this block and applies no filters or sorts beyond scoping to the
481
+ base scope passed into `presenting`. You're in charge of implementing whatever filters and sorts you'd like to support
482
+ in search mode inside of your search subsystem. The block should return an array where the first element is an array
483
+ of a page of matching model ids, and the second option is the total number of matched records.
484
+
485
+ ```ruby
486
+ search do |search_string, options|
487
+ # options will contain:
488
+ # include: an array of the requested association inclusions
489
+ # order: { sort_order: sort_name, direction: direction }
490
+ # limit and offset or page and per_page, depending on which the user has provided
491
+ # requested filters and any default filters
492
+
493
+ # Talk to your search system (solr, elasticsearch, etc.) here.
494
+ results = do_an_actual_search(search_string, location_name: options[:location_name])
495
+
496
+ if results
497
+ [results.map { |result| result.id.to_i }, results.total]
498
+ else
499
+ [false, 0]
500
+ end
501
+ end
502
+ ```
503
+
504
+ If you wish to perform your Brainstem filters in conjunction with your search block you can use the beta `search_and_filter`
505
+ query strategy. [See this for details](lib/brainstem/query_strategies/README.md).
506
+
507
+ * `preload` - Use this annotation to provide a list of valid associations to preload on this model. If you
508
+ always end up asking a question of each instance that requires loading an association, `preload` it here to avoid an
509
+ N+1 query. The syntax is the same as `preload` or `include` in Rails and allows for nesting.
510
+
511
+ ```ruby
512
+ preload :location
513
+ preload :location, features: :feature_creator
514
+ ```
515
+
516
+ * `fields` - The Brainstem `fields` DSL is how you tell Brainstem what JSON fields to provide in each of your presented models.
517
+ Fields have a name, which is what they will be called in the returned JSON, a type which is used for API documentation,
518
+ an optional documentation string, and a number of options. By default, fields will call a model method with the same
519
+ name as the field's name and return the result. Use the `:via` option to call a different method, or the `:dynamic` option
520
+ to provide a lambda that takes the model and returns the field's output value. Fields which result in N + 1 queries can be
521
+ optimized with a `:lookup` option, detailed in the `lookup` section below. Fields can be conditionally returned with the
522
+ `:if` option, detailed in the `conditionals` section below. Expensive fields can be declared as `optional: true` so that they are
523
+ only returned when `optional_fields=field` is provided in the API request. Here are some example fields:
524
+
525
+ ```ruby
526
+ fields do
527
+ field :name, :string, "the Widget's name"
528
+ field :legacy, :boolean, "true for legacy Widgets, false otherwise",
529
+ via: :legacy?
530
+ field :dynamic_name, :string, "a formatted name for this Widget",
531
+ dynamic: lambda { |widget| "This Widget's name is #{widget.name}" }
532
+ field :longform_description, :string, "feature-length description of this Widget",
533
+ optional: true
534
+
535
+ # Fields can be nested
536
+ fields :permissions do
537
+ field :access_level, :integer
538
+ end
539
+ end
540
+ ```
541
+
542
+ * `associations` - Associations are one of the best features of Brainstem. Your users can provide the names of associations
543
+ to `include` with their response, preventing N+1 API requests. Declared `association` entries have a name, an ActiveRecord
544
+ class, an optional documentation string, and some options. By default, associations will call the association or
545
+ method on the model with their name. Like fields, you can use `:via` to call a different method or association and
546
+ `:dynamic` to provide a lambda that takes the model and returns a model, array of models, or relation of models.
547
+ Associations which result in N + 1 queries can be optimized with a `:lookup` option, detailed in the `lookup` secontion below.
548
+
549
+ If you have an association that tends to be large and expensive to return, you can annotate it with the
550
+ `restrict_to_only: true` option and it will only be returned when the `only` URL param is provided and contains a
551
+ specific set of requested model IDs.
552
+
553
+ Included associations will be present in the returned JSON as either `<field>_id`, `<field>_ids`, `<field>_ref`, or `<field>_refs`
554
+ depending on whether they reference a single model, an array (or Relation) of models, a single polymorphic
555
+ association (a polymorphic `belongs_to` or `has_one`), or a plural polymorphic association (a polymorphic `has_many`) respectively.
556
+ When a `*_ref` is returned, it will look like `{ "id": "2", "key": "widgets" }`, telling the consumer the top-level key in
557
+ which to find the identified record by ID.
558
+
559
+ If your model has a native column named `<field>_id`, it will be returned for free without being requested. Otherwise,
560
+ users need to request associations via the `include` url param.
561
+
562
+ ```ruby
563
+ associations do
564
+ association :features, Feature, "features associated with this Widget"
565
+ association :location, Location, "the location of this Widget"
566
+ association :previous_location, Location, "the Widget's previous location",
567
+ dynamic: lambda { |widget| widget.previous_locations.first }
568
+ association :associated_objects, :polymorphic, "a mixture of objects related to this Widget"
569
+ end
570
+ ```
571
+
572
+ * `lookup` - Use this option to avoid N + 1 queries for Fields and Associations. The `lookup` lambda runs once when
573
+ presenting and every presented model gets its assocation or value from the cache the `lookup` lambda generates. The
574
+ `lookup` lambda takes in the presented models and should generate a cache containing the models' coresponding assocations
575
+ or values. Brainstem expects the return result of the `lookup` to be a Hash where the keys are the presented models' ids
576
+ and the values are those models' associations or values. Use the `lookup` when you would like to preload but cannot
577
+ e.g. if your association references `current_user`. If both a `lookup` and `dynamic` options are defined,
578
+ the `lookup` will be used.
579
+
580
+ ```ruby
581
+ associations do
582
+ association :current_user_groups, Group, "the Groups for the current user",
583
+ lookup: lambda { |models|
584
+ Group.where(subject_id: models.map(&:id)
585
+ .where(user_id: current_user.id)
586
+ .group_by { |group| group.subject_id }
587
+ }
588
+ end
589
+ ```
590
+
591
+ * `lookup_fetch` - Use this option for Fields and Associations if you would like to override how a model should retrieve
592
+ its value or assocation returned by the `lookup` cache. The `lookup_fetch` lambda takes in the presented model and the result
593
+ from the `lookup` lambda. It should return the association or value from the `lookup` cache for that `model`. If
594
+ `lookup_fetch` is not defined, Brainstem will run the default. The example `lookup_fetch` below is equivalent to the default.
595
+
596
+ ```ruby
597
+ fields do
598
+ field :current_user_post_count, Post, "count of Posts the current_user has for this model",
599
+ lookup: lambda { |models|
600
+ lookup = Post.where(subject_id: models.map(&:id)
601
+ .where(user_id: current_user.id)
602
+ .group_by { |post| post.subject_id }
603
+
604
+ lookup
605
+ },
606
+ lookup_fetch: lambda { |lookup, model| lookup[model.id] }
607
+ end
608
+ ```
609
+
610
+ * `conditionals` - Conditionals are named questions that can be used to restrict which `fields` are returned. The
611
+ `conditionals` block has two available methods, `request` and `model`. The `request` conditionals run once for the entire
612
+ set of presented models, while `model` ones run once per model. Use `request` conditionals to check and then cache things
613
+ like permissions checks that do not change between models, and use `model` conditionals to ask questions of specific
614
+ models. The optional documentation string is used in API doc generation.
615
+
616
+ ```ruby
617
+ conditionals do
618
+ model :title_is_hello,
619
+ lambda { |model| model.title == 'hello' },
620
+ 'visible when the title is hello'
621
+
622
+ request :user_is_bob,
623
+ lambda { current_user == 'bob' }, # Assuming some sort of `helper` that provides `current_user`
624
+ 'visible only to bob'
625
+ end
626
+
627
+ fields do
628
+ field :hello_title, :string, 'the title, when it is exactly the word "hello"',
629
+ dynamic: lambda { |model| model.title + " is the title" },
630
+ if: :title_is_hello
631
+
632
+ field :secret, :string, "a secret, via the secret_info model method, only visible to bob and when the model's title is hello",
633
+ via: :secret_info,
634
+ if: [:user_is_bob, :title_is_hello]
635
+
636
+ with_options if: :user_is_bob do
637
+ field :bob_title, :string, 'another name for the title, only visible to Bob',
638
+ via: :title
639
+ end
640
+ end
641
+ ```
642
+
643
+ ### A note on Rails 4 Style Scopes
644
+
645
+ In Rails 3 it was acceptable to write scopes like this: `scope :popular, where(:popular => true)`. This was deprecated
646
+ in Rails 4 in preference of scopes that include a callable object: `scope :popular, lambda { where(:popular) => true }`.
647
+
648
+ If your scope does not take any parameters, this can cause a problem with Brainstem if you use a filter that delegates
649
+ to that scope in your presenter. (e.g., `filter :popular`). The preferable way to handle this is to write a Brainstem
650
+ scope that delegates to your model scope:
651
+
652
+ ```ruby
653
+ filter :popular { |scope| scope.popular }
654
+ ```
234
655
 
235
656
  ## Contributing
236
657