rack-reducer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +382 -0
  3. data/lib/rack/reducer.rb +46 -0
  4. data/lib/rack/reducer/parser.rb +20 -0
  5. data/lib/rack/reducer/reduction.rb +49 -0
  6. data/lib/rack/reducer/refinements.rb +30 -0
  7. data/spec/behavior.rb +44 -0
  8. data/spec/benchmarks.rb +56 -0
  9. data/spec/fixtures.rb +19 -0
  10. data/spec/middleware_spec.rb +22 -0
  11. data/spec/rails_example/app/channels/application_cable/channel.rb +4 -0
  12. data/spec/rails_example/app/channels/application_cable/connection.rb +4 -0
  13. data/spec/rails_example/app/controllers/application_controller.rb +2 -0
  14. data/spec/rails_example/app/controllers/artists_controller.rb +53 -0
  15. data/spec/rails_example/app/jobs/application_job.rb +2 -0
  16. data/spec/rails_example/app/mailers/application_mailer.rb +4 -0
  17. data/spec/rails_example/app/models/application_record.rb +3 -0
  18. data/spec/rails_example/app/models/artist.rb +18 -0
  19. data/spec/rails_example/config/application.rb +35 -0
  20. data/spec/rails_example/config/boot.rb +3 -0
  21. data/spec/rails_example/config/environment.rb +5 -0
  22. data/spec/rails_example/config/environments/development.rb +47 -0
  23. data/spec/rails_example/config/environments/production.rb +83 -0
  24. data/spec/rails_example/config/environments/test.rb +42 -0
  25. data/spec/rails_example/config/initializers/application_controller_renderer.rb +8 -0
  26. data/spec/rails_example/config/initializers/backtrace_silencers.rb +7 -0
  27. data/spec/rails_example/config/initializers/cors.rb +16 -0
  28. data/spec/rails_example/config/initializers/filter_parameter_logging.rb +4 -0
  29. data/spec/rails_example/config/initializers/inflections.rb +16 -0
  30. data/spec/rails_example/config/initializers/mime_types.rb +4 -0
  31. data/spec/rails_example/config/initializers/schema.rb +13 -0
  32. data/spec/rails_example/config/initializers/wrap_parameters.rb +14 -0
  33. data/spec/rails_example/config/puma.rb +56 -0
  34. data/spec/rails_example/config/routes.rb +4 -0
  35. data/spec/rails_example/db/seeds.rb +7 -0
  36. data/spec/rails_example/test/test_helper.rb +10 -0
  37. data/spec/rails_spec.rb +7 -0
  38. data/spec/sinatra_functional_spec.rb +32 -0
  39. data/spec/sinatra_mixin_spec.rb +26 -0
  40. data/spec/spec_helper.rb +13 -0
  41. metadata +278 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1fd376a5091b8dac2c04e97b36e3e8880e7cfaecf01e1ef25d69580b30ba0bc4
4
+ data.tar.gz: 7511b21435b309a104381dc2a5a3ccaad4b3b916d937de986c7087b7632ebe95
5
+ SHA512:
6
+ metadata.gz: 482102225a82151f8bbb0036ad4234f9caa741d112477f367e36c63e5c86e688defff43c130f70a0e88b1d006673e42d095228eeb376a86acd0d327c441a14e1
7
+ data.tar.gz: af0473c9840adfea73479dffe3def6c96ca46fa9b1452bbefd9c680b1231f767ff9179dcedfa30a501b10b5726f77e677e500ff666db4ce73b70b128049e788f
data/README.md ADDED
@@ -0,0 +1,382 @@
1
+ Rack::Reducer
2
+ ==========================================
3
+ Safely map URL params to database filters, in any Rack app. If your users need
4
+ to filter or sort data by making HTTP requests, this gem can help.
5
+
6
+ If you're working in Rails, note that Rack::Reducer solves the same problem
7
+ as Platformatec's excellent [HasScope][has_scope]. But Rack::Reducer works in
8
+ any Rack app, with any ORM, or without an ORM at all. Even in Rails, Reducer's
9
+ simpler, more functional API may be a better fit for your needs.
10
+
11
+ Install
12
+ ------------------------------------------
13
+ Add `rack-reducer` to your Gemfile:
14
+
15
+ ```ruby
16
+ gem 'rack-reducer', require: 'rack/reducer'
17
+ ```
18
+
19
+ Use
20
+ ------------------------------------------
21
+ Rack::Reducer maps incoming URL params to an array of filter functions you
22
+ define, chains the applicable filters, and returns filtered data.
23
+
24
+ Suppose you have some incoming requests like these...
25
+
26
+ `GET /artists`
27
+ `GET /artists?name=janelle+monae`
28
+ `GET /artists?name=blake&genre=electronic`
29
+
30
+ You want to filter your `artists` table by name and/or genre when those
31
+ params are present, or return all artists otherwise.
32
+
33
+ Even with just a few optional filters, running them conditonally via `if`
34
+ statements gets messy.
35
+
36
+ ### A Mess
37
+
38
+ ```ruby
39
+ # app/controllers/artists_controller.rb
40
+ class ArtistsController < ApplicationController
41
+ def index
42
+ @artists = Artist.all
43
+ @artists = @artists.where('lower(name) like ?', "%#{name.downcase}%") if params[:name]
44
+ @artists = @artists.where(genre: params[:genre]) if params[:genre]
45
+ @artists = @artists.order(params[:order].to_sym) if params[:order]
46
+ # ...
47
+ # ...
48
+ # pages later...
49
+ @artists.all.to_json
50
+ end
51
+ ```
52
+
53
+ Rack::Reducer lets you chain filters elegantly, whether you're chaining two
54
+ filters or twenty. You can use it by extending it in your models, or by
55
+ calling it as a function.
56
+
57
+ ### Cleaned up by extending Rack::Reducer
58
+ Call `Model.reduce(params)` in your controllers...
59
+
60
+ ```ruby
61
+ # app/controllers/artists_controller.rb
62
+ class ArtistsController < ApplicatonController
63
+ def index
64
+ @artists = Artist.reduce(params)
65
+ @artists.all.to_json
66
+ end
67
+ end
68
+ ```
69
+
70
+ ... and `extend Rack::Reducer` in your models:
71
+ ```ruby
72
+ # app/models/artist.rb
73
+ class Artist < ActiveRecord::Base
74
+ extend Rack::Reducer # makes `self.reduce` available at class level
75
+
76
+ # configure by calling
77
+ # `reduces(some_initial_scope, filters: [an, array, of, lambdas])`
78
+ #
79
+ # filters can use any methods your initial dataset understands.
80
+ # here it's an ActiveRecord query, so filters use AR query methods
81
+ reduces self.all, filters: [
82
+ ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
83
+ ->(genre:) { where(genre: genre) },
84
+ ->(order:) { order(order.to_sym) },
85
+ ]
86
+ end
87
+ ```
88
+
89
+ ### Cleaned up by calling Rack::Reducer as a function
90
+ If you prefer composition to inheritance, you can call Rack::Reducer as a
91
+ function instead of extending it. The functional style can (a) help keep
92
+ your filtering logic in one file, and (b) let you use Rack::Reducer without
93
+ polluting your model's methods.
94
+
95
+ ```ruby
96
+ # app/controllers/artists_controller.rb
97
+ class ArtistsController < ApplicationController
98
+ def index
99
+ @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
100
+ ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
101
+ ->(genre:) { where(genre: genre) },
102
+ ->(order:) { order(order.to_sym) },
103
+ ])
104
+ @artists.all.to_json
105
+ end
106
+ end
107
+ ```
108
+
109
+ The mixin style requires less boilerplate, and is stylistically Railsier.
110
+ The functional style is more flexible. Both styles are supported, tested, and
111
+ handle requests identically. In the examples above:
112
+
113
+ ```ruby
114
+ # GET /artists returns all artists, e.g.
115
+ [
116
+ { "name": "Blake Mills", "genre": "alternative" },
117
+ { "name": "Björk", "genre": "electronic" },
118
+ { "name": "James Blake", "genre": "electronic" },
119
+ { "name": "Janelle Monae", "genre": "alt-soul" },
120
+ { "name": "SZA", "genre": "alt-soul" }
121
+ ]
122
+
123
+ # GET /artists?name=blake returns e.g.
124
+ [
125
+ { "name": "Blake Mills", "genre": "alternative" },
126
+ { "name": "James Blake", "genre": "electronic" }
127
+ ]
128
+
129
+ # GET /artists?name=blake&genre=electronic returns e.g.
130
+ [{ "name": "James Blake", "genre": "electronic" }]
131
+ ```
132
+
133
+
134
+ Framework-specific Examples
135
+ ---------------------------
136
+ These examples apply Rack::Reducer in different frameworks, with a different
137
+ ORM each time. The pairings of ORMs and frameworks are abitrary, just to
138
+ demonstrate a few possible stacks.
139
+
140
+ - [Sinatra](#sinatrasequel)
141
+ - [Rack Middleware](#rack-middlewarehash)
142
+ - [Rails](#railsadvanced)
143
+
144
+ ### Sinatra/Sequel
145
+ This example uses [Sinatra][sinatra] to handle requests, and [Sequel][sequel]
146
+ as an ORM.
147
+
148
+ #### Mixin-style
149
+ ```ruby
150
+ # sintra_mixin_style.rb
151
+ class SinatraMixinApp < Sinatra::Base
152
+ class Artist < Sequel::Model
153
+ extend Rack::Reducer
154
+ reduces self.dataset, filters: [
155
+ ->(genre:) { where(genre: genre) },
156
+ ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
157
+ ->(order:) { order(order.to_sym) },
158
+ ]
159
+ end
160
+
161
+ get '/artists' do
162
+ @artists = Artist.reduce(params)
163
+ @artists.all.to_json
164
+ end
165
+ end
166
+ ```
167
+
168
+ #### Functional style
169
+ ```ruby
170
+ # sinatra_functional_style.rb
171
+ class SinatraFunctionalApp < Sinatra::Base
172
+ DB = Sequel.connect ENV['DATABASE_URL']
173
+
174
+ get '/artists' do
175
+ # dataset is a Sequel::Dataset, so filters use Sequel query methods
176
+ @artists = Rack::Reducer.call(params, dataset: DB[:artists], filters: [
177
+ ->(genre:) { where(genre: genre) },
178
+ ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
179
+ ->(order:) { order(order.to_sym) },
180
+ ])
181
+ @artists.all.to_json
182
+ end
183
+ end
184
+ ```
185
+
186
+ ### Rack Middleware/Hash
187
+ This example runs a raw Rack app with Rack::Reducer mounted as middleware.
188
+ It doesn't use an ORM at all -- it just stores data in a hash.
189
+
190
+ ```ruby
191
+ # config.ru
192
+ require 'rack'
193
+ require 'rack/reducer'
194
+ require 'json'
195
+
196
+ ARTISTS = [
197
+ { name: 'Blake Mills', genre: 'alternative' },
198
+ { name: 'Björk', genre: 'electronic' },
199
+ { name: 'James Blake', genre: 'electronic' },
200
+ { name: 'Janelle Monae', genre: 'alt-soul' },
201
+ { name: 'SZA', genre: 'alt-soul' },
202
+ ]
203
+
204
+ app = Rack::Builder.new do
205
+ # dataset is a hash, so filter functions use ruby hash methods
206
+ use Rack::Reducer, dataset: ARTISTS, filters: [
207
+ ->(genre:) { select { |item| item[:genre].match(/#{genre}/i) } },
208
+ ->(name:) { select { |item| item[:name].match(/#{name}/i) } },
209
+ ->(order:) { sort_by { |item| item[order.to_sym] } },
210
+ ]
211
+ run ->(env) { [200, {}, [env['rack.reduction'].to_json]] }
212
+ end
213
+
214
+ run app
215
+ ```
216
+
217
+ When Rack::Reducer is mounted as middleware, it stores its filtered data in
218
+ env['rack.reduction'], then calls the next app in the middleware stack. You can
219
+ change the `env` key by passing a new name as option to `use`:
220
+
221
+ ```ruby
222
+ # config.ru
223
+ use Rack::Reducer, key: 'myapp.custom_key', dataset: ARTISTS, filters: [
224
+ #an array of lambdas
225
+ ]
226
+ ```
227
+
228
+ ### Rails/Advanced
229
+ The examples in the [introduction](#use) cover basic Rails use. The examples
230
+ below cover more advanced use.
231
+
232
+ If you're comfortable in a non-Rails stack, you can apply these advanced
233
+ techniques there too. I wholeheartedly endorse [Roda][roda], and use
234
+ Rack::Reducer with Roda/Sequel in production.
235
+
236
+ #### Chaining reduce with other ActiveRecord query methods
237
+ In the mixin-style, you can chain `Model.reduce` with other ActiveRecord
238
+ queries, as long as `reduce` is the first call in the chain:
239
+
240
+ ```ruby
241
+ # app/models/artist.rb
242
+ class Artist < ApplicationRecord
243
+ extend Rack::Reducer
244
+ reduces self.all, filters: [
245
+ # filters get instance_exec'd against the initial dataset,
246
+ # in this case `self.all`, so filters can use query methods, scopes, etc
247
+ ->(name:) { by_name(name) },
248
+ ->(genre:) { where(genre: genre) },
249
+ ->(order:) { order(order.to_sym) }
250
+ ]
251
+
252
+ scope :by_name, lambda { |name|
253
+ where('lower(name) like ?', "%#{name.downcase}%")
254
+ }
255
+
256
+ # here's a scope we're not using in our Reducer filters,
257
+ # but will use in our controller
258
+ scope :signed, lambda { where(signed: true) }
259
+ end
260
+
261
+ # app/controllers/artists_controller.rb
262
+ class ArtistsController < ApplicationController
263
+ def index
264
+ # you can chain reduce with other ActiveRecord queries,
265
+ # as long as reduce is first in the chain
266
+ @artists = Artist.reduce(params).signed
267
+ @artists.to_json
268
+ end
269
+ end
270
+ ```
271
+
272
+
273
+ #### Dynamically setting Reducer's initial dataset
274
+ Rack::Reducer's mixin style only lets you target one dataset for reduction.
275
+ If you need different initial data in different contexts, and don't want to
276
+ determine that data via filters, you can use the functional style:
277
+
278
+ ```ruby
279
+ # app/controllers/artists_controller.rb
280
+ class ArtistsController < ApplicationController
281
+ def index
282
+ @scope = current_user.admin? ? Artist.all : Artist.signed
283
+ @artists = Rack::Reducer.call(params, dataset: @scope, filters: [
284
+ ->(name:) { by_name(name) },
285
+ ->(genre:) { where(genre: genre) },
286
+ ->(order:) { order(order.to_sym) }
287
+ ])
288
+ @artists.to_json
289
+ end
290
+ end
291
+ ```
292
+
293
+ #### Default filters
294
+ Most of the time it makes sense to use *required* keyword arguments for each
295
+ filter, and skip running the filter altogether when the keyword argments aren't
296
+ present.
297
+
298
+ But you may want to run a filter always, with a sensible default when the params
299
+ don't specify a value. Ordering results is a common case.
300
+
301
+ The code below will order by `params[:order]` when it exists, and by name
302
+ otherwise.
303
+
304
+ ```ruby
305
+ # app/controllers/artists_controller.rb
306
+ class ArtistsController < ApplicationController
307
+ def index
308
+ @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
309
+ ->(genre:) { where(genre: genre) },
310
+ ->(order: 'name') { order(order.to_sym) }
311
+ ])
312
+ @artists.to_json
313
+ end
314
+ end
315
+ ```
316
+
317
+
318
+ How Rack::Reducer Works
319
+ --------------------------------------
320
+ Rack::Reducer takes a dataset, a params hash, and an array of lambda functions.
321
+
322
+ To return filtered data, it calls [reduce][reduce] on your array of lambdas,
323
+ with the reduction's initial value set to `dataset`.
324
+
325
+ Each reduction looks for keys in the `params` hash that match the
326
+ current lambda's [keyword arguments][keywords]. If the keys exist, it
327
+ `instance_exec`s the lambda against the dataset, passing just those keys as
328
+ arguments, and finally passes the filtered dataset on to the next lambda.
329
+
330
+ Lambdas that don't find all their required keyword arguments in `params` don't
331
+ execute at all, and just pass the unaltered dataset down the chain.
332
+
333
+ The reason Reducer works with any ORM is that *you* supply the dataset and
334
+ filter functions. Reducer doesn't need to know anything about ActiveRecord,
335
+ Mongoid, etc -- it just `instance_exec`s your own code against your own dataset.
336
+
337
+ ### Security
338
+ Rack::Reducer claims to "safely" map URL params to filters, but it accepts an
339
+ unfiltered params hash. What gives?
340
+
341
+ By using keyword arguments in your filter lambdas, you are *explicitly* naming
342
+ the params you'll accept into your filters. Params that aren't keywords never
343
+ get evaluated.
344
+
345
+ For even more security, you can typecast the params in your filters. Most ORMs
346
+ handle this for you, but as an example:
347
+
348
+ ```ruby
349
+ FILTERS = [
350
+ # typecast params[:name] to a string
351
+ ->(name:) { where(name: name.to_s) },
352
+ # typecast params[:updated_before] and params[:updated_after]
353
+ # to times, and set a default for updated_after if it's missing
354
+ lambda |updated_before:, updated_after: 1.month.ago| {
355
+ where(updated_at: updated_after.to_time..updated_before.to_time)
356
+ }
357
+ ]
358
+ ```
359
+
360
+ ### Performance
361
+ According to `spec/benchmarks.rb`, Rack::Reducer executes about 90% as quickly
362
+ as a set of hard-coded conditional filters. It is extremly unlikely to be a
363
+ bottleneck in your application.
364
+
365
+
366
+
367
+ Contributing
368
+ -------------------------------
369
+ ### Bugs
370
+ Open [an issue](https://github.com/chrisfrank/rack-reducer/issues) on Github.
371
+
372
+ ### Pull Requests
373
+ Please include tests, following the style of the specs in `spec/*_spec.rb`.
374
+
375
+
376
+
377
+ [has_scope]: https://github.com/plataformatec/has_scope
378
+ [sinatra]: https://github.com/sinatra/sinatra
379
+ [sequel]: https://github.com/jeremyevans/sequel
380
+ [roda]: https://github.com/jeremyevans/roda
381
+ [reduce]: http://ruby-doc.org/core-2.5.0/Enumerable.html#method-i-reduce
382
+ [keywords]: https://robots.thoughtbot.com/ruby-2-keyword-arguments
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/request'
4
+ require_relative 'reducer/reduction'
5
+
6
+ module Rack
7
+ # use request params to apply filters to a dataset
8
+ module Reducer
9
+ # call Rack::Reducer as a function, instead of mounting it as middleware
10
+ def self.call(params, dataset:, filters:)
11
+ Reduction.new(
12
+ nil, # first arg to Reduction is `app`, which is for middleware only
13
+ params: params,
14
+ filters: filters,
15
+ dataset: dataset,
16
+ ).reduce
17
+ end
18
+
19
+ def self.new(app, options = {})
20
+ Reduction.new(app, options)
21
+ end
22
+
23
+ # extend Rack::Reducer to get `reduce` and `reduces` as class-methods
24
+ #
25
+ # class Artist < SomeORM::Model
26
+ # extend Rack::Reducer
27
+ # reduces self.all, filters: [
28
+ # lambda { |name:| where(name: name) },
29
+ # lambda { |genre:| where(genre: genre) },
30
+ # ]
31
+ # end
32
+ def reduce(params)
33
+ Reduction.new(
34
+ nil,
35
+ params: params,
36
+ filters: @rack_reducer_filters,
37
+ dataset: @rack_reducer_dataset
38
+ ).reduce
39
+ end
40
+
41
+ def reduces(dataset, filters:)
42
+ @rack_reducer_dataset = dataset
43
+ @rack_reducer_filters = filters
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Reducer
5
+ # convert params from Sinatra, Rails, Roda, etc into a symbol hash
6
+ module Parser
7
+ def self.call(data)
8
+ data.is_a?(Hash) ? data : hashify(data)
9
+ end
10
+
11
+ # turns out a Rails params hash is not really a hash
12
+ # it's safe to call .to_unsafe_hash here, because params
13
+ # are automatically sanitized by the lambda keywords
14
+ def self.hashify(data)
15
+ fn = %i[to_unsafe_h to_h].find { |name| data.respond_to?(name) }
16
+ data.send(fn)
17
+ end
18
+ end
19
+ end
20
+ end