praxis 2.0.pre.3 → 2.0.pre.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (99) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +0 -1
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +26 -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 +7 -4
  11. data/lib/praxis/action_definition.rb +9 -16
  12. data/lib/praxis/api_general_info.rb +21 -0
  13. data/lib/praxis/application.rb +1 -2
  14. data/lib/praxis/bootloader_stages/routing.rb +2 -4
  15. data/lib/praxis/docs/generator.rb +11 -6
  16. data/lib/praxis/docs/open_api_generator.rb +255 -0
  17. data/lib/praxis/docs/openapi/info_object.rb +31 -0
  18. data/lib/praxis/docs/openapi/media_type_object.rb +59 -0
  19. data/lib/praxis/docs/openapi/operation_object.rb +40 -0
  20. data/lib/praxis/docs/openapi/parameter_object.rb +69 -0
  21. data/lib/praxis/docs/openapi/paths_object.rb +55 -0
  22. data/lib/praxis/docs/openapi/request_body_object.rb +51 -0
  23. data/lib/praxis/docs/openapi/response_object.rb +63 -0
  24. data/lib/praxis/docs/openapi/responses_object.rb +44 -0
  25. data/lib/praxis/docs/openapi/schema_object.rb +87 -0
  26. data/lib/praxis/docs/openapi/server_object.rb +24 -0
  27. data/lib/praxis/docs/openapi/tag_object.rb +21 -0
  28. data/lib/praxis/extensions/attribute_filtering.rb +2 -0
  29. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +148 -157
  30. data/lib/praxis/extensions/attribute_filtering/active_record_patches.rb +15 -0
  31. data/lib/praxis/extensions/attribute_filtering/active_record_patches/5x.rb +90 -0
  32. data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_0.rb +68 -0
  33. data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_1_plus.rb +58 -0
  34. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +35 -0
  35. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +13 -12
  36. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +3 -2
  37. data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +7 -9
  38. data/lib/praxis/extensions/field_selection/field_selector.rb +4 -0
  39. data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +6 -9
  40. data/lib/praxis/extensions/pagination.rb +130 -0
  41. data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +42 -0
  42. data/lib/praxis/extensions/pagination/header_generator.rb +70 -0
  43. data/lib/praxis/extensions/pagination/ordering_params.rb +238 -0
  44. data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
  45. data/lib/praxis/extensions/pagination/pagination_params.rb +378 -0
  46. data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +45 -0
  47. data/lib/praxis/handlers/json.rb +2 -0
  48. data/lib/praxis/handlers/www_form.rb +5 -0
  49. data/lib/praxis/handlers/{xml.rb → xml-sample.rb} +6 -0
  50. data/lib/praxis/links.rb +4 -0
  51. data/lib/praxis/mapper/active_model_compat.rb +23 -5
  52. data/lib/praxis/mapper/resource.rb +16 -9
  53. data/lib/praxis/mapper/selector_generator.rb +11 -10
  54. data/lib/praxis/mapper/sequel_compat.rb +1 -0
  55. data/lib/praxis/media_type.rb +1 -56
  56. data/lib/praxis/multipart/part.rb +5 -2
  57. data/lib/praxis/plugins/mapper_plugin.rb +1 -1
  58. data/lib/praxis/plugins/pagination_plugin.rb +71 -0
  59. data/lib/praxis/resource_definition.rb +4 -12
  60. data/lib/praxis/response_definition.rb +1 -1
  61. data/lib/praxis/route.rb +2 -4
  62. data/lib/praxis/routing_config.rb +4 -8
  63. data/lib/praxis/tasks/api_docs.rb +23 -0
  64. data/lib/praxis/tasks/routes.rb +10 -15
  65. data/lib/praxis/types/media_type_common.rb +10 -0
  66. data/lib/praxis/types/multipart_array.rb +62 -0
  67. data/lib/praxis/validation_handler.rb +1 -2
  68. data/lib/praxis/version.rb +1 -1
  69. data/praxis.gemspec +4 -5
  70. data/spec/functional_spec.rb +9 -6
  71. data/spec/praxis/action_definition_spec.rb +4 -16
  72. data/spec/praxis/api_general_info_spec.rb +6 -6
  73. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +304 -0
  74. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +39 -0
  75. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +34 -0
  76. data/spec/praxis/extensions/field_expansion_spec.rb +6 -24
  77. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +15 -11
  78. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +4 -3
  79. data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +130 -0
  80. data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_active_model.rb +45 -2
  81. data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_sequel.rb +0 -0
  82. data/spec/praxis/mapper/selector_generator_spec.rb +32 -0
  83. data/spec/praxis/media_type_spec.rb +5 -129
  84. data/spec/praxis/request_spec.rb +3 -22
  85. data/spec/praxis/resource_definition_spec.rb +1 -1
  86. data/spec/praxis/response_definition_spec.rb +8 -9
  87. data/spec/praxis/route_spec.rb +2 -9
  88. data/spec/praxis/routing_config_spec.rb +4 -13
  89. data/spec/praxis/types/multipart_array_spec.rb +4 -21
  90. data/spec/spec_app/config/environment.rb +0 -2
  91. data/spec/spec_app/design/api.rb +7 -1
  92. data/spec/spec_app/design/media_types/instance.rb +0 -8
  93. data/spec/spec_app/design/media_types/volume.rb +0 -12
  94. data/spec/spec_app/design/resources/instances.rb +1 -2
  95. data/spec/spec_helper.rb +6 -0
  96. data/spec/support/spec_media_types.rb +0 -73
  97. metadata +51 -49
  98. data/spec/praxis/handlers/xml_spec.rb +0 -177
  99. data/spec/praxis/links_spec.rb +0 -68
@@ -0,0 +1,68 @@
1
+ # FOR AR < 6.1
2
+ module ActiveRecord
3
+ PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
4
+ class Relation
5
+ def construct_join_dependency(associations, join_type) # :nodoc:
6
+ # Praxis: inject references into the join dependency
7
+ ActiveRecord::Associations::JoinDependency.new(
8
+ klass, table, associations, join_type, references: references_values
9
+ )
10
+ end
11
+ end
12
+
13
+ module Associations
14
+ class JoinDependency
15
+ attr_accessor :references
16
+
17
+ private
18
+ def initialize(base, table, associations, join_type, references: nil)
19
+ tree = self.class.make_tree associations
20
+ @references = references # Save the references values into the instance (to use during build)
21
+ built = build(tree, base)
22
+
23
+ @join_root = JoinBase.new(base, table, built)
24
+ @join_type = join_type
25
+ end
26
+
27
+ # Praxis: table aliases for is shared for 5x and 6.0
28
+ def table_aliases_for(parent, node)
29
+ node.reflection.chain.map do |reflection|
30
+ is_root_reflection = reflection == node.reflection
31
+ table = alias_tracker.aliased_table_for(
32
+ reflection.table_name,
33
+ table_alias_for(reflection, parent, !is_root_reflection),
34
+ reflection.klass.type_caster
35
+ )
36
+ # through tables do not need a special alias_path alias (as they shouldn't really referenced by the client)
37
+ if is_root_reflection && node.alias_path
38
+ table = table.left if table.is_a?(Arel::Nodes::TableAlias) #un-alias it if necessary
39
+ table = table.alias(node.alias_path.join('/'))
40
+ end
41
+ table
42
+ end
43
+ end
44
+
45
+ # Praxis: build for is shared for 5x and 6.0
46
+ def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
47
+ associations.map do |name, right|
48
+ reflection = find_reflection base_klass, name
49
+ reflection.check_validity!
50
+ reflection.check_eager_loadable!
51
+
52
+ if reflection.polymorphic?
53
+ raise EagerLoadPolymorphicError.new(reflection)
54
+ end
55
+ # Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
56
+ child_path = (path && !path.empty?) ? path + [name] : nil
57
+ association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
58
+ association.alias_path = child_path if references.include?(child_path.join('/'))
59
+ association
60
+ end
61
+ end
62
+
63
+ end
64
+ class ActiveRecord::Associations::JoinDependency::JoinAssociation
65
+ attr_accessor :alias_path
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,58 @@
1
+ # FOR AR >= 6.1
2
+ module ActiveRecord
3
+ PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
4
+ module Associations
5
+ class JoinDependency
6
+
7
+ private
8
+ def make_constraints(parent, child, join_type)
9
+ foreign_table = parent.table
10
+ foreign_klass = parent.base_klass
11
+ child.join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) do |reflection|
12
+ table, terminated = @joined_tables[reflection]
13
+ root = reflection == child.reflection
14
+
15
+ if table && (!root || !terminated)
16
+ @joined_tables[reflection] = [table, root] if root
17
+ next table, true
18
+ end
19
+
20
+ table_name = @references[reflection.name.to_sym]
21
+ # Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
22
+ table_name = @references[child&.alias_path.join('/').to_sym] unless table_name
23
+
24
+ table = alias_tracker.aliased_table_for(reflection.klass.arel_table, table_name) do
25
+ name = reflection.alias_candidate(parent.table_name)
26
+ root ? name : "#{name}_join"
27
+ end
28
+
29
+ @joined_tables[reflection] ||= [table, root] if join_type == Arel::Nodes::OuterJoin
30
+ table
31
+ end.concat child.children.flat_map { |c| make_constraints(child, c, join_type) }
32
+ end
33
+
34
+ # Praxis: build for is shared for 5x and 6.0
35
+ def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
36
+ associations.map do |name, right|
37
+ reflection = find_reflection base_klass, name
38
+ reflection.check_validity!
39
+ reflection.check_eager_loadable!
40
+
41
+ if reflection.polymorphic?
42
+ raise EagerLoadPolymorphicError.new(reflection)
43
+ end
44
+ # Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
45
+ child_path = (path && !path.empty?) ? path + [name] : nil
46
+ association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
47
+ #association.alias_path = child_path if references.include?(child_path.join('/'))
48
+ association.alias_path = child_path # ??? should be the line above no?
49
+ association
50
+ end
51
+ end
52
+ end
53
+
54
+ class ActiveRecord::Associations::JoinDependency::JoinAssociation
55
+ attr_accessor :alias_path
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,35 @@
1
+ module Praxis
2
+ module Extensions
3
+ module AttributeFiltering
4
+ class FilterTreeNode
5
+ attr_reader :path, :conditions, :children
6
+ # # parsed_filters is an Array of {name: X, op: , value: } ... exactly the format of the FilteringParams.load method
7
+ def initialize(parsed_filters, path: [])
8
+ @path = path # Array that marks the tree 'path' to this node (with respect to the absolute root)
9
+ @conditions = [] # Conditions to apply directly to this node
10
+ @children = {} # Hash with a new NodeTree object value, keyed by name
11
+ children_data = {} # Hash with keys as names of the first level component of the children nodes (and values as array of matching filters)
12
+ parsed_filters.map do |hash|
13
+ *components = hash[:name].to_s.split('.')
14
+ if components.empty?
15
+ return
16
+ elsif components.size == 1
17
+ @conditions << hash.slice(:name, :op, :value)
18
+ else
19
+ children_data[components.first] ||= []
20
+ children_data[components.first] << hash
21
+ end
22
+ end
23
+ # An array of FilterTreeNodes corresponding to each children
24
+ @children = children_data.each_with_object({}) do |(name, arr), hash|
25
+ sub_filters = arr.map do |item|
26
+ _parent, *rest = item[:name].to_s.split('.')
27
+ item.merge(name: rest.join('.'))
28
+ end
29
+ hash[name] = self.class.new(sub_filters, path: path + [name] )
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -26,7 +26,6 @@ module Praxis
26
26
  include Attributor::Type
27
27
  include Attributor::Dumpable
28
28
 
29
- # This DSL allows to define which attributes are allowed in the filters, and with which operators
30
29
  class DSLCompiler < Attributor::DSLCompiler
31
30
  # "account.id": { operators: ["=", "!="] },
32
31
  # name: { operators: ["=", "!="], fuzzy_match: true },
@@ -38,8 +37,8 @@ module Praxis
38
37
  end
39
38
 
40
39
  VALUE_REGEX = /[^,&]*/
41
- AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
42
- FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|=|<|>)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
40
+ AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>','!','!!']).freeze
41
+ FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|!!|=|<|>|!)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
43
42
 
44
43
  # Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
45
44
  # definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
@@ -62,6 +61,10 @@ module Praxis
62
61
  end
63
62
  end
64
63
 
64
+ def json_schema_type
65
+ :string
66
+ end
67
+
65
68
  def add_filter(name, operators:, fuzzy:)
66
69
  components = name.to_s.split('.').map(&:to_sym)
67
70
  attribute, enclosing_type = find_filter_attribute(components, media_type)
@@ -176,7 +179,7 @@ module Praxis
176
179
  else
177
180
  value
178
181
  end
179
- arr.push(name: attr_name, specs: { op: match[:operator], value: coerced } )
182
+ arr.push(name: attr_name, op: match[:operator], value: coerced )
180
183
  end
181
184
  new(parsed)
182
185
  end
@@ -204,28 +207,27 @@ module Praxis
204
207
  def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
205
208
  parsed_array.each_with_object([]) do |item, errors|
206
209
  attr_name = item[:name]
207
- specs = item[:specs]
208
210
  attr_filters = allowed_filters[attr_name]
209
211
  unless attr_filters
210
212
  errors << "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
211
213
  next
212
214
  end
213
215
  allowed_operators = attr_filters[:operators]
214
- unless allowed_operators.include?(specs[:op])
215
- errors << "Operator #{specs[:op]} not allowed for filter #{attr_name}"
216
+ unless allowed_operators.include?(item[:op])
217
+ errors << "Operator #{item[:op]} not allowed for filter #{attr_name}"
216
218
  end
217
219
  value_type = attr_filters[:value_type]
218
- value = specs[:value]
220
+ value = item[:value]
219
221
  if value_type && !value_type.valid_type?(value)
220
222
  # Allow a collection of values of the right type for multimatch (if operators are = or !=)
221
- if ['=','!='].include?(specs[:op])
223
+ if ['=','!='].include?(item[:op])
222
224
  coll_type = Attributor::Collection.of(value_type)
223
225
  if !coll_type.valid_type?(value)
224
226
  errors << "Invalid type in filter/s value for #{attr_name} " +\
225
227
  "(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
226
228
  end
227
229
  else
228
- errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{specs[:op]}' is not a #{value_type.name.split('::').last})"
230
+ errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{item[:op]}' is not a #{value_type.name.split('::').last})"
229
231
  end
230
232
  end
231
233
 
@@ -243,8 +245,7 @@ module Praxis
243
245
  def dump
244
246
  parsed_array.each_with_object([]) do |item, arr|
245
247
  field = item[:name]
246
- spec = item[:specs]
247
- arr << "#{field}#{spec[:op]}#{spec[:value]}"
248
+ arr << "#{field}#{item[:op]}#{item[:value]}"
248
249
  end.join('&')
249
250
  end
250
251
 
@@ -34,7 +34,8 @@ module Praxis
34
34
 
35
35
  # By default we'll simply use the incoming op and value, and will map
36
36
  # the attribute based on what's on the `attr_to_column` hash
37
- def build_clause(filters)
37
+ def generate(filters)
38
+ raise "Not refactored yet!"
38
39
  seen_associations = Set.new
39
40
  filters.each do |(attr, spec)|
40
41
  column_name = attr_to_column[attr]
@@ -68,7 +69,7 @@ module Praxis
68
69
  self.class.attr_to_column
69
70
  end
70
71
 
71
- # Private to try to funnel all column names through `build_clause` that restricts
72
+ # Private to try to funnel all column names through `generate` that restricts
72
73
  # the attribute names better (to allow more difficult SQL injections )
73
74
  private def add_clause(attr:, op:, value:)
74
75
  # TODO: partial matching
@@ -5,19 +5,20 @@ module Praxis
5
5
  class ActiveRecordQuerySelector
6
6
  attr_reader :selector, :query
7
7
  # Gets a dataset, a selector...and should return a dataset with the selector definition applied.
8
- def initialize(query:, selectors:)
8
+ def initialize(query:, selectors:, debug: false)
9
9
  @selector = selectors
10
10
  @query = query
11
+ @logger = debug ? Logger.new(STDOUT) : nil
11
12
  end
12
13
 
13
- def generate(debug: false)
14
+ def generate
14
15
  # TODO: unfortunately, I think we can only control the select clauses for the top model
15
16
  # (as I'm not sure ActiveRecord supports expressing it in the join...)
16
17
  @query = add_select(query: query, selector_node: selector)
17
18
  eager_hash = _eager(selector)
18
19
 
19
20
  @query = @query.includes(eager_hash)
20
- explain_query(query, eager_hash) if debug
21
+ explain_query(query, eager_hash) if @logger
21
22
 
22
23
  @query
23
24
  end
@@ -37,13 +38,10 @@ module Praxis
37
38
  end
38
39
 
39
40
  def explain_query(query, eager_hash)
40
- prev = ActiveRecord::Base.logger
41
- ActiveRecord::Base.logger = Logger.new(STDOUT)
42
- ActiveRecord::Base.logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
43
- ActiveRecord::Base.logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
41
+ @logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
42
+ @logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
44
43
  query.explain
45
- ActiveRecord::Base.logger.debug("Query plan end")
46
- ActiveRecord::Base.logger = prev
44
+ @logger.debug("Query plan end")
47
45
  end
48
46
  end
49
47
  end
@@ -7,6 +7,10 @@ module Praxis
7
7
  include Attributor::Type
8
8
  include Attributor::Dumpable
9
9
 
10
+ def self.json_schema_type
11
+ :string
12
+ end
13
+
10
14
  def self.native_type
11
15
  self
12
16
  end
@@ -8,19 +8,20 @@ module Praxis
8
8
  class SequelQuerySelector
9
9
  attr_reader :selector, :query
10
10
  # Gets a dataset, a selector...and should return a dataset with the selector definition applied.
11
- def initialize(query:, selectors:)
11
+ def initialize(query:, selectors:, debug: false)
12
12
  @selector = selectors
13
13
  @query = query
14
+ @logger = debug ? Logger.new(STDOUT) : nil
14
15
  end
15
16
 
16
- def generate(debug: false)
17
+ def generate
17
18
  @query = add_select(query: query, selector_node: @selector)
18
19
 
19
20
  @query = @selector.tracks.inject(@query) do |ds, (track_name, track_node)|
20
21
  ds.eager(track_name => _eager(track_node) )
21
22
  end
22
23
 
23
- explain_query(query) if debug
24
+ explain_query(query) if @logger
24
25
  @query
25
26
  end
26
27
 
@@ -47,13 +48,9 @@ module Praxis
47
48
  end
48
49
 
49
50
  def explain_query(ds)
50
- prev_loggers = Sequel::Model.db.loggers
51
- stdout_logger = Logger.new($stdout)
52
- Sequel::Model.db.loggers = [stdout_logger]
53
- stdout_logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
51
+ @logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
54
52
  ds.all
55
- stdout_logger.debug("Query plan end")
56
- Sequel::Model.db.loggers = prev_loggers
53
+ @logger.debug("Query plan end")
57
54
  end
58
55
  end
59
56
  end
@@ -0,0 +1,130 @@
1
+ begin
2
+ require 'link_header'
3
+ rescue LoadError
4
+ warn "Praxis::Pagination requires the 'link_header' gem, which can not be found. " \
5
+ "Please make sure it's in your Gemfile or installed in your system."
6
+ end
7
+ require 'praxis/extensions/pagination/pagination_params'
8
+ require 'praxis/extensions/pagination/ordering_params'
9
+ require 'praxis/extensions/pagination/pagination_handler'
10
+ require 'praxis/extensions/pagination/header_generator'
11
+
12
+ module Praxis
13
+ module Extensions
14
+ module Pagination
15
+ extend ActiveSupport::Concern
16
+ # This PaginatedController concern should be added to controllers that have actions that define the
17
+ # pagination and order parameters so that calling `paginate( query: <base_query>, table: <main_table_name> )`
18
+ # would handle all the required logic for paginating, ordering and generating the Link and TotalCount headers.
19
+ # This assumes that the query object are chainable and based on ActiveRecord at the moment (although that logic)
20
+ # can be easily applied to other chainable query proxies.
21
+ #
22
+ # Here's a simple example on how to use it for a fake Items controller
23
+ # class Items < V1::Controllers::BaseController
24
+ # include Praxis::Controller
25
+ # include Praxis::Extensions::Rendering
26
+ # implements V1::Endpoints::Items
27
+ #
28
+ # include Praxis::Extensions::Pagination
29
+ #
30
+ # def index(filters: nil, pagination: nil, order: nil, **_args)
31
+ # items = current_user.items.all
32
+ # items = handle_pagination( query: items)
33
+ #
34
+ # display(items)
35
+ # end
36
+ # end
37
+ #
38
+ # This code will properly add the right clauses to the final query based on the pagination strategy and ordering
39
+ # and it will also generate the Link header with the appropriate relationships depending on the paging strategy.
40
+ # When total_count is requested in the pagination a header with TotalCount will also be included.
41
+
42
+ PaginationStruct = Struct.new(:paginator, :order, :total_count)
43
+
44
+ included do
45
+ after :action do |controller, _callee|
46
+ if controller.response.status < 300
47
+ # If this action has the pagination parameter defined,
48
+ # calculate and set the pagination headers (Link header and possibly Total-Count)
49
+ if controller._pagination.paginator
50
+ headers = controller.build_pagination_headers(
51
+ pagination: controller._pagination,
52
+ current_url: controller.request.path,
53
+ current_query_params: controller.request.query
54
+ )
55
+ controller.response.headers.merge! headers
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Will set the typed paginator and order object into a controller ivar
62
+ # This is lazily evaluated and memoized, so there's no need to only calculate things for actions that paginate/sort
63
+ def _pagination
64
+ return @_pagination if @_pagination
65
+
66
+ pagination = {}
67
+ attrs = request.action&.params&.type&.attributes
68
+ pagination[:paginator] = request.params.pagination if attrs&.key? :pagination
69
+ pagination[:order] = request.params.order if attrs&.key? :order
70
+
71
+ @_pagination = PaginationStruct.new(pagination[:paginator], pagination[:order])
72
+ end
73
+
74
+ # Main entrypoint: Handles all pagination pieces
75
+ # takes:
76
+ # * the query to build from and the table
77
+ # * the request (for link header generation)
78
+ # * requires the _pagination variable to be there (set by this module) to return the pagination struct
79
+ def handle_pagination(query:, type: :active_record)
80
+ handler_klass = \
81
+ case type
82
+ when :active_record
83
+ ActiveRecordPaginationHandler
84
+ when :sequel
85
+ SequelPaginationHandler
86
+ else
87
+ raise "Attempting to use pagination but Active Record or Sequel gems found"
88
+ end
89
+
90
+ # Gather and save the count if required
91
+ if _pagination.paginator&.total_count
92
+ _pagination.total_count = handler_klass.count(query.dup)
93
+ end
94
+
95
+ query = handler_klass.order(query, _pagination.order)
96
+ # Maybe this is a class instance instead of a class method?...(of the appropriate AR/Sequel type)...
97
+ # self.class.paginate(query, table, _pagination)
98
+ handler_klass.paginate(query, _pagination)
99
+ end
100
+
101
+ def build_pagination_headers(pagination:, current_url:, current_query_params:)
102
+ links = if pagination.paginator.by
103
+ # We're assuming that the last element has a "symbol/string" field with the same name of the "by" pagination.
104
+ last_element = response.body.last
105
+ if last_element
106
+ last_value = last_element[pagination.paginator.by.to_sym] || last_element[pagination.paginator.by]
107
+ end
108
+ HeaderGenerator.build_cursor_headers(
109
+ paginator: pagination.paginator,
110
+ last_value: last_value,
111
+ total_count: pagination.total_count
112
+ )
113
+ else
114
+ HeaderGenerator.build_paging_headers(
115
+ paginator: pagination.paginator,
116
+ total_count: pagination.total_count
117
+ )
118
+ end
119
+
120
+ HeaderGenerator.generate_headers(
121
+ links: links,
122
+ current_url: current_url,
123
+ current_query_params: current_query_params,
124
+ total_count: pagination.total_count
125
+ )
126
+ end
127
+
128
+ end
129
+ end
130
+ end