fetcheable_on_api 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # FetcheableOnApi configuration object.
4
+ # Configuration class for FetcheableOnApi gem settings.
5
5
  #
6
+ # This class holds global configuration options that affect the behavior
7
+ # of filtering, sorting, and pagination across all controllers that use
8
+ # the FetcheableOnApi module.
9
+ #
10
+ # Configuration is typically set in a Rails initializer file, but can
11
+ # also be modified at runtime if needed.
12
+ #
13
+ # @example Setting configuration in an initializer
14
+ # # config/initializers/fetcheable_on_api.rb
15
+ # FetcheableOnApi.configure do |config|
16
+ # config.pagination_default_size = 50
17
+ # end
18
+ #
19
+ # @example Runtime configuration changes
20
+ # FetcheableOnApi.configuration.pagination_default_size = 100
21
+ #
22
+ # @since 0.1.0
6
23
  class Configuration
7
- # @attribute [Integer] Default pagination size
24
+ # Default number of records per page when no page[size] parameter is provided.
25
+ # This value is used by the Pageable module when clients don't specify
26
+ # a page size in their requests.
27
+ #
28
+ # @return [Integer] The default pagination size
29
+ # @example
30
+ # # With default configuration (25):
31
+ # # GET /users?page[number]=2
32
+ # # Returns 25 records starting from record 26
33
+ #
34
+ # # With custom configuration (50):
35
+ # # GET /users?page[number]=2
36
+ # # Returns 50 records starting from record 51
8
37
  attr_accessor :pagination_default_size
9
38
 
39
+ # Initialize configuration with default values.
40
+ # Sets up sensible defaults that work well for most applications.
10
41
  def initialize
11
42
  @pagination_default_size = 25
12
43
  end
@@ -1,11 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # Filterable implements `filter` parameter support.
4
+ # Filterable implements support for JSONAPI-compliant filtering via `filter` query parameters.
5
+ #
6
+ # This module enables controllers to process filter parameters in the format:
7
+ # `filter[attribute]=value` or `filter[attribute]=value1,value2` for multiple values
8
+ #
9
+ # It supports:
10
+ # - 30+ Arel predicates (eq, ilike, between, in, gt, lt, matches, etc.)
11
+ # - Association filtering with custom class names
12
+ # - Custom lambda predicates for complex filtering logic
13
+ # - Multiple filter values with OR logic
14
+ # - Date/time filtering with custom formats
15
+ #
16
+ # @example Basic filtering setup
17
+ # class UsersController < ApplicationController
18
+ # filter_by :name, :email, :status
19
+ #
20
+ # def index
21
+ # users = apply_fetcheable(User.all)
22
+ # render json: users
23
+ # end
24
+ # end
25
+ #
26
+ # # GET /users?filter[name]=john&filter[status]=active
27
+ #
28
+ # @example Association filtering
29
+ # class PostsController < ApplicationController
30
+ # filter_by :title
31
+ # filter_by :author, class_name: User, as: 'name'
32
+ #
33
+ # def index
34
+ # posts = apply_fetcheable(Post.joins(:author))
35
+ # render json: posts
36
+ # end
37
+ # end
38
+ #
39
+ # # GET /posts?filter[author]=john&filter[title]=rails
40
+ #
41
+ # @example Custom predicates
42
+ # class ProductsController < ApplicationController
43
+ # filter_by :price, with: :gteq # Greater than or equal
44
+ # filter_by :created_at, with: :between, format: :datetime
45
+ #
46
+ # def index
47
+ # products = apply_fetcheable(Product.all)
48
+ # render json: products
49
+ # end
50
+ # end
51
+ #
52
+ # # GET /products?filter[price]=100&filter[created_at]=1609459200,1640995200
53
+ #
54
+ # @see https://jsonapi.org/format/#fetching-filtering JSONAPI Filtering Specification
5
55
  module Filterable
56
+ # Arel predicates that expect array values instead of single values.
57
+ # These predicates work with multiple values and are handled differently
58
+ # during parameter validation and processing.
6
59
  #
7
- # Predicates supported for filtering.
8
- #
60
+ # @example Usage with array predicates
61
+ # filter_by :tags, with: :in_all
62
+ # # Expects: filter[tags][]= or filter[tags]=value1,value2
63
+ # Arel predicates that expect array values instead of single values.
9
64
  PREDICATES_WITH_ARRAY = %i[
10
65
  does_not_match_all
11
66
  does_not_match_any
@@ -29,87 +84,148 @@ module FetcheableOnApi
29
84
  not_in_any
30
85
  ].freeze
31
86
 
87
+ # Hook called when Filterable is included in a class.
88
+ # Sets up the class to support filter configuration and provides
89
+ # the filter_by class method.
32
90
  #
33
- # Public class methods
34
- #
91
+ # @param base [Class] The class including this module
92
+ # @private
35
93
  def self.included(base)
36
94
  base.class_eval do
37
95
  extend ClassMethods
96
+ # Store filter configurations per class to avoid conflicts between controllers
38
97
  class_attribute :filters_configuration, instance_writer: false
39
98
  self.filters_configuration = {}
40
99
  end
41
100
  end
42
101
 
43
- # Class methods made available to your controllers.
102
+ # Class methods made available to controllers when Filterable is included.
44
103
  module ClassMethods
45
- # Define a filterable attribute.
104
+ # Define one or more filterable attributes for the controller.
105
+ #
106
+ # This method configures which model attributes can be filtered via query parameters
107
+ # and how those filters should be processed.
108
+ #
109
+ # @param attrs [Array<Symbol>] List of attribute names to make filterable
110
+ # @param options [Hash] Configuration options for the filters
111
+ # @option options [String, Symbol] :as Alias for the database column name
112
+ # @option options [Class] :class_name Model class for association filtering (defaults to collection class)
113
+ # @option options [Symbol, Proc] :with Arel predicate to use (:ilike, :eq, :between, etc.) or custom lambda
114
+ # @option options [Symbol] :format Value format (:string, :array, :datetime) for parameter processing
115
+ # @option options [Symbol] :association Association name when different from inferred name
116
+ #
117
+ # @example Basic attribute filtering
118
+ # filter_by :name, :email, :status
119
+ # # Allows: filter[name]=john&filter[email]=john@example.com&filter[status]=active
120
+ #
121
+ # @example Custom predicate
122
+ # filter_by :age, with: :gteq # Greater than or equal
123
+ # filter_by :created_at, with: :between, format: :datetime
124
+ # # Allows: filter[age]=18&filter[created_at]=1609459200,1640995200
125
+ #
126
+ # @example Association filtering
127
+ # filter_by :author, class_name: User, as: 'name'
128
+ # # Allows: filter[author]=john (filters by users.name)
46
129
  #
47
- # @see FetcheableOnApi::Filterable::PREDICATES_WITH_ARRAY
130
+ # @example Custom lambda predicate
131
+ # filter_by :full_name, with: -> (collection, value) {
132
+ # collection.arel_table[:first_name].matches("%#{value}%").or(
133
+ # collection.arel_table[:last_name].matches("%#{value}%")
134
+ # )
135
+ # }
48
136
  #
49
- # @param attrs [Array] options to define one or more filters.
50
- # @option attrs [String, nil] :as Alias the filtered attribute
51
- # @option attrs [String, nil] :class_name Override the class of the filter target
52
- # @option attrs [String, nil] :with Use a specific predicate
137
+ # @raise [ArgumentError] When invalid options are provided
138
+ # @see PREDICATES_WITH_ARRAY For list of array-based predicates
53
139
  def filter_by(*attrs)
54
140
  options = attrs.extract_options!
55
141
  options.symbolize_keys!
56
- options.assert_valid_keys(
57
- :as, :class_name, :with, :format, :association
58
- )
59
142
 
143
+ # Validate that only supported options are provided
144
+ options.assert_valid_keys(:as, :class_name, :with, :format, :association)
145
+
146
+ # Create a new configuration hash to avoid modifying parent class config
60
147
  self.filters_configuration = filters_configuration.dup
61
148
 
62
149
  attrs.each do |attr|
150
+ # Initialize default configuration for this attribute
63
151
  filters_configuration[attr] ||= {
64
- as: options[:as] || attr,
152
+ as: options[:as] || attr
65
153
  }
66
154
 
155
+ # Merge in the provided options
67
156
  filters_configuration[attr].merge!(options)
68
157
  end
69
158
  end
70
159
  end
71
160
 
72
- #
73
- # Public instance methods
74
- #
161
+ # Protected instance methods for filtering functionality
75
162
 
76
- #
77
- # Protected instance methods
78
- #
79
163
  protected
80
164
 
165
+ # Generate the list of valid parameter keys for Rails strong parameters.
166
+ # This method examines the filter configuration to determine which parameters
167
+ # should be permitted, taking into account predicates that expect arrays.
168
+ #
169
+ # @return [Array] Array of parameter keys, with Hash format for array predicates
170
+ # @example
171
+ # # For filter_by :name, :tags (where tags uses in_any predicate)
172
+ # # Returns: [:name, {tags: []}]
173
+ # @private
81
174
  def valid_keys
82
175
  keys = filters_configuration.keys
83
176
  keys.each_with_index do |key, index|
84
177
  predicate = filters_configuration[key.to_sym].fetch(:with, :ilike)
85
178
 
86
- if(%i[between not_between in in_all in_any].include?(predicate))
179
+ # Special handling for predicates that work with ranges or arrays
180
+ if %i[between not_between in in_all in_any].include?(predicate)
87
181
  format = filters_configuration[key.to_sym].fetch(:format) { nil }
88
- keys[index] = {key => []} if format == :array
182
+ # Use array format for explicit array formatting or for array predicates
183
+ if format == :array || PREDICATES_WITH_ARRAY.include?(predicate.to_sym)
184
+ keys[index] = { key => [] }
185
+ end
89
186
  next
90
187
  end
91
188
 
189
+ # Skip if it's a custom lambda predicate or doesn't expect arrays
92
190
  next if predicate.respond_to?(:call) ||
93
191
  PREDICATES_WITH_ARRAY.exclude?(predicate.to_sym)
94
192
 
95
- keys[index] = {key => []}
193
+ # Convert to array format for predicates that expect multiple values
194
+ keys[index] = { key => [] }
96
195
  end
97
196
 
98
197
  keys
99
198
  end
100
199
 
200
+ # Apply filtering to the collection based on filter query parameters.
201
+ # This is the main method that processes all configured filters and
202
+ # applies them to the ActiveRecord relation.
203
+ #
204
+ # @param collection [ActiveRecord::Relation] The collection to filter
205
+ # @return [ActiveRecord::Relation] The filtered collection
206
+ # @raise [FetcheableOnApi::ArgumentError] When filter parameters are invalid
207
+ #
208
+ # @example
209
+ # # With params: { filter: { name: 'john', status: 'active' } }
210
+ # filtered_users = apply_filters(User.all)
211
+ # # Generates: WHERE users.name ILIKE '%john%' AND users.status ILIKE '%active%'
101
212
  def apply_filters(collection)
213
+ # Return early if no filter parameters are provided
102
214
  return collection if params[:filter].blank?
103
215
 
216
+ # Validate that filter parameters are properly formatted
104
217
  foa_valid_parameters!(:filter)
105
218
 
219
+ # Extract and permit only configured filter parameters
106
220
  filter_params = params.require(:filter)
107
- .permit(valid_keys)
221
+ .permit(*valid_keys)
108
222
  .to_hash
109
223
 
224
+ # Process each filter parameter and build Arel predicates
110
225
  filtering = filter_params.map do |column, values|
111
226
  config = filters_configuration[column.to_sym]
112
227
 
228
+ # Extract configuration for this filter
113
229
  format = config.fetch(:format, :string)
114
230
  column_name = config.fetch(:as, column)
115
231
  klass = config.fetch(:class_name, collection.klass)
@@ -120,66 +236,126 @@ module FetcheableOnApi
120
236
 
121
237
  predicate = config.fetch(:with, :ilike)
122
238
 
239
+ # Join association table if filtering on a different model
123
240
  if collection_klass != klass
124
241
  collection = collection.joins(association_class_or_name)
125
242
  end
126
243
 
244
+ # Skip if values is nil or empty
245
+ next if values.nil? || values == ""
246
+
247
+ # Handle range-based predicates (between, not_between)
127
248
  if %i[between not_between].include?(predicate)
128
249
  if values.is_a?(String)
129
- predicates(predicate, collection, klass, column_name, values.split(","))
250
+ # Single range: "start,end"
251
+ predicates(predicate, collection, klass, column_name, values.split(','))
130
252
  else
253
+ # Multiple ranges: ["start1,end1", "start2,end2"] with OR logic
131
254
  values.map do |value|
132
- predicates(predicate, collection, klass, column_name, value.split(","))
255
+ predicates(predicate, collection, klass, column_name, value.split(','))
133
256
  end.inject(:or)
134
257
  end
135
258
  elsif values.is_a?(String)
136
- values.split(",").map do |value|
259
+ # Single value or comma-separated values with OR logic
260
+ values.split(',').map do |value|
137
261
  predicates(predicate, collection, klass, column_name, value)
138
262
  end.inject(:or)
139
263
  else
140
- values.map! { |el| el.split(",") }
264
+ # Array of values, each potentially comma-separated
265
+ values.map! { |el| el.split(',') }
141
266
  predicates(predicate, collection, klass, column_name, values)
142
267
  end
143
268
  end
144
269
 
270
+ # Combine all filter predicates with AND logic
145
271
  collection.where(filtering.flatten.compact.inject(:and))
146
272
  end
147
273
 
148
- # Apply arel predicate on collection
274
+ # Build an Arel predicate for the given parameters.
275
+ # This method translates filter predicates into Arel expressions that can
276
+ # be used in ActiveRecord where clauses.
277
+ #
278
+ # @param predicate [Symbol, Proc] The predicate type (:eq, :ilike, :between, etc.) or custom lambda
279
+ # @param collection [ActiveRecord::Relation] The collection being filtered (used for lambda predicates)
280
+ # @param klass [Class] The model class for the attribute being filtered
281
+ # @param column_name [String, Symbol] The database column name to filter on
282
+ # @param value [Object] The filter value(s) to compare against
283
+ # @return [Arel::Node] An Arel predicate node
284
+ # @raise [ArgumentError] When an unsupported predicate is used
285
+ #
286
+ # @example
287
+ # # predicates(:eq, collection, User, 'name', 'john')
288
+ # # Returns: users.name = 'john'
289
+ #
290
+ # # predicates(:between, collection, User, 'age', [18, 65])
291
+ # # Returns: users.age BETWEEN 18 AND 65
292
+ #
293
+ # @private
149
294
  def predicates(predicate, collection, klass, column_name, value)
150
295
  case predicate
296
+ # Range predicates - work with two values (start, end)
151
297
  when :between
152
298
  klass.arel_table[column_name].between(value.first..value.last)
153
- when :does_not_match
154
- klass.arel_table[column_name].does_not_match("%#{value}%")
155
- when :does_not_match_all
156
- klass.arel_table[column_name].does_not_match_all(value)
157
- when :does_not_match_any
158
- klass.arel_table[column_name].does_not_match_any(value)
299
+ when :not_between
300
+ klass.arel_table[column_name].not_between(value.first..value.last)
301
+
302
+ # Equality predicates - exact matching
159
303
  when :eq
160
304
  klass.arel_table[column_name].eq(value)
305
+ when :not_eq
306
+ klass.arel_table[column_name].not_eq(value)
307
+
308
+ # Comparison predicates - numeric/date comparisons
309
+ when :gt
310
+ klass.arel_table[column_name].gt(value)
311
+ when :gteq
312
+ klass.arel_table[column_name].gteq(value)
313
+ when :lt
314
+ klass.arel_table[column_name].lt(value)
315
+ when :lteq
316
+ klass.arel_table[column_name].lteq(value)
317
+
318
+ # Array inclusion predicates - check if value is in a set
319
+ when :in
320
+ if value.is_a?(Array)
321
+ klass.arel_table[column_name].in(value.flatten.compact.uniq)
322
+ else
323
+ klass.arel_table[column_name].in(value)
324
+ end
325
+ when :not_in
326
+ klass.arel_table[column_name].not_in(value)
327
+
328
+ # Pattern matching predicates - for text search
329
+ when :ilike
330
+ # Default predicate - case-insensitive partial matching
331
+ klass.arel_table[column_name].matches("%#{value}%")
332
+ when :matches
333
+ # Exact pattern matching (supports SQL wildcards)
334
+ klass.arel_table[column_name].matches(value)
335
+ when :does_not_match
336
+ klass.arel_table[column_name].does_not_match("%#{value}%")
337
+
338
+ # Array-based predicates (work with multiple values)
161
339
  when :eq_all
162
340
  klass.arel_table[column_name].eq_all(value)
163
341
  when :eq_any
164
342
  klass.arel_table[column_name].eq_any(value)
165
- when :gt
166
- klass.arel_table[column_name].gt(value)
167
343
  when :gt_all
168
344
  klass.arel_table[column_name].gt_all(value)
169
345
  when :gt_any
170
346
  klass.arel_table[column_name].gt_any(value)
171
- when :gteq
172
- klass.arel_table[column_name].gteq(value)
173
347
  when :gteq_all
174
348
  klass.arel_table[column_name].gteq_all(value)
175
349
  when :gteq_any
176
350
  klass.arel_table[column_name].gteq_any(value)
177
- when :in
178
- if value.is_a?(Array)
179
- klass.arel_table[column_name].in(value.flatten.compact.uniq)
180
- else
181
- klass.arel_table[column_name].in(value)
182
- end
351
+ when :lt_all
352
+ klass.arel_table[column_name].lt_all(value)
353
+ when :lt_any
354
+ klass.arel_table[column_name].lt_any(value)
355
+ when :lteq_all
356
+ klass.arel_table[column_name].lteq_all(value)
357
+ when :lteq_any
358
+ klass.arel_table[column_name].lteq_any(value)
183
359
  when :in_all
184
360
  if value.is_a?(Array)
185
361
  klass.arel_table[column_name].in_all(value.flatten.compact.uniq)
@@ -192,51 +368,40 @@ module FetcheableOnApi
192
368
  else
193
369
  klass.arel_table[column_name].in_any(value)
194
370
  end
195
- when :lt
196
- klass.arel_table[column_name].lt(value)
197
- when :lt_all
198
- klass.arel_table[column_name].lt_all(value)
199
- when :lt_any
200
- klass.arel_table[column_name].lt_any(value)
201
- when :lteq
202
- klass.arel_table[column_name].lteq(value)
203
- when :lteq_all
204
- klass.arel_table[column_name].lteq_all(value)
205
- when :lteq_any
206
- klass.arel_table[column_name].lteq_any(value)
207
- when :ilike
208
- klass.arel_table[column_name].matches("%#{value}%")
209
- when :matches
210
- klass.arel_table[column_name].matches(value)
211
- when :matches_all
212
- klass.arel_table[column_name].matches_all(value)
213
- when :matches_any
214
- klass.arel_table[column_name].matches_any(value)
215
- when :not_between
216
- klass.arel_table[column_name].not_between(value.first..value.last)
217
- when :not_eq
218
- klass.arel_table[column_name].not_eq(value)
219
371
  when :not_eq_all
220
372
  klass.arel_table[column_name].not_eq_all(value)
221
373
  when :not_eq_any
222
374
  klass.arel_table[column_name].not_eq_any(value)
223
- when :not_in
224
- klass.arel_table[column_name].not_in(value)
225
375
  when :not_in_all
226
376
  klass.arel_table[column_name].not_in_all(value)
227
377
  when :not_in_any
228
378
  klass.arel_table[column_name].not_in_any(value)
379
+ when :matches_all
380
+ klass.arel_table[column_name].matches_all(value)
381
+ when :matches_any
382
+ klass.arel_table[column_name].matches_any(value)
383
+ when :does_not_match_all
384
+ klass.arel_table[column_name].does_not_match_all(value)
385
+ when :does_not_match_any
386
+ klass.arel_table[column_name].does_not_match_any(value)
229
387
  else
388
+ # Handle custom lambda predicates
230
389
  unless predicate.respond_to?(:call)
231
390
  raise ArgumentError,
232
391
  "unsupported predicate `#{predicate}`"
233
392
  end
234
393
 
394
+ # Call the custom predicate with collection and value
235
395
  predicate.call(collection, value)
236
396
  end
237
397
  end
238
398
 
239
- # Types allowed by default for filter action.
399
+ # Override the default permitted types to allow Arrays for filter parameters.
400
+ # Filtering supports more flexible parameter types compared to sorting/pagination
401
+ # since filter values can be arrays of values for certain predicates.
402
+ #
403
+ # @return [Array<Class>] Array of permitted parameter types for filtering
404
+ # @private
240
405
  def foa_default_permitted_types
241
406
  [ActionController::Parameters, Hash, Array]
242
407
  end
@@ -1,68 +1,126 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # Pageable implements pagination support.
4
+ # Pageable implements support for JSONAPI-compliant pagination via `page` query parameters.
5
+ #
6
+ # This module enables controllers to process pagination parameters in the format:
7
+ # `page[number]=2&page[size]=25` following the JSONAPI specification for page-based pagination.
5
8
  #
6
9
  # It handles the controller parameters:
10
+ # - `page[number]` - The requested page number (default: 1)
11
+ # - `page[size]` - Number of records per page (default: from configuration)
12
+ #
13
+ # If no `page` parameter is present on the request, the full collection is returned.
14
+ #
15
+ # The following pagination information is automatically added to response headers:
16
+ # - `Pagination-Current-Page` - The page number that is returned
17
+ # - `Pagination-Per` - The number of records included in the page
18
+ # - `Pagination-Total-Pages` - The total number of pages available
19
+ # - `Pagination-Total-Count` - The total number of records available
7
20
  #
8
- # - <code>page[:number]</code> the requested page (default: 1).
9
- # - <code>page[:size]</code> number of records per page.
21
+ # @example Basic pagination setup
22
+ # class UsersController < ApplicationController
23
+ # def index
24
+ # users = apply_fetcheable(User.all)
25
+ # render json: users
26
+ # # Response headers will include pagination info
27
+ # end
28
+ # end
10
29
  #
11
- # If no <code>page</code> parameter is present on the request, the full collection is
12
- # returned.
30
+ # # GET /users?page[number]=2&page[size]=10
13
31
  #
14
- # The following pagination information is add to the response headers:
32
+ # @example With custom default page size
33
+ # # In config/initializers/fetcheable_on_api.rb
34
+ # FetcheableOnApi.configure do |config|
35
+ # config.pagination_default_size = 50
36
+ # end
15
37
  #
16
- # - <code>Pagination-Current-Page</code> the page that is returned.
17
- # - <code>Pagination-Per</code> the number of records included in the page.
18
- # - <code>Pagination-Total-Pages</code> the total number of pages available.
19
- # - <code>Pagination-Total-Count</code> the total number of records available.
38
+ # @example Response headers
39
+ # # Pagination-Current-Page: 2
40
+ # # Pagination-Per: 10
41
+ # # Pagination-Total-Pages: 15
42
+ # # Pagination-Total-Count: 150
20
43
  #
44
+ # @see https://jsonapi.org/format/#fetching-pagination JSONAPI Pagination Specification
21
45
  module Pageable
22
- #
23
- # Supports
24
- #
46
+ # Protected instance methods for pagination functionality
25
47
 
26
- #
27
- # Public class methods
28
- #
29
-
30
- #
31
- # Public instance methods
32
- #
48
+ protected
33
49
 
50
+ # Apply pagination to the collection based on page query parameters.
51
+ # This is the main method that processes page parameters and applies
52
+ # limit/offset to the ActiveRecord relation while setting response headers.
34
53
  #
35
- # Protected instance methods
54
+ # @param collection [ActiveRecord::Relation] The collection to paginate
55
+ # @return [ActiveRecord::Relation] The paginated collection with limit and offset applied
56
+ # @raise [FetcheableOnApi::ArgumentError] When page parameters are invalid
36
57
  #
37
- protected
38
-
58
+ # @example
59
+ # # With params: { page: { number: 2, size: 10 } }
60
+ # paginated_users = apply_pagination(User.all)
61
+ # # Generates: LIMIT 10 OFFSET 10
62
+ # # Sets headers: Pagination-Current-Page: 2, Pagination-Per: 10, etc.
39
63
  def apply_pagination(collection)
64
+ # Return early if no pagination parameters are provided
40
65
  return collection if params[:page].blank?
41
66
 
67
+ # Validate that page parameters are properly formatted
42
68
  foa_valid_parameters!(:page)
43
69
 
70
+ # Extract pagination values and count total records
44
71
  limit, offset, count, page = extract_pagination_informations(collection)
72
+
73
+ # Set pagination headers for the response
45
74
  define_header_pagination(limit, count, page)
46
75
 
76
+ # Apply limit and offset to the collection
47
77
  collection.limit(limit).offset(offset)
48
78
  end
49
79
 
50
80
  private
51
81
 
82
+ # Set pagination information in the response headers.
83
+ # These headers provide clients with information about the current page
84
+ # and total number of records/pages available.
85
+ #
86
+ # @param limit [Integer] Number of records per page
87
+ # @param count [Integer] Total number of records
88
+ # @param page [Integer] Current page number
89
+ # @private
52
90
  def define_header_pagination(limit, count, page)
53
- response.headers["Pagination-Current-Page"] = page
54
- response.headers["Pagination-Per"] = limit
55
- response.headers["Pagination-Total-Pages"] = (count.to_f / limit.to_f).ceil
56
- response.headers["Pagination-Total-Count"] = count
91
+ response.headers['Pagination-Current-Page'] = page
92
+ response.headers['Pagination-Per'] = limit
93
+ response.headers['Pagination-Total-Pages'] = limit > 0 ? (count.to_f / limit.to_f).ceil : 0
94
+ response.headers['Pagination-Total-Count'] = count
57
95
  end
58
96
 
97
+ # Extract and calculate pagination information from parameters and collection.
98
+ # This method processes the page parameters and calculates the appropriate
99
+ # limit, offset, total count, and current page number.
100
+ #
101
+ # @param collection [ActiveRecord::Relation] The collection to paginate
102
+ # @return [Array<Integer>] Array containing [limit, offset, count, page]
103
+ #
104
+ # @example
105
+ # # With params: { page: { number: 3, size: 20 } }
106
+ # limit, offset, count, page = extract_pagination_informations(User.all)
107
+ # # => [20, 40, 150, 3] (20 per page, skip 40 records, 150 total, page 3)
108
+ #
109
+ # @private
59
110
  def extract_pagination_informations(collection)
111
+ # Get page size from parameters or use configured default
60
112
  limit = params[:page].fetch(
61
113
  :size, FetcheableOnApi.configuration.pagination_default_size
62
114
  ).to_i
63
115
 
116
+ # Get page number from parameters or default to 1
64
117
  page = params[:page].fetch(:number, 1).to_i
118
+
119
+ # Calculate offset based on page number and size
65
120
  offset = (page - 1) * limit
121
+
122
+ # Count total records excluding any existing pagination/ordering
123
+ # This ensures we get the total count before any pagination is applied
66
124
  count = collection.except(:offset, :limit, :order).count
67
125
 
68
126
  [limit, offset, count, page]