fetcheable_on_api 0.4.1 → 0.6.1
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 +4 -4
- data/ASSOCIATION_SORTING_SOLUTION.md +119 -0
- data/CLAUDE.md +97 -0
- data/Gemfile.lock +54 -39
- data/README.md +311 -1
- data/lib/fetcheable_on_api/configuration.rb +33 -2
- data/lib/fetcheable_on_api/filterable.rb +274 -75
- data/lib/fetcheable_on_api/pageable.rb +85 -27
- data/lib/fetcheable_on_api/sortable.rb +192 -31
- data/lib/fetcheable_on_api/version.rb +5 -1
- data/lib/fetcheable_on_api.rb +160 -42
- data/lib/generators/fetcheable_on_api/install_generator.rb +15 -1
- data/lib/generators/templates/fetcheable_on_api_initializer.rb +14 -0
- metadata +9 -7
@@ -1,68 +1,126 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FetcheableOnApi
|
4
|
-
# Pageable implements pagination
|
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
|
-
#
|
9
|
-
#
|
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
|
-
#
|
12
|
-
# returned.
|
30
|
+
# # GET /users?page[number]=2&page[size]=10
|
13
31
|
#
|
14
|
-
#
|
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
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
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
|
-
#
|
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
|
-
|
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[
|
54
|
-
response.headers[
|
55
|
-
response.headers[
|
56
|
-
response.headers[
|
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]
|
@@ -1,113 +1,274 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FetcheableOnApi
|
4
|
-
# Sortable implements `
|
4
|
+
# Sortable implements support for JSONAPI-compliant sorting via `sort` query parameters.
|
5
|
+
#
|
6
|
+
# This module enables controllers to process sort parameters in the format:
|
7
|
+
# `sort=field1,-field2,+field3` where:
|
8
|
+
# - No prefix or `+` prefix means ascending order
|
9
|
+
# - `-` prefix means descending order
|
10
|
+
# - Multiple fields are comma-separated and applied in order
|
11
|
+
#
|
12
|
+
# It supports:
|
13
|
+
# - Single and multiple field sorting
|
14
|
+
# - Ascending and descending sort directions
|
15
|
+
# - Association sorting with custom class names
|
16
|
+
# - Case-insensitive sorting with the `lower` option
|
17
|
+
# - Field aliasing for different database column names
|
18
|
+
#
|
19
|
+
# @example Basic sorting setup
|
20
|
+
# class UsersController < ApplicationController
|
21
|
+
# sort_by :name, :email, :created_at
|
22
|
+
#
|
23
|
+
# def index
|
24
|
+
# users = apply_fetcheable(User.all)
|
25
|
+
# render json: users
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# # GET /users?sort=name,-created_at (name asc, created_at desc)
|
30
|
+
#
|
31
|
+
# @example Association sorting
|
32
|
+
# class PostsController < ApplicationController
|
33
|
+
# sort_by :title, :created_at
|
34
|
+
# sort_by :author, class_name: User, as: 'name'
|
35
|
+
#
|
36
|
+
# def index
|
37
|
+
# posts = apply_fetcheable(Post.joins(:author))
|
38
|
+
# render json: posts
|
39
|
+
# end
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
# # GET /posts?sort=author,-created_at (by author name asc, then created_at desc)
|
43
|
+
#
|
44
|
+
# @example Case-insensitive sorting
|
45
|
+
# class UsersController < ApplicationController
|
46
|
+
# sort_by :name, lower: true # Sort by lowercase name
|
47
|
+
# sort_by :email, :created_at
|
48
|
+
#
|
49
|
+
# def index
|
50
|
+
# users = apply_fetcheable(User.all)
|
51
|
+
# render json: users
|
52
|
+
# end
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# # GET /users?sort=name (sorts by LOWER(users.name))
|
56
|
+
#
|
57
|
+
# @see https://jsonapi.org/format/#fetching-sorting JSONAPI Sorting Specification
|
5
58
|
module Sortable
|
59
|
+
# Maps sort direction prefixes to Arel sort methods.
|
60
|
+
# Used to parse the sort parameter and determine ascending vs descending order.
|
6
61
|
#
|
7
|
-
#
|
8
|
-
#
|
62
|
+
# @example
|
63
|
+
# # "+name" or "name" -> :asc (ascending)
|
64
|
+
# # "-name" -> :desc (descending)
|
9
65
|
SORT_ORDER = {
|
10
|
-
|
11
|
-
|
66
|
+
'+' => :asc, # Explicit ascending (same as no prefix)
|
67
|
+
'-' => :desc, # Explicit descending
|
12
68
|
}.freeze
|
13
69
|
|
70
|
+
# Hook called when Sortable is included in a class.
|
71
|
+
# Sets up the class to support sort configuration and provides
|
72
|
+
# the sort_by class method.
|
14
73
|
#
|
15
|
-
#
|
16
|
-
#
|
74
|
+
# @param base [Class] The class including this module
|
75
|
+
# @private
|
17
76
|
def self.included(base)
|
18
77
|
base.class_eval do
|
19
78
|
extend ClassMethods
|
79
|
+
# Store sort configurations per class to avoid conflicts between controllers
|
20
80
|
class_attribute :sorts_configuration, instance_writer: false
|
21
81
|
self.sorts_configuration = {}
|
22
82
|
end
|
23
83
|
end
|
24
84
|
|
25
|
-
# Class methods made available to
|
85
|
+
# Class methods made available to controllers when Sortable is included.
|
26
86
|
module ClassMethods
|
27
|
-
# Define one
|
87
|
+
# Define one or more sortable attributes for the controller.
|
88
|
+
#
|
89
|
+
# This method configures which model attributes can be sorted via query parameters
|
90
|
+
# and how those sorts should be processed.
|
91
|
+
#
|
92
|
+
# @param attrs [Array<Symbol>] List of attribute names to make sortable
|
93
|
+
# @param options [Hash] Configuration options for the sorts
|
94
|
+
# @option options [String, Symbol] :as Alias for the database column name
|
95
|
+
# @option options [Boolean] :lower Whether to sort on the lowercase version of the attribute
|
96
|
+
# @option options [Class] :class_name Model class for association sorting (defaults to collection class)
|
97
|
+
# @option options [Symbol] :association Association name when different from inferred name
|
98
|
+
#
|
99
|
+
# @example Basic attribute sorting
|
100
|
+
# sort_by :name, :email, :created_at
|
101
|
+
# # Allows: sort=name,-email,created_at
|
102
|
+
#
|
103
|
+
# @example Case-insensitive sorting
|
104
|
+
# sort_by :name, lower: true
|
105
|
+
# # Generates: ORDER BY LOWER(users.name)
|
106
|
+
#
|
107
|
+
# @example Association sorting
|
108
|
+
# sort_by :author, class_name: User, as: 'name'
|
109
|
+
# # Allows: sort=author (sorts by users.name)
|
28
110
|
#
|
29
|
-
# @
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
33
|
-
#
|
111
|
+
# @example Association sorting with custom association name
|
112
|
+
# sort_by :author_name, class_name: User, as: 'name', association: :author
|
113
|
+
# # Allows: sort=author_name (sorts by users.name via author association)
|
114
|
+
# # Note: Make sure your collection is joined: Book.joins(:author)
|
115
|
+
#
|
116
|
+
# @example Field aliasing
|
117
|
+
# sort_by :full_name, as: 'name'
|
118
|
+
# # Maps sort=full_name to ORDER BY users.name
|
34
119
|
def sort_by(*attrs)
|
35
120
|
options = attrs.extract_options!
|
36
121
|
options.symbolize_keys!
|
37
122
|
|
123
|
+
# Validate that only supported options are provided
|
124
|
+
options.assert_valid_keys(:as, :class_name, :lower, :association)
|
125
|
+
|
126
|
+
# Create a new configuration hash to avoid modifying parent class config
|
38
127
|
self.sorts_configuration = sorts_configuration.dup
|
39
128
|
|
40
129
|
attrs.each do |attr|
|
130
|
+
# Initialize default configuration for this attribute
|
41
131
|
sorts_configuration[attr] ||= {
|
42
|
-
as: attr
|
132
|
+
as: attr
|
43
133
|
}
|
44
134
|
|
135
|
+
# Merge in the provided options, overriding defaults
|
45
136
|
sorts_configuration[attr] = sorts_configuration[attr].merge(options)
|
46
137
|
end
|
47
138
|
end
|
48
139
|
end
|
49
140
|
|
50
|
-
#
|
51
|
-
# Public instance methods
|
52
|
-
#
|
141
|
+
# Protected instance methods for sorting functionality
|
53
142
|
|
54
|
-
#
|
55
|
-
# Protected instance methods
|
56
|
-
#
|
57
143
|
protected
|
58
144
|
|
145
|
+
# Apply sorting to the collection based on sort query parameters.
|
146
|
+
# This is the main method that processes the sort parameter and
|
147
|
+
# applies ordering to the ActiveRecord relation.
|
148
|
+
#
|
149
|
+
# @param collection [ActiveRecord::Relation] The collection to sort
|
150
|
+
# @return [ActiveRecord::Relation] The sorted collection
|
151
|
+
# @raise [FetcheableOnApi::ArgumentError] When sort parameters are invalid
|
152
|
+
#
|
153
|
+
# @example
|
154
|
+
# # With params: { sort: 'name,-created_at' }
|
155
|
+
# sorted_users = apply_sort(User.all)
|
156
|
+
# # Generates: ORDER BY users.name ASC, users.created_at DESC
|
59
157
|
def apply_sort(collection)
|
158
|
+
# Return early if no sort parameters are provided
|
60
159
|
return collection if params[:sort].blank?
|
61
160
|
|
161
|
+
# Validate that sort parameter is a string
|
62
162
|
foa_valid_parameters!(:sort, foa_permitted_types: [String])
|
63
163
|
|
164
|
+
# Parse the sort parameter and build Arel ordering expressions
|
64
165
|
ordering = format_params(params[:sort]).map do |attr_name, sort_method|
|
65
166
|
arel_sort(attr_name, sort_method, collection)
|
66
167
|
end
|
67
168
|
|
169
|
+
# Apply the ordering, filtering out any nil values (unconfigured sorts)
|
68
170
|
collection.order(ordering.compact)
|
69
171
|
end
|
70
172
|
|
71
173
|
private
|
72
174
|
|
175
|
+
# Build an Arel ordering expression for the given attribute and sort direction.
|
176
|
+
# Returns nil if the attribute is not configured for sorting or doesn't exist on the model.
|
177
|
+
#
|
178
|
+
# @param attr_name [Symbol] The attribute name to sort by
|
179
|
+
# @param sort_method [Symbol] The sort direction (:asc or :desc)
|
180
|
+
# @param collection [ActiveRecord::Relation] The collection being sorted
|
181
|
+
# @return [Arel::Node, nil] An Arel ordering node or nil if invalid
|
182
|
+
# @private
|
73
183
|
def arel_sort(attr_name, sort_method, collection)
|
184
|
+
# Skip if this attribute is not configured for sorting
|
74
185
|
return if sorts_configuration[attr_name].blank?
|
75
186
|
|
76
187
|
klass = class_for(attr_name, collection)
|
77
188
|
field = field_for(attr_name)
|
189
|
+
|
190
|
+
# Skip if the field doesn't exist on the model
|
78
191
|
return unless belong_to_attributes_for?(klass, field)
|
79
192
|
|
193
|
+
# Build the Arel attribute reference using the appropriate table
|
80
194
|
attribute = klass.arel_table[field]
|
81
|
-
attribute = attribute.lower if sorts_configuration[attr_name].fetch(:lower, false)
|
82
195
|
|
196
|
+
# Apply lowercase transformation if configured
|
197
|
+
config = sorts_configuration[attr_name] || {}
|
198
|
+
attribute = attribute.lower if config.fetch(:lower, false)
|
199
|
+
|
200
|
+
# Apply the sort direction (asc or desc)
|
83
201
|
attribute.send(sort_method)
|
84
202
|
end
|
85
203
|
|
204
|
+
# Determine the model class to use for this sort attribute.
|
205
|
+
# Uses the configured class_name or falls back to the collection's class.
|
206
|
+
#
|
207
|
+
# @param attr_name [Symbol] The attribute name
|
208
|
+
# @param collection [ActiveRecord::Relation] The collection being sorted
|
209
|
+
# @return [Class] The model class to use
|
210
|
+
# @private
|
86
211
|
def class_for(attr_name, collection)
|
87
|
-
sorts_configuration[attr_name]
|
212
|
+
config = sorts_configuration[attr_name] || {}
|
213
|
+
config.fetch(:class_name, collection.klass)
|
88
214
|
end
|
89
215
|
|
216
|
+
# Get the database field name for this sort attribute.
|
217
|
+
# Uses the configured alias (:as option) or the attribute name itself.
|
218
|
+
#
|
219
|
+
# @param attr_name [Symbol] The attribute name
|
220
|
+
# @return [String] The database column name
|
221
|
+
# @private
|
90
222
|
def field_for(attr_name)
|
91
|
-
sorts_configuration[attr_name]
|
223
|
+
config = sorts_configuration[attr_name] || {}
|
224
|
+
config.fetch(:as, attr_name).to_s
|
92
225
|
end
|
93
226
|
|
227
|
+
# Check if the given field exists as an attribute on the model class.
|
228
|
+
# This prevents SQL errors from trying to sort by non-existent columns.
|
229
|
+
#
|
230
|
+
# @param klass [Class] The model class
|
231
|
+
# @param field [String] The field name to check
|
232
|
+
# @return [Boolean] True if the field exists on the model
|
233
|
+
# @private
|
94
234
|
def belong_to_attributes_for?(klass, field)
|
95
235
|
klass.attribute_names.include?(field)
|
96
236
|
end
|
97
237
|
|
238
|
+
# Parse the sort parameter string into a hash of attributes and directions.
|
98
239
|
#
|
99
|
-
#
|
100
|
-
#
|
240
|
+
# This method takes a comma-separated string of sort fields (with optional
|
241
|
+
# direction prefixes) and converts it into a hash mapping field names to
|
242
|
+
# sort directions.
|
101
243
|
#
|
244
|
+
# @param params [String] The sort parameter string
|
245
|
+
# @return [Hash{Symbol => Symbol}] Hash mapping attribute names to sort directions
|
246
|
+
#
|
247
|
+
# @example
|
248
|
+
# format_params("-email,first_name,+last_name")
|
249
|
+
# # => { email: :desc, first_name: :asc, last_name: :asc }
|
250
|
+
#
|
251
|
+
# format_params("name")
|
252
|
+
# # => { name: :asc }
|
253
|
+
#
|
254
|
+
# @private
|
102
255
|
def format_params(params)
|
103
|
-
|
256
|
+
result = {}
|
257
|
+
|
104
258
|
params
|
105
|
-
.split(
|
259
|
+
.split(',') # Split on commas to get individual fields
|
106
260
|
.each do |attribute|
|
107
|
-
|
108
|
-
|
261
|
+
# Trim whitespace
|
262
|
+
attribute = attribute.strip
|
263
|
+
|
264
|
+
# Extract the direction prefix (+ or -) or default to +
|
265
|
+
sort_sign = attribute =~ /\A[+-]/ ? attribute.slice!(0) : '+'
|
266
|
+
|
267
|
+
# Map the field name to its sort direction
|
268
|
+
result[attribute.to_sym] = SORT_ORDER[sort_sign]
|
109
269
|
end
|
110
|
-
|
270
|
+
|
271
|
+
result
|
111
272
|
end
|
112
273
|
end
|
113
274
|
end
|
@@ -1,5 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FetcheableOnApi
|
4
|
-
|
4
|
+
# Current version of the FetcheableOnApi gem.
|
5
|
+
# Follows semantic versioning (semver.org) principles.
|
6
|
+
#
|
7
|
+
# @return [String] The version string in format "MAJOR.MINOR.PATCH"
|
8
|
+
VERSION = '0.6.1'.freeze
|
5
9
|
end
|