might 0.3.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 (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