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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'refinements'
4
+ require_relative 'parser'
5
+
6
+ module Rack
7
+ module Reducer
8
+ # call `reduce` on a params hash, filtering data via lambdas with
9
+ # matching keyword arguments
10
+ class Reduction
11
+ using Refinements # augment Hash & Proc inside this scope
12
+
13
+ DEFAULTS = {
14
+ dataset: [],
15
+ filters: [],
16
+ key: 'rack.reduction',
17
+ params: nil
18
+ }.freeze
19
+
20
+ def initialize(app, props)
21
+ @app = app
22
+ @props = DEFAULTS.merge(props)
23
+ end
24
+
25
+ # when mounted as middleware, set env[@props[:key]] to the output
26
+ # of self.reduce, then call the next app in the middleware stack
27
+ def call(env)
28
+ @params = Rack::Request.new(env).params.symbolize_keys
29
+ @app.call env.merge(@props[:key] => reduce)
30
+ end
31
+
32
+ def reduce
33
+ @props[:filters].reduce(@props[:dataset], &method(:apply_filter))
34
+ end
35
+
36
+ private
37
+
38
+ def params
39
+ @params ||= Parser.call(@props[:params]).symbolize_keys
40
+ end
41
+
42
+ def apply_filter(data, fn)
43
+ requirements = fn.required_argument_names.to_set
44
+ return data unless params.satisfies?(requirements)
45
+ data.instance_exec(params.slice(*fn.all_argument_names), &fn)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Reducer
5
+ # refine a few core classes in Rack::Reducer's scope only
6
+ module Refinements
7
+ refine Hash do
8
+ def symbolize_keys
9
+ each_with_object({}) do |(key, val), hash|
10
+ hash[key.to_sym] = val.is_a?(Hash) ? val.symbolize_keys : val
11
+ end
12
+ end
13
+
14
+ def satisfies?(requirements)
15
+ slice(*requirements).keys.to_set == requirements
16
+ end
17
+ end
18
+
19
+ refine Proc do
20
+ def required_argument_names
21
+ parameters.select { |arg| arg[0] == :keyreq }.map(&:last)
22
+ end
23
+
24
+ def all_argument_names
25
+ parameters.map(&:last)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
data/spec/behavior.rb ADDED
@@ -0,0 +1,44 @@
1
+ shared_examples_for Rack::Reducer do
2
+ let(:app) { described_class }
3
+
4
+ it 'responds with unfiltered data when filter params are empty' do
5
+ get('/artists') do |res|
6
+ ARTISTS.each { |artist| expect(res.body).to include(artist[:name]) }
7
+ end
8
+ end
9
+
10
+ it 'filters by a single param, e.g. name' do
11
+ get('/artists?name=Blake') do |response|
12
+ expect(response.body).to include('Blake Mills')
13
+ expect(response.body).to include('James Blake')
14
+ expect(response.body).not_to include('SZA')
15
+ end
16
+ end
17
+
18
+ it 'filters by a single param, e.g. genre' do
19
+ get('/artists?genre=electronic') do |response|
20
+ expect(response.body).to include('Björk')
21
+ expect(response.body).to include('James Blake')
22
+ expect(response.body).not_to include('Blake Mills')
23
+ end
24
+
25
+ get '/artists?genre=soul' do |response|
26
+ expect(response.body).to include('Janelle Monae')
27
+ expect(response.body).not_to include('Björk')
28
+ end
29
+ end
30
+
31
+ it 'chains multiple filters' do
32
+ get('/artists?genre=electronic&name=blake') do |response|
33
+ expect(response.body).to include('James Blake')
34
+ expect(response.body).not_to include('Blake Mills')
35
+ end
36
+ end
37
+
38
+ it 'can sort as well as filter' do
39
+ get '/artists?order=genre' do |response|
40
+ genre = JSON.parse(response.body).dig(0, 'genre')
41
+ expect(genre).to eq('alt-soul')
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+ require_relative 'fixtures'
3
+ require 'sinatra/base'
4
+ require 'json'
5
+ require 'benchmark/ips'
6
+
7
+ class App < Sinatra::Base
8
+ get '/conditionals' do
9
+ @artists = DB[:artists]
10
+ if (genre = params[:genre])
11
+ @artists = @artists.grep(:genre, "%#{genre}%", case_insensitive: true)
12
+ end
13
+ if (name = params[:name])
14
+ @artists = @artists.grep(:name, "%#{name}%", case_insensitive: true)
15
+ end
16
+
17
+ @artists.to_json
18
+ end
19
+
20
+ get '/reduction' do
21
+ @artists = Rack::Reducer.call(params, dataset: DB[:artists], filters: [
22
+ ->(genre:) { grep(:genre, "%#{genre}%", case_insensitive: true) },
23
+ ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
24
+ ])
25
+
26
+ @artists.to_json
27
+ end
28
+ end
29
+
30
+ describe 'Performance' do
31
+ let(:app) { App }
32
+
33
+ it 'compares favorably to spaghetti code when params are empty' do
34
+ Benchmark.ips(3) do |bm|
35
+ bm.report('conditionals, empty params') do
36
+ get '/conditionals'
37
+ end
38
+ bm.report('reduction, empty params') do
39
+ get '/reduction'
40
+ end
41
+ bm.compare!
42
+ end
43
+ end
44
+
45
+ it 'compares favorably to spaghetti code when params are full' do
46
+ Benchmark.ips(3) do |bm|
47
+ bm.report('conditionals, full params') do
48
+ get '/conditionals?name=blake&genre=electronic'
49
+ end
50
+ bm.report('reduction, full params') do
51
+ get '/reduction?name=blake&genre=electronic'
52
+ end
53
+ bm.compare!
54
+ end
55
+ end
56
+ end
data/spec/fixtures.rb ADDED
@@ -0,0 +1,19 @@
1
+ # hash fixture data
2
+ ARTISTS = [
3
+ { name: 'Blake Mills', genre: 'alternative' },
4
+ { name: 'Björk', genre: 'electronic' },
5
+ { name: 'James Blake', genre: 'electronic' },
6
+ { name: 'Janelle Monae', genre: 'alt-soul' },
7
+ { name: 'SZA', genre: 'alt-soul' },
8
+ ].freeze
9
+
10
+ require 'sequel'
11
+ DB = Sequel.sqlite
12
+
13
+ DB.create_table :artists do
14
+ String :name
15
+ String :genre
16
+ end
17
+
18
+ # put the hash fixtures into an in-memory SQLite database
19
+ ARTISTS.each { |artist| DB[:artists].insert(artist) }
@@ -0,0 +1,22 @@
1
+ require 'spec_helper'
2
+ require_relative 'fixtures'
3
+ require 'json'
4
+
5
+ # mount Rack::Reducer as middleware, let it filter data into env['rack.reduction'],
6
+ # and respond with env['rack.reduction'].to_json
7
+ module MiddlewareTest
8
+ def self.app
9
+ Rack::Builder.new do
10
+ use Rack::Reducer, dataset: ARTISTS, filters: [
11
+ ->(genre:) { select { |item| item[:genre].match(/#{genre}/i) } },
12
+ ->(name:) { select { |item| item[:name].match(/#{name}/i) } },
13
+ ->(order:) { sort_by { |item| item[order.to_sym] } }
14
+ ]
15
+ run ->(env) { [200, {}, [env['rack.reduction'].to_json]] }
16
+ end
17
+ end
18
+ end
19
+
20
+ describe MiddlewareTest.app do
21
+ it_behaves_like Rack::Reducer
22
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Channel < ActionCable::Channel::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Connection < ActionCable::Connection::Base
3
+ end
4
+ end
@@ -0,0 +1,2 @@
1
+ class ApplicationController < ActionController::API
2
+ end
@@ -0,0 +1,53 @@
1
+ class ArtistsController < ApplicationController
2
+ before_action :set_artist, only: [:show, :update, :destroy]
3
+
4
+ # GET /artists
5
+ def index
6
+ @artists = Artist.reduce(params)
7
+
8
+ render json: @artists
9
+ end
10
+
11
+ # GET /artists/1
12
+ def show
13
+ render json: @artist
14
+ end
15
+
16
+ # POST /artists
17
+ def create
18
+ @artist = Artist.new(artist_params)
19
+
20
+ if @artist.save
21
+ render json: @artist, status: :created, location: @artist
22
+ else
23
+ render json: @artist.errors, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ # PATCH/PUT /artists/1
28
+ def update
29
+ if @artist.update(artist_params)
30
+ render json: @artist
31
+ else
32
+ render json: @artist.errors, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ # DELETE /artists/1
37
+ def destroy
38
+ @artist.destroy
39
+ end
40
+
41
+ private
42
+ # Use callbacks to share common setup or constraints between actions.
43
+ def set_artist
44
+ @artist = Artist.find(params[:id])
45
+ end
46
+
47
+ # Only allow a trusted parameter "white list" through.
48
+ def artist_params
49
+ params.require(:artist).permit(:name, :genre, :last_release)
50
+ end
51
+ end
52
+ #filters: [:search_genre, :search_name].map { |m| method(m).to_proc },
53
+
@@ -0,0 +1,2 @@
1
+ class ApplicationJob < ActiveJob::Base
2
+ end
@@ -0,0 +1,4 @@
1
+ class ApplicationMailer < ActionMailer::Base
2
+ default from: 'from@example.com'
3
+ layout 'mailer'
4
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ self.abstract_class = true
3
+ end
@@ -0,0 +1,18 @@
1
+ class Artist < ApplicationRecord
2
+ scope :by_name, lambda { |name|
3
+ where('lower(name) like ?', "%#{name.downcase}%")
4
+ }
5
+ def self.search_genre(genre)
6
+ where('lower(genre) like ?', "%#{genre.downcase}%")
7
+ end
8
+
9
+ extend Rack::Reducer
10
+ reduces all, filters: [
11
+ # filters can call class methods...
12
+ ->(genre:) { search_genre(genre) },
13
+ # or scopes...
14
+ ->(name:) { by_name(name) },
15
+ # or inline ActiveRecord queries
16
+ ->(order:) { order(order.to_sym) }
17
+ ]
18
+ end
@@ -0,0 +1,35 @@
1
+ require_relative 'boot'
2
+
3
+ require "rails"
4
+ # Pick the frameworks you want:
5
+ require "active_model/railtie"
6
+ #require "active_job/railtie"
7
+ require "active_record/railtie"
8
+ require "action_controller/railtie"
9
+ require "action_mailer/railtie"
10
+ require "action_view/railtie"
11
+ #require "action_cable/engine"
12
+ # require "sprockets/railtie"
13
+ #require "rails/test_unit/railtie"
14
+
15
+ # Require the gems listed in Gemfile, including any gems
16
+ # you've limited to :test, :development, or :production.
17
+ Bundler.require(*Rails.groups)
18
+
19
+ module ExRails
20
+ class Application < Rails::Application
21
+ # Initialize configuration defaults for originally generated Rails version.
22
+ config.load_defaults 5.1
23
+
24
+
25
+ # Settings in config/environments/* take precedence over those specified here.
26
+ # Application configuration should go into files in config/initializers
27
+ # -- all .rb files in that directory are automatically loaded.
28
+
29
+ # Only loads a smaller set of middleware suitable for API only apps.
30
+ # Middleware like session, flash, cookies can be added back manually.
31
+ # Skip views, helpers and assets when generating a new resource.
32
+ config.api_only = true
33
+
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
2
+
3
+ require 'bundler/setup' # Set up gems listed in the Gemfile.
@@ -0,0 +1,5 @@
1
+ # Load the Rails application.
2
+ require_relative 'application'
3
+
4
+ # Initialize the Rails application.
5
+ Rails.application.initialize!
@@ -0,0 +1,47 @@
1
+ Rails.application.configure do
2
+ # Settings specified here will take precedence over those in config/application.rb.
3
+
4
+ # In the development environment your application's code is reloaded on
5
+ # every request. This slows down response time but is perfect for development
6
+ # since you don't have to restart the web server when you make code changes.
7
+ config.cache_classes = false
8
+
9
+ # Do not eager load code on boot.
10
+ config.eager_load = false
11
+
12
+ # Show full error reports.
13
+ config.consider_all_requests_local = true
14
+
15
+ # Enable/disable caching. By default caching is disabled.
16
+ if Rails.root.join('tmp/caching-dev.txt').exist?
17
+ config.action_controller.perform_caching = true
18
+
19
+ config.cache_store = :memory_store
20
+ config.public_file_server.headers = {
21
+ 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}"
22
+ }
23
+ else
24
+ config.action_controller.perform_caching = false
25
+
26
+ config.cache_store = :null_store
27
+ end
28
+
29
+ # Don't care if the mailer can't send.
30
+ config.action_mailer.raise_delivery_errors = false
31
+
32
+ config.action_mailer.perform_caching = false
33
+
34
+ # Print deprecation notices to the Rails logger.
35
+ config.active_support.deprecation = :log
36
+
37
+ # Raise an error on page load if there are pending migrations.
38
+ config.active_record.migration_error = :page_load
39
+
40
+
41
+ # Raises error for missing translations
42
+ # config.action_view.raise_on_missing_translations = true
43
+
44
+ # Use an evented file watcher to asynchronously detect changes in source code,
45
+ # routes, locales, etc. This feature depends on the listen gem.
46
+ config.file_watcher = ActiveSupport::EventedFileUpdateChecker
47
+ end
@@ -0,0 +1,83 @@
1
+ Rails.application.configure do
2
+ # Settings specified here will take precedence over those in config/application.rb.
3
+
4
+ # Code is not reloaded between requests.
5
+ config.cache_classes = true
6
+
7
+ # Eager load code on boot. This eager loads most of Rails and
8
+ # your application in memory, allowing both threaded web servers
9
+ # and those relying on copy on write to perform better.
10
+ # Rake tasks automatically ignore this option for performance.
11
+ config.eager_load = true
12
+
13
+ # Full error reports are disabled and caching is turned on.
14
+ config.consider_all_requests_local = false
15
+ config.action_controller.perform_caching = true
16
+
17
+ # Attempt to read encrypted secrets from `config/secrets.yml.enc`.
18
+ # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or
19
+ # `config/secrets.yml.key`.
20
+ config.read_encrypted_secrets = true
21
+
22
+ # Disable serving static files from the `/public` folder by default since
23
+ # Apache or NGINX already handles this.
24
+ config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
25
+
26
+
27
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
28
+ # config.action_controller.asset_host = 'http://assets.example.com'
29
+
30
+ # Specifies the header that your server uses for sending files.
31
+ # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
32
+ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
33
+
34
+ # Mount Action Cable outside main process or domain
35
+ # config.action_cable.mount_path = nil
36
+ # config.action_cable.url = 'wss://example.com/cable'
37
+ # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
38
+
39
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
40
+ # config.force_ssl = true
41
+
42
+ # Use the lowest log level to ensure availability of diagnostic information
43
+ # when problems arise.
44
+ config.log_level = :debug
45
+
46
+ # Prepend all log lines with the following tags.
47
+ config.log_tags = [ :request_id ]
48
+
49
+ # Use a different cache store in production.
50
+ # config.cache_store = :mem_cache_store
51
+
52
+ # Use a real queuing backend for Active Job (and separate queues per environment)
53
+ # config.active_job.queue_adapter = :resque
54
+ # config.active_job.queue_name_prefix = "ex_rails_#{Rails.env}"
55
+ config.action_mailer.perform_caching = false
56
+
57
+ # Ignore bad email addresses and do not raise email delivery errors.
58
+ # Set this to true and configure the email server for immediate delivery to raise delivery errors.
59
+ # config.action_mailer.raise_delivery_errors = false
60
+
61
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
62
+ # the I18n.default_locale when a translation cannot be found).
63
+ config.i18n.fallbacks = true
64
+
65
+ # Send deprecation notices to registered listeners.
66
+ config.active_support.deprecation = :notify
67
+
68
+ # Use default logging formatter so that PID and timestamp are not suppressed.
69
+ config.log_formatter = ::Logger::Formatter.new
70
+
71
+ # Use a different logger for distributed setups.
72
+ # require 'syslog/logger'
73
+ # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
74
+
75
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
76
+ logger = ActiveSupport::Logger.new(STDOUT)
77
+ logger.formatter = config.log_formatter
78
+ config.logger = ActiveSupport::TaggedLogging.new(logger)
79
+ end
80
+
81
+ # Do not dump schema after migrations.
82
+ config.active_record.dump_schema_after_migration = false
83
+ end