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.
- checksums.yaml +5 -5
- data/.rspec +0 -1
- data/.ruby-version +1 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile +1 -1
- data/Guardfile +2 -1
- data/Rakefile +1 -7
- data/TODO.md +28 -0
- data/lib/api_browser/package-lock.json +7110 -0
- data/lib/praxis.rb +6 -4
- data/lib/praxis/action_definition.rb +9 -16
- data/lib/praxis/application.rb +1 -2
- data/lib/praxis/bootloader_stages/routing.rb +2 -4
- data/lib/praxis/extensions/attribute_filtering.rb +2 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +148 -157
- data/lib/praxis/extensions/attribute_filtering/active_record_patches.rb +15 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/5x.rb +90 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_0.rb +68 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_1_plus.rb +58 -0
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +35 -0
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +9 -12
- data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +3 -2
- data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +7 -9
- data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +6 -9
- data/lib/praxis/extensions/pagination.rb +130 -0
- data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +42 -0
- data/lib/praxis/extensions/pagination/header_generator.rb +70 -0
- data/lib/praxis/extensions/pagination/ordering_params.rb +234 -0
- data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
- data/lib/praxis/extensions/pagination/pagination_params.rb +374 -0
- data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +45 -0
- data/lib/praxis/handlers/json.rb +2 -0
- data/lib/praxis/handlers/www_form.rb +5 -0
- data/lib/praxis/handlers/{xml.rb → xml-sample.rb} +6 -0
- data/lib/praxis/mapper/active_model_compat.rb +23 -5
- data/lib/praxis/mapper/resource.rb +16 -9
- data/lib/praxis/mapper/sequel_compat.rb +1 -0
- data/lib/praxis/media_type.rb +1 -56
- data/lib/praxis/plugins/mapper_plugin.rb +1 -1
- data/lib/praxis/plugins/pagination_plugin.rb +71 -0
- data/lib/praxis/resource_definition.rb +4 -12
- data/lib/praxis/route.rb +2 -4
- data/lib/praxis/routing_config.rb +4 -8
- data/lib/praxis/tasks/routes.rb +9 -14
- data/lib/praxis/validation_handler.rb +1 -2
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +2 -3
- data/spec/functional_spec.rb +9 -6
- data/spec/praxis/action_definition_spec.rb +4 -16
- data/spec/praxis/api_general_info_spec.rb +6 -6
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +304 -0
- data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +39 -0
- data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +34 -0
- data/spec/praxis/extensions/field_expansion_spec.rb +6 -24
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +15 -11
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +4 -3
- data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +130 -0
- data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_active_model.rb +45 -2
- data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_sequel.rb +0 -0
- data/spec/praxis/media_type_spec.rb +5 -129
- data/spec/praxis/request_spec.rb +3 -22
- data/spec/praxis/resource_definition_spec.rb +1 -1
- data/spec/praxis/response_definition_spec.rb +1 -5
- data/spec/praxis/route_spec.rb +2 -9
- data/spec/praxis/routing_config_spec.rb +4 -13
- data/spec/praxis/types/multipart_array_spec.rb +4 -21
- data/spec/spec_app/config/environment.rb +0 -2
- data/spec/spec_app/design/api.rb +1 -1
- data/spec/spec_app/design/media_types/instance.rb +0 -8
- data/spec/spec_app/design/media_types/volume.rb +0 -12
- data/spec/spec_app/design/resources/instances.rb +1 -2
- data/spec/spec_helper.rb +6 -0
- data/spec/support/spec_media_types.rb +0 -73
- metadata +35 -45
- data/spec/praxis/handlers/xml_spec.rb +0 -177
- 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
|