praxis 2.0.pre.5 → 2.0.pre.6

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 (76) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +0 -1
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +22 -0
  5. data/Gemfile +1 -1
  6. data/Guardfile +2 -1
  7. data/Rakefile +1 -7
  8. data/TODO.md +28 -0
  9. data/lib/api_browser/package-lock.json +7110 -0
  10. data/lib/praxis.rb +6 -4
  11. data/lib/praxis/action_definition.rb +9 -16
  12. data/lib/praxis/application.rb +1 -2
  13. data/lib/praxis/bootloader_stages/routing.rb +2 -4
  14. data/lib/praxis/extensions/attribute_filtering.rb +2 -0
  15. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +148 -157
  16. data/lib/praxis/extensions/attribute_filtering/active_record_patches.rb +15 -0
  17. data/lib/praxis/extensions/attribute_filtering/active_record_patches/5x.rb +90 -0
  18. data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_0.rb +68 -0
  19. data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_1_plus.rb +58 -0
  20. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +35 -0
  21. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +9 -12
  22. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +3 -2
  23. data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +7 -9
  24. data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +6 -9
  25. data/lib/praxis/extensions/pagination.rb +130 -0
  26. data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +42 -0
  27. data/lib/praxis/extensions/pagination/header_generator.rb +70 -0
  28. data/lib/praxis/extensions/pagination/ordering_params.rb +234 -0
  29. data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
  30. data/lib/praxis/extensions/pagination/pagination_params.rb +374 -0
  31. data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +45 -0
  32. data/lib/praxis/handlers/json.rb +2 -0
  33. data/lib/praxis/handlers/www_form.rb +5 -0
  34. data/lib/praxis/handlers/{xml.rb → xml-sample.rb} +6 -0
  35. data/lib/praxis/mapper/active_model_compat.rb +23 -5
  36. data/lib/praxis/mapper/resource.rb +16 -9
  37. data/lib/praxis/mapper/sequel_compat.rb +1 -0
  38. data/lib/praxis/media_type.rb +1 -56
  39. data/lib/praxis/plugins/mapper_plugin.rb +1 -1
  40. data/lib/praxis/plugins/pagination_plugin.rb +71 -0
  41. data/lib/praxis/resource_definition.rb +4 -12
  42. data/lib/praxis/route.rb +2 -4
  43. data/lib/praxis/routing_config.rb +4 -8
  44. data/lib/praxis/tasks/routes.rb +9 -14
  45. data/lib/praxis/validation_handler.rb +1 -2
  46. data/lib/praxis/version.rb +1 -1
  47. data/praxis.gemspec +2 -3
  48. data/spec/functional_spec.rb +9 -6
  49. data/spec/praxis/action_definition_spec.rb +4 -16
  50. data/spec/praxis/api_general_info_spec.rb +6 -6
  51. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +304 -0
  52. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +39 -0
  53. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +34 -0
  54. data/spec/praxis/extensions/field_expansion_spec.rb +6 -24
  55. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +15 -11
  56. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +4 -3
  57. data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +130 -0
  58. data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_active_model.rb +45 -2
  59. data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_sequel.rb +0 -0
  60. data/spec/praxis/media_type_spec.rb +5 -129
  61. data/spec/praxis/request_spec.rb +3 -22
  62. data/spec/praxis/resource_definition_spec.rb +1 -1
  63. data/spec/praxis/response_definition_spec.rb +1 -5
  64. data/spec/praxis/route_spec.rb +2 -9
  65. data/spec/praxis/routing_config_spec.rb +4 -13
  66. data/spec/praxis/types/multipart_array_spec.rb +4 -21
  67. data/spec/spec_app/config/environment.rb +0 -2
  68. data/spec/spec_app/design/api.rb +1 -1
  69. data/spec/spec_app/design/media_types/instance.rb +0 -8
  70. data/spec/spec_app/design/media_types/volume.rb +0 -12
  71. data/spec/spec_app/design/resources/instances.rb +1 -2
  72. data/spec/spec_helper.rb +6 -0
  73. data/spec/support/spec_media_types.rb +0 -73
  74. metadata +35 -45
  75. data/spec/praxis/handlers/xml_spec.rb +0 -177
  76. data/spec/praxis/links_spec.rb +0 -68
@@ -0,0 +1,42 @@
1
+ require_relative 'pagination_handler'
2
+
3
+ module Praxis
4
+ module Extensions
5
+ module Pagination
6
+ class ActiveRecordPaginationHandler < PaginationHandler
7
+
8
+ def self.where_lt(query, attr, value)
9
+ # TODO: common for AR/Sequel? Seems we could use Arel and more-specific Sequel things
10
+ query.where(query.table[attr].lt(value))
11
+ end
12
+
13
+ def self.where_gt(query, attr, value)
14
+ query.where(query.table[attr].gt(value))
15
+ end
16
+
17
+ def self.order(query, order)
18
+ return query unless order
19
+ query = query.reorder('')
20
+
21
+ order.each do |spec_hash|
22
+ direction, name = spec_hash.first
23
+ query = query.order(name => direction)
24
+ end
25
+ query
26
+ end
27
+
28
+ def self.count(query)
29
+ query.count(:all)
30
+ end
31
+
32
+ def self.offset(query, offset)
33
+ query.offset(offset)
34
+ end
35
+
36
+ def self.limit(query, limit)
37
+ query.limit(limit)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,70 @@
1
+ module Praxis
2
+ module Extensions
3
+ module Pagination
4
+ class HeaderGenerator
5
+ def self.build_cursor_headers(paginator:, last_value:, total_count: nil)
6
+ [:next, :prev, :first, :last].each_with_object({}) do |rel_name, info|
7
+ case rel_name
8
+ when :next
9
+ # If we don't know the total, we'll try to go to the next page
10
+ # but assume we're done if there isn't a last value...
11
+ if last_value
12
+ info[:next] = { by: paginator.by, from: last_value, items: paginator.items }
13
+ info[:next][:total_count] = true if total_count.present?
14
+ end
15
+ when :prev
16
+ # Not possible to go back
17
+ when :first
18
+ info[:first] = { by: paginator.by, items: paginator.items }
19
+ info[:first][:total_count] = true if total_count.present?
20
+ when :last
21
+ # Not possible to scroll to last
22
+ end
23
+ end
24
+ end
25
+
26
+ # This is only for plain paging
27
+ def self.build_paging_headers(paginator:, total_count: nil)
28
+ last_page = total_count.nil? ? nil : (total_count / (paginator.items * 1.0)).ceil
29
+ [:next, :prev, :first, :last].each_with_object({}) do |rel_name, info|
30
+ num = case rel_name
31
+ when :first
32
+ 1
33
+ when :prev
34
+ next if paginator.page < 2
35
+ paginator.page - 1
36
+ when :next
37
+ # don't include this link if we know the total and we see there are no more pages
38
+ next if last_page.present? && (paginator.page >= last_page)
39
+ # if we don't know the total, we'll specify to the next page even if it ends up being blank
40
+ paginator.page + 1
41
+ when :last
42
+ next if last_page.blank?
43
+ last_page
44
+ end
45
+ info[rel_name] = {
46
+ page: num,
47
+ items: paginator.items,
48
+ total_count: total_count.present?
49
+ }
50
+ end
51
+ end
52
+
53
+ def self.generate_headers(links:, current_url:, current_query_params:, total_count:)
54
+ mapped = links.map do |(rel, info)|
55
+ # Make sure to encode it our way (with comma-separated args, as it is our own syntax, and not a query string one)
56
+ pagination_param = info.map { |(k, v)| "#{k}=#{v}" }.join(",")
57
+ new_url = current_url + "?" + current_query_params.dup.merge(pagination: pagination_param).to_query
58
+
59
+ LinkHeader::Link.new(new_url, [["rel", rel.to_s]])
60
+ end
61
+ link_header = LinkHeader.new(mapped)
62
+
63
+ headers = { "Link" => link_header.to_s }
64
+ headers["Total-Count"] = total_count if total_count
65
+ headers
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,234 @@
1
+ require 'forwardable'
2
+
3
+ module Praxis
4
+ module Extensions
5
+ module Pagination
6
+ class OrderingParams
7
+ include Attributor::Type
8
+ include Attributor::Dumpable
9
+ extend Forwardable
10
+
11
+ def_delegators :items, :empty?
12
+
13
+ # DSL for restricting how to order.
14
+ # It allows the concrete list of the fields one can use (through 'by_fields')
15
+ # It also allows to enforce that list for all positions of the ordering definition (through 'enforce_for :all|:first')
16
+ # By default, only the first ordering position will be subject to that enforcement (i.e., 'enforce_for :first' is the default)
17
+ # Example
18
+ #
19
+ # attribute :order, Praxis::Types::OrderingParams.for(MediaTypes::Bar) do
20
+ # by_fields :id, :name
21
+ # enforce_for :all
22
+ # end
23
+ class DSLCompiler < Attributor::DSLCompiler
24
+ def by_fields(*fields)
25
+ requested = fields.map(&:to_sym)
26
+ non_matching = requested - target.media_type.attributes.keys
27
+ unless non_matching.empty?
28
+ raise "Error, you've requested to order by fields that do not exist in the mediatype!\n" \
29
+ "The following #{non_matching.size} field/s do not exist in media type #{target.media_type.name} :\n" +
30
+ non_matching.join(',').to_s
31
+ end
32
+ target.fields_allowed = requested
33
+ end
34
+
35
+ def enforce_for(which)
36
+ case which.to_sym
37
+ when :all
38
+ target.enforce_all = true
39
+ when :first
40
+ # nothing, that's the default
41
+ else
42
+ raise "Error: unknown parameter for the 'enforce_for' : #{which}. Only :all or :first are allowed"
43
+ end
44
+ end
45
+ end
46
+
47
+ # Configurable DEFAULTS
48
+ @enforce_all_fields = false
49
+
50
+ def self.enforce_all_fields(newval = nil)
51
+ newval ? @enforce_all_fields = newval : @enforce_all_fields
52
+ end
53
+
54
+ # Ordering type that allows you to specify the ordering characteristing of a requested listing
55
+ # Ordering is based on given mediatype, which allows for ensuring validation of type names etc.
56
+
57
+ # Syntax (similar to json-api)
58
+ # * One can specify ordering based on several fields (to resolve tie breakers) by separating them with commas
59
+ # * Requesting a descending order can be done by adding a `-` before the field name. Prepending a `+` enforces
60
+ # ascending order (which is the default if no sign is specified)
61
+ # Example:
62
+ # `name,last_name,-birth_date`
63
+
64
+ # Abstract class, which needs to be used by subclassing it through the .for method, to link it to a particular
65
+ # MediaType, so that the field name checking and value coercion can be performed
66
+ class << self
67
+ attr_reader :media_type
68
+ attr_accessor :fields_allowed
69
+ attr_accessor :enforce_all # True when we need to enforce the allowed fields at all ordering positions
70
+
71
+ def for(media_type, **_opts)
72
+ unless media_type < Praxis::MediaType
73
+ raise ArgumentError, "Invalid type: #{media_type.name} for Ordering. " \
74
+ "Must be a subclass of MediaType"
75
+ end
76
+
77
+ ::Class.new(self) do
78
+ @media_type = media_type
79
+ if media_type
80
+ # By default all fields in the mediatype are allowed (but defining a DSL block will override it to more specific ones)
81
+ @fields_allowed = media_type.attributes.keys
82
+ end
83
+ # Default is to only enforce the allowed fields in the first ordering position (the one typicall uses an index if there)
84
+ @enforce_all = OrderingParams.enforce_all_fields
85
+ end
86
+ end
87
+ end
88
+
89
+ attr_reader :items
90
+
91
+ def self.native_type
92
+ self
93
+ end
94
+
95
+ def self.name
96
+ 'Praxis::Types::OrderingParams'
97
+ end
98
+
99
+ def self.display_name
100
+ 'Ordering'
101
+ end
102
+
103
+ def self.family
104
+ 'string'
105
+ end
106
+
107
+ def self.constructable?
108
+ true
109
+ end
110
+
111
+ def self.construct(pagination_definition, **options)
112
+ return self if pagination_definition.nil?
113
+
114
+ DSLCompiler.new(self, options).parse(*pagination_definition)
115
+ self
116
+ end
117
+
118
+ def self.example(_context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
119
+ fields = if media_type
120
+ chosen_set = if enforce_all
121
+ fields_allowed.sample(2)
122
+ else
123
+ starting_set = fields_allowed.sample(1)
124
+ simple_attrs = media_type.attributes.select do |_k, attr|
125
+ attr.type == Attributor::String || attr.type < Attributor::Numeric || attr.type < Attributor::Temporal
126
+ end.keys
127
+ starting_set + simple_attrs.select { |attr| attr != starting_set.first }.sample(1)
128
+ end
129
+ chosen_set.each_with_object([]) do |chosen, arr|
130
+ sign = rand(10) < 5 ? "-" : ""
131
+ arr << "#{sign}#{chosen}"
132
+ end.join(',')
133
+ else
134
+ "name,last_name,-birth_date"
135
+ end
136
+ load(fields)
137
+ end
138
+
139
+ def self.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil)
140
+ instance = load(value, context)
141
+ instance.validate(context)
142
+ end
143
+
144
+ def self.load(order, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
145
+ return order if order.is_a?(native_type)
146
+
147
+ parsed_order = {}
148
+ unless order.nil?
149
+ parsed_order = order.split(',').each_with_object([]) do |order_string, arr|
150
+ item = if order_string[0] == '-'
151
+ { desc: order_string[1..-1].to_s }
152
+ elsif order_string[0] == '+'
153
+ { asc: order_string[1..-1].to_s }
154
+ else
155
+ { asc: order_string.to_s }
156
+ end
157
+ arr.push item
158
+ end
159
+ end
160
+
161
+ new(parsed_order)
162
+ end
163
+
164
+ def self.dump(value, **_opts)
165
+ load(value).dump
166
+ end
167
+
168
+ def self.describe(_root = false, example: nil)
169
+ hash = super
170
+
171
+ if fields_allowed
172
+ hash[:fields_allowed] = fields_allowed
173
+ hash[:enforced_for] = enforce_all ? :all : :first
174
+ end
175
+
176
+ hash
177
+ end
178
+
179
+ def initialize(parsed)
180
+ @items = parsed
181
+ end
182
+
183
+ def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
184
+ return [] if items.blank?
185
+
186
+ errors = []
187
+ if self.class.fields_allowed
188
+ # Validate against the enforced components (either all, or just the first one)
189
+ enforceable_items = self.class.enforce_all ? items : [items.first]
190
+
191
+ enforceable_items.each do |spec|
192
+ _dir, field = spec.first
193
+ field = field.to_sym
194
+ next unless !self.class.fields_allowed.include?(field)
195
+ errors << if self.class.media_type.attributes.key?(field)
196
+ "Ordering by field \'#{field}\' is disallowed. Ordering is only allowed using the following fields: " +
197
+ self.class.fields_allowed.map { |f| "\'#{f}\'" }.join(', ').to_s
198
+ else
199
+ "Ordering by field \'#{field}\' is not possible as this field does not exist in " \
200
+ "media type #{self.class.media_type.name}"
201
+ end
202
+ end
203
+ end
204
+
205
+ errors
206
+ end
207
+
208
+ def dump
209
+ items.each_with_object([]) do |spec, arr|
210
+ dir, field = spec.first
211
+ arr << if dir == :desc
212
+ "-#{field}"
213
+ else
214
+ field
215
+ end
216
+ end.join(',')
217
+ end
218
+
219
+ def each
220
+ items.each do |item|
221
+ yield item
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ # Alias it to a much shorter and sweeter name in the Types namespace.
230
+ module Praxis
231
+ module Types
232
+ OrderingParams = Praxis::Extensions::Pagination::OrderingParams
233
+ end
234
+ end
@@ -0,0 +1,68 @@
1
+ module Praxis
2
+ module Extensions
3
+ module Pagination
4
+ class PaginationHandler
5
+ class PaginationException < Exception; end
6
+
7
+ def self.paginate(query, pagination)
8
+ return query unless pagination.paginator
9
+
10
+ paginator = pagination.paginator
11
+
12
+ q = if paginator.page
13
+ offset(query, (paginator.page - 1) * paginator.items)
14
+ else
15
+ # If there is an order clause that complies with the "by" field sorting, we can use it directly
16
+ # i.e., We can be smart about allowing the main sort field matching the pagination one (in case you want to sub-order in a custom way)
17
+ oclause = if pagination.order.nil? || pagination.order.empty? # No ordering specified => use ascending based on the "by" field
18
+ direction = :asc
19
+ order(query, [{ asc: paginator.by }])
20
+ else
21
+ first_ordering = pagination.order.items.first
22
+ direction = first_ordering.keys.first
23
+ unless first_ordering[direction].to_sym == pagination.paginator.by.to_sym
24
+ string_clause = pagination.order.items.map { |h|
25
+ dir, name = h.first
26
+ "#{name} #{dir}"
27
+ }.join(',')
28
+ raise PaginationException,
29
+ "Ordering clause [#{string_clause}] is incompatible with pagination by field '#{pagination.paginator.by}'. " \
30
+ "When paginating by a field value, one cannot specify the 'order' clause " \
31
+ "unless the clause's primary field matches the pagination field."
32
+ end
33
+ order(query, pagination.order)
34
+ end
35
+
36
+ if paginator.from
37
+ if direction == :desc
38
+ where_lt(oclause, paginator.by, paginator.from)
39
+ else
40
+ where_gt(oclause, paginator.by, paginator.from)
41
+ end
42
+ else
43
+ oclause
44
+ end
45
+
46
+ end
47
+ limit(q, paginator.items)
48
+ end
49
+
50
+ def self.where_lt(query, attr, value)
51
+ raise "implement in derived class"
52
+ end
53
+
54
+ def self.where_gt(query, attr, value)
55
+ raise "implement in derived class"
56
+ end
57
+
58
+ def self.offset(query, offset)
59
+ raise "implement in derived class"
60
+ end
61
+
62
+ def self.limit(query, limit)
63
+ raise "implement in derived class"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,374 @@
1
+ module Praxis
2
+ module Extensions
3
+ module Pagination
4
+ class PaginationParams
5
+ include Attributor::Type
6
+ include Attributor::Dumpable
7
+
8
+ # Pagination type that allows you to define a parameter type in an endpoint, including a DSL to restrict or configure
9
+ # the pagination options of every single definition, and which will take care of parsing, coercing and validating the
10
+ # pagination syntax below. It also takes care of generating the Link
11
+ # and total count headers.
12
+ # Pagination supports both cursor and page-based:
13
+ # 1 - Page-based pagination (i.e., offset-limit based paging assuming an explicit or implicit ordering underneath)
14
+ # * it is done by using the 'page=<integer>' parameter, indicating which page number to output (based on a given page size)
15
+ # * setting the page size on a per-request basis can be achieved by setting the 'items=<integer>` parameter
16
+ # * example: `page=5,items=50` (should return items 200-250 from the collection)
17
+ # 2- Cursor-based pagination (paginating based on a field value)
18
+ # * it is done by using 'by=email', indicating the field name to use, and possibly using 'from=joe@example.com' to indicate
19
+ # after which values of the field to start listing (no 'from' values assume starting from the beginning).
20
+ # * the default page size can be overriden on a per-request basis as well with the 'items=<integer>` parameter
21
+ # # example `by=email,from=joe@example.com,items=100`
22
+ #
23
+ # In either situation, one can also request to receive the total count of elements existing in the collection (pre-paging)
24
+ # by using 'total_count=true'.
25
+ #
26
+ # Every pagination request will also receive a Link header (RFC 5988) properly populated with followable links.
27
+ # When the 'total_count=true' parameter is used, a 'Total-Count' header will also be returned containing the total number
28
+ # of existing results pre-pagination. Note that calculating the total count incurs in an extra DB query so it does have
29
+ # performance implications
30
+
31
+ ######################################################
32
+ # DSL for definition pagination parameters in a defined filter.
33
+ # Available options are:
34
+ #
35
+ # One can limit which fields the pagination (by cursor) can be allowed. Typically only indexed fields should
36
+ # be allowed for performance reasons:
37
+ # * by_fields <Array of field names> (if not provided, all fields are allowed)
38
+ # One can limit the total maximum of items of the requested page size from the client can ask for:
39
+ # * max_items <integer> (there is a static upper limit to thie value set by the MAX_ITEMS constant)
40
+ # One can set the default amount of items to return when not specified by the client
41
+ # * page_size <integer> (less or equal than max_items, if the max is set)
42
+ # One can expicitly disallow either paging or cursor based pagination (by default both are allowed)
43
+ # * disallow :paging | :cursor
44
+ # One can set the default pagination mode when no :page, :by/:from parameters are passed in.
45
+ # * default <mode>: <value> where mode can be :page or :by (and the value is an integer or a field name respectively)
46
+ #
47
+ # Here's a full example:
48
+ # attribute :pagination, Types::PaginationParams.for(MediaTypes::Book) do
49
+ # by_fields :id, :name
50
+ # max_items 500
51
+ # page_size 50
52
+ # disallow :paging
53
+ # default by: :id
54
+ # end
55
+ class DSLCompiler < Attributor::DSLCompiler
56
+ def by_fields(*fields)
57
+ requested = fields.map(&:to_sym)
58
+ non_matching = requested - target.media_type.attributes.keys
59
+ unless non_matching.empty?
60
+ raise "Error, you've requested to paginate by fields that do not exist in the mediatype!\n" \
61
+ "The following #{non_matching.size} field/s do not exist in media type #{target.media_type.name} :\n" +
62
+ non_matching.join(',').to_s
63
+ end
64
+ target.fields_allowed = requested
65
+ end
66
+
67
+ def max_items(max)
68
+ target.defaults[:max_items] = Integer(max)
69
+ end
70
+
71
+ def page_size(size)
72
+ target.defaults[:page_size] = Integer(size)
73
+ end
74
+
75
+ def disallow(pagination_type)
76
+ default_mode, default_value = target.defaults[:default_mode].first
77
+ case pagination_type
78
+ when :paging
79
+ if default_mode == :page
80
+ raise "Cannot disallow page-based pagination if you define a default pagination of: page: #{default_value}"
81
+ end
82
+ target.defaults[:disallow_paging] = true
83
+ when :cursor
84
+ if default_mode == :by
85
+ raise "Cannot disallow cursor-based pagination if you define a default pagination of: by: #{default_value}"
86
+ end
87
+ target.defaults[:disallow_cursor] = true
88
+ end
89
+ end
90
+
91
+ def default(spec)
92
+ unless spec.is_a?(Hash) && spec.keys.size == 1 && [:by, :page].include?(spec.keys.first)
93
+ raise "'default' syntax for pagination takes exactly one key specification. Either by: <:fieldname> or page: <num>" \
94
+ "#{spec} is invalid"
95
+ end
96
+ mode, value = spec.first
97
+ def_mode = case mode
98
+ when :by
99
+ if target.fields_allowed && !target.fields_allowed&.include?(value)
100
+ raise "Error setting default pagination. Field #{value} is not amongst the allowed fields."
101
+ end
102
+ if target.defaults[:disallow_cursor]
103
+ raise "Cannot define a default pagination that is cursor based, if cursor-based pagination is disallowed."
104
+ end
105
+ { by: value }
106
+ when :page
107
+ unless value.is_a?(Integer)
108
+ raise "Error setting default pagination. Initial page should be a integer (but got #{value})"
109
+ end
110
+ if target.defaults[:disallow_paging]
111
+ raise "Cannot define a default pagination that is page-based, if page-based pagination is disallowed."
112
+ end
113
+ { page: value }
114
+ end
115
+ target.defaults[:default_mode] = def_mode
116
+ end
117
+ end
118
+
119
+ # Configurable DEFAULTS
120
+ @max_items = nil # Unlimited by default (since it's not possible to set it to nil for now from the app)
121
+ @default_page_size = 100
122
+ @paging_default_mode = { page: 1 }
123
+ @disallow_paging_by_default = false
124
+ @disallow_cursor_by_default = false
125
+
126
+ def self.max_items(newval = nil)
127
+ newval ? @max_items = newval : @max_items
128
+ end
129
+
130
+ def self.default_page_size(newval = nil)
131
+ newval ? @default_page_size = newval : @default_page_size
132
+ end
133
+
134
+ def self.disallow_paging_by_default(newval = nil)
135
+ newval ? @disallow_paging_by_default = newval : @disallow_paging_by_default
136
+ end
137
+
138
+ def self.disallow_cursor_by_default(newval = nil)
139
+ newval ? @disallow_cursor_by_default = newval : @disallow_cursor_by_default
140
+ end
141
+
142
+ def self.paging_default_mode(newval = nil)
143
+ if newval
144
+ unless newval.respond_to?(:keys) && newval.keys.size == 1 && [:by, :page].include?(newval.keys.first)
145
+ raise "Error setting paging_default_mode, value must be a hash with :by or :page keys"
146
+ end
147
+ @paging_default_mode = newval
148
+ end
149
+ @paging_default_mode
150
+ end
151
+
152
+ # Abstract class, which needs to be used by subclassing it through the .for method, to link it to a particular
153
+ # MediaType, so that the field name checking and value coercion can be performed
154
+ class << self
155
+ attr_reader :media_type
156
+ attr_reader :defaults
157
+ attr_accessor :fields_allowed
158
+
159
+ def for(media_type, **_opts)
160
+ unless media_type < Praxis::MediaType
161
+ raise ArgumentError, "Invalid type: #{media_type.name} for Paginator. " \
162
+ "Must be a subclass of MediaType"
163
+ end
164
+
165
+ ::Class.new(self) do
166
+ @media_type = media_type
167
+ @defaults = {
168
+ page_size: PaginationParams.default_page_size,
169
+ max_items: PaginationParams.max_items,
170
+ disallow_paging: PaginationParams.disallow_paging_by_default,
171
+ disallow_cursor: PaginationParams.disallow_cursor_by_default,
172
+ default_mode: PaginationParams.paging_default_mode
173
+ }
174
+ end
175
+ end
176
+ end
177
+
178
+ def self.native_type
179
+ self
180
+ end
181
+
182
+ def self.name
183
+ 'Extensions::Pagination::PaginationParams'
184
+ end
185
+
186
+ def self.display_name
187
+ 'Paginator'
188
+ end
189
+
190
+ def self.family
191
+ 'string'
192
+ end
193
+
194
+ def self.constructable?
195
+ true
196
+ end
197
+
198
+ def self.construct(pagination_definition, **options)
199
+ return self if pagination_definition.nil?
200
+
201
+ DSLCompiler.new(self, options).parse(*pagination_definition)
202
+ self
203
+ end
204
+
205
+ attr_reader :by
206
+ attr_reader :from
207
+ attr_reader :items
208
+ attr_reader :page
209
+ attr_reader :total_count
210
+
211
+ def self.example(_context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
212
+ fields = if media_type
213
+ mt_example = media_type.example
214
+ simple_attrs = media_type.attributes.select do |_k, attr|
215
+ attr.type == Attributor::String || attr.type == Attributor::Integer
216
+ end
217
+
218
+ selectable = mt_example.object.keys & simple_attrs.keys
219
+ by = selectable.sample(1).first
220
+ from = media_type.attributes[by].example(parent: mt_example)
221
+ # Make sure to encode the value of the from, as it can contain commas and such
222
+ from = CGI.escape(from) if from.is_a? String
223
+ "by=#{by},from=#{from},items=#{defaults[:page_size]}"
224
+ else
225
+ "by=id,from=20,items=100"
226
+ end
227
+ load(fields)
228
+ end
229
+
230
+ def self.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil)
231
+ instance = load(value, context)
232
+ instance.validate(context)
233
+ end
234
+
235
+ CLAUSE_REGEX = /(?<type>[^=]+)=(?<value>.+)$/
236
+ def self.load(paginator, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
237
+ return paginator if paginator.is_a?(native_type) || paginator.nil?
238
+ parsed = {}
239
+ unless paginator.nil?
240
+ parsed = paginator.split(',').each_with_object({}) do |paginator_string, hash|
241
+ match = CLAUSE_REGEX.match(paginator_string)
242
+ case match[:type].to_sym
243
+ when :page
244
+ hash[:page] = Integer(match[:value])
245
+ when :by
246
+ hash[:by] = match[:value]
247
+ when :from
248
+ hash[:from] = match[:value]
249
+ when :total_count
250
+ hash[:total_count] = (match[:value] != 'false') # unless explicitly set to false, we'll take it as true...
251
+ when :items
252
+ hash[:items] = Integer(match[:value])
253
+ else
254
+ raise "Error loading pagination parameters: unknown parameter with name '#{match[:type]}' found"
255
+ end
256
+ end
257
+ end
258
+
259
+ parsed[:items] = defaults[:page_size] unless parsed.key?(:items)
260
+ parsed[:from] = coerce_field(parsed[:by], parsed[:from]) if parsed.key?(:from)
261
+
262
+ # If no by/from or page specified, we're gonna apply the defaults
263
+ unless parsed.key?(:by) || parsed.key?(:from) || parsed.key?(:page)
264
+ mode, value = defaults[:default_mode].first
265
+ case mode
266
+ when :by
267
+ parsed[:by] = value
268
+ when :page
269
+ parsed[:page] = value
270
+ end
271
+ end
272
+
273
+ new(parsed)
274
+ end
275
+
276
+ def self.dump(value, **_opts)
277
+ load(value).dump
278
+ end
279
+
280
+ def self.describe(_root = false, example: nil)
281
+ hash = super
282
+
283
+ hash[:fields_allowed] = fields_allowed if fields_allowed
284
+ if defaults
285
+ hash[:max_items] = defaults[:max_items]
286
+ hash[:page_size] = defaults[:page_size]
287
+ hash[:default_mode] = defaults[:default_mode]
288
+
289
+ disallowed = []
290
+ disallowed << :paging if defaults[:disallow_paging] == true
291
+ disallowed << :cursor if defaults[:disallow_cursor] == true
292
+ hash[:disallowed] = disallowed unless disallowed.empty?
293
+ end
294
+
295
+ hash
296
+ end
297
+
298
+ # Silently ignore if the fiels does not exist...let's let the validation check it instead
299
+ def self.coerce_field(name, value)
300
+ if media_type&.attributes
301
+ attrs = media_type&.attributes || {}
302
+ attribute = attrs[name.to_sym]
303
+ attribute.type.load(value) if attribute
304
+ else
305
+ value
306
+ end
307
+ end
308
+
309
+ # Instance methods
310
+ def initialize(parsed)
311
+ @by = parsed[:by]
312
+ @from = parsed[:from]
313
+ @items = parsed[:items]
314
+ @page = parsed[:page]
315
+ @total_count = parsed[:total_count]
316
+ end
317
+
318
+ def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT) # rubocop:disable Metrics/PerceivedComplexity
319
+ errors = []
320
+
321
+ if page
322
+ if self.class.defaults[:disallow_paging]
323
+ errors << "Page-based pagination is disallowed (i.e., using 'page=' parameter)"
324
+ end
325
+ elsif self.class.defaults[:disallow_cursor]
326
+ errors << "Cursor-based pagination is disallowed (i.e., using 'by=' or 'from=' parameter)"
327
+ end
328
+
329
+ if page && page <= 0
330
+ errors << "Page parameter cannot be zero or negative! (got: #{parsed.page})"
331
+ end
332
+
333
+ if items && (items <= 0 || ( self.class.defaults[:max_items] && items > self.class.defaults[:max_items]) )
334
+ errors << "Value of 'items' is invalid (got: #{items}). It must be positive, and smaller than the maximum amount of items per request (set to #{self.class.defaults[:max_items]})"
335
+ end
336
+
337
+ if page && (by || from)
338
+ errors << "Cannot specify the field to use and its start value to paginate from when using a fix pager (i.e., `by` and/or `from` params are not compabible with `page`)"
339
+ end
340
+
341
+ if by && self.class.fields_allowed && !self.class.fields_allowed.include?(by.to_sym)
342
+ errors << if self.class.media_type.attributes.key?(by.to_sym)
343
+ "Paginating by field \'#{by}\' is disallowed"
344
+ else
345
+ "Paginating by field \'#{by}\' is not possible as this field does not exist in "\
346
+ "media type #{self.class.media_type.name}"
347
+ end
348
+ end
349
+ errors
350
+ end
351
+
352
+ # Dump back string parseable form
353
+ def dump
354
+ str = if @page
355
+ "page=#{@page}"
356
+ else
357
+ s = "by=#{@by}"
358
+ s += ",from=#{@from}" if @from
359
+ end
360
+ str += ",items=#{items}" if @items
361
+ str += ",total_count=true" if @total_count
362
+ str
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
368
+
369
+ # Alias it to a much shorter and sweeter name in the Types namespace.
370
+ module Praxis
371
+ module Types
372
+ PaginationParams = Praxis::Extensions::Pagination::PaginationParams
373
+ end
374
+ end