rack-reducer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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