might 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.hound.yml +2 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +19 -0
  6. data/.travis.yml +14 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +202 -0
  9. data/README.md +301 -0
  10. data/Rakefile +6 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +7 -0
  13. data/config/locales/en.yml +7 -0
  14. data/lib/might.rb +4 -0
  15. data/lib/might/fetcher.rb +252 -0
  16. data/lib/might/fetcher_error.rb +4 -0
  17. data/lib/might/filter_middleware.rb +28 -0
  18. data/lib/might/filter_parameter.rb +68 -0
  19. data/lib/might/filter_parameter_definition.rb +82 -0
  20. data/lib/might/filter_parameters_extractor.rb +78 -0
  21. data/lib/might/filter_parameters_validator.rb +26 -0
  22. data/lib/might/filter_predicates.rb +26 -0
  23. data/lib/might/filter_undefined_parameter.rb +15 -0
  24. data/lib/might/filter_value_validator.rb +43 -0
  25. data/lib/might/pagination_middleware.rb +60 -0
  26. data/lib/might/pagination_parameters_validator.rb +55 -0
  27. data/lib/might/paginator.rb +58 -0
  28. data/lib/might/railtie.rb +8 -0
  29. data/lib/might/ransackable_filter.rb +24 -0
  30. data/lib/might/ransackable_filter_parameters_adapter.rb +61 -0
  31. data/lib/might/ransackable_sort.rb +27 -0
  32. data/lib/might/ransackable_sort_parameters_adapter.rb +23 -0
  33. data/lib/might/result.rb +68 -0
  34. data/lib/might/sort_middleware.rb +31 -0
  35. data/lib/might/sort_parameter.rb +54 -0
  36. data/lib/might/sort_parameter_definition.rb +54 -0
  37. data/lib/might/sort_parameters_extractor.rb +62 -0
  38. data/lib/might/sort_parameters_validator.rb +26 -0
  39. data/lib/might/sort_undefined_parameter.rb +15 -0
  40. data/lib/might/sort_value_validator.rb +43 -0
  41. data/lib/might/version.rb +4 -0
  42. data/might.gemspec +33 -0
  43. metadata +240 -0
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:rspec)
5
+
6
+ task default: :rspec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'might'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,7 @@
1
+ en:
2
+ activemodel:
3
+ errors:
4
+ messages:
5
+ undefined_filter: is not allowed filter name
6
+ undefined_sort_order: is not allowed sort order
7
+ invalid_page_type: is expected to be a HashMap with :limit and :offset keys
@@ -0,0 +1,4 @@
1
+ # Top level namespace for mighty fetchers
2
+ module Might
3
+ require 'might/version'
4
+ end
@@ -0,0 +1,252 @@
1
+ require 'might/sort_parameters_extractor'
2
+ require 'might/sort_parameters_validator'
3
+ require 'might/filter_parameters_extractor'
4
+ require 'might/filter_parameters_validator'
5
+ require 'might/pagination_parameters_validator'
6
+
7
+ require 'might/filter_middleware'
8
+ require 'might/sort_middleware'
9
+ require 'might/pagination_middleware'
10
+
11
+ require 'might/result'
12
+ require 'uber/inheritable_attr'
13
+ require 'middleware'
14
+
15
+ #
16
+ module Might
17
+ # Configure your own fetcher
18
+ #
19
+ # PagesFetcher < Might::Fetcher
20
+ # self.resource_class = Page
21
+ # end
22
+ #
23
+ # You can configure filterable attributes for model
24
+ #
25
+ # filter :id, validates: { presence: true }
26
+ # filter :name
27
+ # filter :start_at, validates: { presence: true }
28
+ #
29
+ # If your property name doesn't match the name in the query string, use the :as option:
30
+ #
31
+ # filter :kind, as: :type
32
+ #
33
+ # So the Movie#kind property would be exposed to API as :type
34
+ #
35
+ # You may specify allowed sorting order:
36
+ #
37
+ # sort :id
38
+ # sort :name
39
+ #
40
+ # If your property name doesn't match the name in the query string, use the :as option:
41
+ #
42
+ # sort :position, as: :relevance
43
+ #
44
+ # So client should pass +?sort=relevance+ in order to sort by position
45
+ #
46
+ # It's also possible to reverse meaning of the order direction. For example it's not
47
+ # make sense to order by position from lower value to higher.
48
+ # The meaning default for that sorting is reversed order by default, so more relevant elenents
49
+ # would be the first.
50
+ #
51
+ # sort :position, as: :relevance, reverse_direction: true
52
+ #
53
+ class Fetcher
54
+ extend Uber::InheritableAttr
55
+
56
+ inheritable_attr :resource_class
57
+ inheritable_attr :filter_parameters_definition
58
+ inheritable_attr :sort_parameters_definition
59
+ inheritable_attr :middleware_changes
60
+
61
+ self.filter_parameters_definition = Set.new
62
+ self.sort_parameters_definition = Set.new
63
+ self.middleware_changes = []
64
+
65
+ # @return [Hash]
66
+ attr_reader :params
67
+
68
+ # @param params [Hash]
69
+ def initialize(params)
70
+ @params = params
71
+ end
72
+
73
+ # @return [ActiveRecord::Result] filtered and sorted collection
74
+ # @yieldparam collection [Result] if a block given
75
+ #
76
+ # @example
77
+ # PagesFetcher.new(params).call #=> Result
78
+ #
79
+ # @example block syntax
80
+ # PagesFetcher.new(params) do |result|
81
+ # if result.success?
82
+ # result.get
83
+ # else
84
+ # result.errors
85
+ # end
86
+ # end
87
+ #
88
+ def call
89
+ processed_params, errors = process_params(params)
90
+ result = if errors.any?
91
+ Failure.new(errors)
92
+ else
93
+ processed_collection, = middleware.call([collection, processed_params])
94
+ Success.new(processed_collection)
95
+ end
96
+
97
+ if block_given?
98
+ yield result
99
+ else
100
+ result
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # @return [ActiveRecord::Relation]
107
+ def collection
108
+ self.class.resource_class.all
109
+ end
110
+
111
+ # Library middleware stack
112
+ # @return [Middleware::Builder]
113
+ def default_middleware
114
+ Middleware::Builder.new do |b|
115
+ b.use FilterMiddleware
116
+ b.use SortMiddleware
117
+ b.use PaginationMiddleware
118
+ end
119
+ end
120
+
121
+ # User modified middleware stack
122
+ # @return [Middleware::Builder]
123
+ def middleware
124
+ default_middleware.tap do |builder|
125
+ self.class.middleware_changes.each do |change|
126
+ builder.instance_eval(&change)
127
+ end
128
+ end
129
+ end
130
+
131
+ # @return [Hash, Array] tuple of parameters and processing errors
132
+ # this errors may be shown to front-end developer
133
+ def process_params(params)
134
+ Middleware::Builder.new do |b|
135
+ b.use FilterParametersExtractor, self.class.filter_parameters_definition
136
+ b.use FilterParametersValidator
137
+ b.use SortParametersExtractor, self.class.sort_parameters_definition
138
+ b.use SortParametersValidator
139
+ b.use PaginationParametersValidator
140
+ end.call([params, []])
141
+ end
142
+
143
+ class << self
144
+ # Alter middleware chain with the given block
145
+ # @param [Proc] change
146
+ # @return [Might]
147
+ #
148
+ # @example
149
+ # class ChannelsFetcher
150
+ # middleware do
151
+ # use CustomMiddleware
152
+ # end
153
+ # end
154
+ #
155
+ def middleware(&change)
156
+ fail ArgumentError unless block_given?
157
+ middleware_changes << change
158
+ self
159
+ end
160
+
161
+ # Register collection sorting by its name
162
+ # @param name [Symbol] of the field
163
+ # @return [void]
164
+ # @see +RansackableSort+ for details
165
+ #
166
+ def sort(name, options = {})
167
+ definition = SortParameterDefinition.new(name, options)
168
+ sort_parameters_definition.add(definition)
169
+ end
170
+
171
+ # Register collection filter by its name
172
+ # @see +Might::Filter+ for details
173
+ #
174
+ # @overload filter(filter_name, options)
175
+ # @param [Symbol] filter_name
176
+ # @param [Hash] options
177
+ # @return [void]
178
+ # @example
179
+ # filter :genre_name, on: :resource
180
+ #
181
+ # @overload filter(filter_name: predicates, **options)
182
+ # @param [Symbol] filter_name
183
+ # @param [<Symbol>] predicates
184
+ # @param [Hash] other options options
185
+ # @return [void]
186
+ # @example
187
+ # filter genre_name: [:eq, :in], on: :resource
188
+ #
189
+ def filter(*args)
190
+ options = args.extract_options!
191
+ if args.empty?
192
+ filter_name = options.keys.first
193
+ predicates = options.values.first
194
+ options = options.except(filter_name).merge(predicates: predicates)
195
+ else
196
+ filter_name = args.first
197
+ end
198
+
199
+ definition = FilterParameterDefinition.new(filter_name, options)
200
+ filter_parameters_definition.add(definition)
201
+ end
202
+
203
+ # @param params [Hash] user provided input
204
+ # @yieldparam collection [ActiveRecord::Relation]
205
+ def run(params, &block)
206
+ new(params).call(&block)
207
+ end
208
+
209
+ # Add middleware to the end of middleware chane
210
+ #
211
+ # class MovieFetcher
212
+ # after do |scope, params|
213
+ # # do something with scope and params
214
+ # [scope.map(&:resource), params]
215
+ # end
216
+ # end
217
+ #
218
+ def after(&block)
219
+ alter_middleware(:use, &block)
220
+ end
221
+
222
+ # Add middleware to the beginning of middleware chane
223
+ #
224
+ # class MovieFetcher
225
+ # before do |scope, params|
226
+ # # do something with scope and params
227
+ # [scope.map(&:resource), params]
228
+ # end
229
+ # end
230
+ #
231
+ def before(middleware_or_index = 0, &block)
232
+ alter_middleware(:insert_before, middleware_or_index, &block)
233
+ end
234
+
235
+ private
236
+
237
+ def alter_middleware(method_name, *args, &block)
238
+ fail ArgumentError unless block_given?
239
+ middleware_changes.push lambda { |builder|
240
+ builder.send method_name, *args, lambda { |env|
241
+ scope, params = env
242
+ block.call(scope, params).tap do |r|
243
+ if !r.is_a?(Array) || r.size != 2
244
+ fail 'After block must return tuple of scope and params'
245
+ end
246
+ end
247
+ }
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,4 @@
1
+ module Might
2
+ class FetcherError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,28 @@
1
+ require_relative 'ransackable_filter'
2
+ require_relative 'ransackable_filter_parameters_adapter'
3
+ require 'middleware'
4
+ #
5
+ module Might
6
+ # Filter scope using ransack gem
7
+ #
8
+ class FilterMiddleware
9
+ # @param app [#call, Proc]
10
+ #
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ scope, = ::Middleware::Builder.new do |b|
17
+ b.use RansackableFilterParametersAdapter
18
+ b.use RansackableFilter
19
+ end.call(env)
20
+
21
+ app.call([scope, env[1]])
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :app
27
+ end
28
+ end
@@ -0,0 +1,68 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+ require 'active_support/core_ext/object/blank'
3
+
4
+ module Might
5
+ # User provided filtering on particular parameter
6
+ #
7
+ class FilterParameter
8
+ # @return [String, nil]
9
+ #
10
+ attr_reader :predicate
11
+
12
+ # @return [ParameterDefinition, nil]
13
+ #
14
+ attr_reader :definition
15
+
16
+ def initialize(value, predicate, definition)
17
+ @value = value
18
+ @predicate = predicate.to_s
19
+ @definition = definition
20
+ end
21
+
22
+ delegate :name, to: :definition
23
+ delegate :on, to: :definition
24
+
25
+ # Check if this parameter provided by user.
26
+ # If the parameter defined by not given by user this returns false.
27
+ # @return [Boolean]
28
+ def provided?
29
+ value.present? && predicate.present?
30
+ end
31
+
32
+ def ==(other)
33
+ is_a?(other.class) &&
34
+ value == other.value &&
35
+ predicate == other.predicate &&
36
+ definition == other.definition
37
+ end
38
+
39
+ # @return [String, Array<String>]
40
+ # TODO: memoize
41
+ def value
42
+ if @value.is_a?(Array)
43
+ @value.map { |v| definition.coerce(v) }
44
+ else
45
+ definition.coerce(@value)
46
+ end
47
+ end
48
+
49
+ def valid?
50
+ validators.all?(&:valid?)
51
+ end
52
+
53
+ def invalid?
54
+ validators.any?(&:invalid?)
55
+ end
56
+
57
+ def errors
58
+ validators.map(&:errors).flat_map(&:full_messages).compact.uniq
59
+ end
60
+
61
+ private
62
+
63
+ def validators
64
+ values_for_validation = @value.is_a?(Array) ? @value : [@value]
65
+ @validators ||= values_for_validation.map { |v| definition.validator.new(v) }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,82 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+ require_relative 'filter_predicates'
3
+ require_relative 'filter_value_validator'
4
+
5
+ module Might
6
+ # Filtering parameter definition
7
+ #
8
+ class FilterParameterDefinition
9
+ # If the property name doesn't match the name in the query string, use the :as option
10
+ # @return [String]
11
+ attr_reader :as
12
+
13
+ # @return [String]
14
+ attr_reader :name
15
+
16
+ # Association on which this parameter is defined.
17
+ # @return [String, nil]
18
+ attr_reader :on
19
+
20
+ # White-listed predicates
21
+ # @return [<String>]
22
+ attr_reader :predicates
23
+
24
+ # @param [String] name of the field
25
+ # @param [Hash] options
26
+ # @option options [Symbol, {Symbol => String}, {Symbol => <String>}] :on (nil) filter on given relation
27
+ # @option options [<String>] :predicates (ALL_PREDICATES) white-listed predicates
28
+ # @option options [Proc] :coerce coercion for the value
29
+ # @option options [Hash{Symbol => any}] :validates for the value
30
+ # @example
31
+ # validates: presence: true, length: { is: 6 }
32
+ #
33
+ def initialize(name, options = {})
34
+ options.assert_valid_keys(:as, :on, :predicates, :coerce, :validates)
35
+ @as = options.fetch(:as, name).to_s
36
+ @name = name.to_s
37
+ @on = options[:on]
38
+ @predicates = Array(options.fetch(:predicates, FilterPredicates::ALL)).map(&:to_s)
39
+ @coerce = options.fetch(:coerce, ->(v) { v })
40
+ @validations = options.fetch(:validates, {})
41
+ end
42
+
43
+ # If two parameters have the same name, they are equal.
44
+ delegate :hash, to: :name
45
+
46
+ def eql?(other)
47
+ other.is_a?(self.class) && other.name == name
48
+ end
49
+
50
+ def ==(other)
51
+ other.is_a?(self.class) &&
52
+ other.name == name &&
53
+ other.as == as &&
54
+ other.on == on &&
55
+ other.predicates == predicates
56
+ end
57
+
58
+ # @param [any] value
59
+ # @return [any] coerced value according the definition
60
+ #
61
+ def coerce(value)
62
+ @coerce.call(value)
63
+ end
64
+
65
+ def validator
66
+ FilterValueValidator.build(self)
67
+ end
68
+
69
+ def defined?
70
+ true
71
+ end
72
+
73
+ def undefined?
74
+ !self.defined?
75
+ end
76
+
77
+ # Proc with defined validations
78
+ # @return [Proc]
79
+ # @api private
80
+ attr_reader :validations
81
+ end
82
+ end