rails_api_kit 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 34e70026279699c8b1e1a1a3b4bcd5fe6e35ebd30114490eabc87db67444d84b
4
+ data.tar.gz: 1c24f1ad20cddc1340688d39d051caa659cfaaa35ef38e30e2b9c68603847f95
5
+ SHA512:
6
+ metadata.gz: 742002a691ca1ce8bf656e13be82c495aec57cd6741bf21e3e22293857f83d61a8308904b08a9910e2d76984ba48efedf196d052627d0e8c68300ee0cd474953
7
+ data.tar.gz: cf6e1acbb07f3e573f80122cad888e2409599b17f691c54933864ca49106357fa3ab3e8e58b862f5af232c13a5c555cbc3ef2be1067b57415913a6cc7af13f75
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 Stas Suscov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,313 @@
1
+ # ApiKit
2
+
3
+ A lightweight Rails toolkit for building standardized, structured API responses with serialization, error handling, filtering, sorting, and pagination.
4
+
5
+ > Building clean, consistent APIs shouldn't be rocket science. ApiKit provides simple, powerful modules to get you up and running quickly.
6
+
7
+ ## Features
8
+
9
+ ApiKit offers a collection of lightweight modules that integrate seamlessly with your Rails controllers:
10
+
11
+ * **Object serialization** - Powered by Active Model Serializers
12
+ * **Error handling** - Standardized error responses for parameters, validation, and generic errors
13
+ * **Fetching** - Support for relationship includes and sparse fieldsets
14
+ * **Filtering & Sorting** - Advanced filtering and sorting powered by Ransack
15
+ * **Pagination** - Built-in pagination support with links and metadata
16
+
17
+ ## Installation
18
+
19
+ **Requirements:**
20
+ - Ruby 3.3.0 or higher
21
+ - Rails 8.0 or higher
22
+
23
+ Add this line to your application's Gemfile:
24
+
25
+ ```ruby
26
+ gem "rails_api_kit"
27
+ ```
28
+
29
+ And then execute:
30
+
31
+ $ bundle install
32
+
33
+ ## Quick Start
34
+
35
+ ### 1. Enable Rails Integration
36
+
37
+ Add this to an initializer:
38
+
39
+ ```ruby
40
+ # config/initializers/rails_api_kit.rb
41
+ require "rails_api_kit"
42
+
43
+ ApiKit::RailsApp.install!
44
+ ```
45
+
46
+ This registers the media type and renderers.
47
+
48
+ ### 2. Basic Usage
49
+
50
+ ```ruby
51
+ class UsersController < ApplicationController
52
+ include ApiKit::Filtering
53
+ include ApiKit::Pagination
54
+
55
+ def index
56
+ allowed_fields = [ :first_name, :last_name, :created_at ]
57
+
58
+ api_filter(User.all, allowed_fields) do |filtered|
59
+ api_paginate(filtered.result) do |paginated|
60
+ render api_paginate: paginated
61
+ end
62
+ end
63
+ end
64
+
65
+ def show
66
+ user = User.find(params[:id])
67
+ render api: user
68
+ end
69
+ end
70
+ ```
71
+
72
+ ### 3. Create Serializers
73
+
74
+ ```ruby
75
+ class UserSerializer < ActiveModel::Serializer
76
+ attributes :id, :first_name, :last_name, :email, :created_at, :updated_at
77
+
78
+ has_many :posts
79
+ end
80
+ ```
81
+
82
+ ## Core Modules
83
+
84
+ ### ApiKit::Filtering
85
+
86
+ Provides powerful filtering and sorting using Ransack:
87
+
88
+ ```ruby
89
+ class UsersController < ApplicationController
90
+ include ApiKit::Filtering
91
+
92
+ def index
93
+ allowed_fields = [ :first_name, :last_name, :email, :posts_title ]
94
+
95
+ api_filter(User.all, allowed_fields) do |filtered|
96
+ render api: filtered.result
97
+ end
98
+ end
99
+ end
100
+ ```
101
+
102
+ **Example requests:**
103
+ ```bash
104
+ # Filter by first name containing "John"
105
+ GET /users?filter[first_name_cont]=John
106
+
107
+ # Sort by last name descending, then first name ascending
108
+ GET /users?sort=-last_name,first_name
109
+
110
+ # Complex filtering with relationships
111
+ GET /users?filter[posts_title_matches_any]=Ruby,Rails&sort=-created_at
112
+ ```
113
+
114
+ #### Sorting with Expressions
115
+
116
+ Enable aggregation expressions for advanced sorting:
117
+
118
+ ```ruby
119
+ def index
120
+ allowed_fields = [ :first_name, :posts_count ]
121
+ options = { sort_with_expressions: true }
122
+
123
+ api_filter(User.joins(:posts), allowed_fields, options) do |filtered|
124
+ render api: filtered.result.group("users.id")
125
+ end
126
+ end
127
+ ```
128
+
129
+ ```bash
130
+ # Sort by post count
131
+ GET /users?sort=-posts_count_sum
132
+ ```
133
+
134
+ ### ApiKit::Pagination
135
+
136
+ Handles pagination with standardized links:
137
+
138
+ ```ruby
139
+ class UsersController < ApplicationController
140
+ include ApiKit::Pagination
141
+
142
+ def index
143
+ api_paginate(User.all) do |paginated|
144
+ render api_paginate: paginated
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def api_meta(resources)
151
+ {
152
+ pagination: api_pagination_meta(resources),
153
+ total: resources.respond_to?(:count) ? resources.count : resources.size
154
+ }
155
+ end
156
+ end
157
+ ```
158
+
159
+ **Example requests:**
160
+ ```bash
161
+ # Get page 2 with 20 items per page
162
+ GET /users?page[number]=2&page[size]=20
163
+ ```
164
+
165
+ ### ApiKit::Fetching
166
+
167
+ Supports relationship inclusion and sparse fieldsets:
168
+
169
+ ```ruby
170
+ class UsersController < ApplicationController
171
+ include ApiKit::Fetching
172
+
173
+ def index
174
+ render api: User.all
175
+ end
176
+
177
+ private
178
+
179
+ def api_include
180
+ # Whitelist allowed includes
181
+ super & [ "posts", "profile" ]
182
+ end
183
+ end
184
+ ```
185
+
186
+ **Example requests:**
187
+ ```bash
188
+ # Include related posts
189
+ GET /users?include=posts
190
+
191
+ # Sparse fieldsets
192
+ GET /users?fields[user]=first_name,last_name
193
+ ```
194
+
195
+ ### ApiKit::Errors
196
+
197
+ Standardized error responses for common Rails exceptions:
198
+
199
+ ```ruby
200
+ class UsersController < ApplicationController
201
+ include ApiKit::Errors
202
+
203
+ def create
204
+ user = User.new(user_params)
205
+
206
+ if user.save
207
+ render api: user, status: :created
208
+ else
209
+ render api_errors: user.errors, status: :unprocessable_entity
210
+ end
211
+ end
212
+
213
+ def update
214
+ user = User.find(params[:id])
215
+
216
+ if user.update(user_params)
217
+ render api: user
218
+ else
219
+ render api_errors: user.errors, status: :unprocessable_entity
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ def user_params
226
+ params.require(:data).require(:attributes).permit(:first_name, :last_name, :email)
227
+ end
228
+
229
+ def render_api_internal_server_error(exception)
230
+ # Custom exception handling (e.g., error tracking)
231
+ # Sentry.capture_exception(exception)
232
+ super(exception)
233
+ end
234
+ end
235
+ ```
236
+
237
+ **Handled exceptions:**
238
+ - `StandardError` → 500 Internal Server Error
239
+ - `ActiveRecord::RecordNotFound` → 404 Not Found
240
+ - `ActionController::ParameterMissing` → 422 Unprocessable Entity
241
+
242
+ ## Advanced Configuration
243
+
244
+ ### Custom Serializer Resolution
245
+
246
+ ```ruby
247
+ class UsersController < ApplicationController
248
+ def index
249
+ render api: User.all, serializer_class: CustomUserSerializer
250
+ end
251
+
252
+ private
253
+
254
+ def api_serializer_class(resource, is_collection)
255
+ ApiKit::RailsApp.serializer_class(resource, is_collection)
256
+ rescue NameError
257
+ "#{resource.class.name}Serializer".constantize
258
+ end
259
+ end
260
+ ```
261
+
262
+ ### Custom Page Size
263
+
264
+ ```ruby
265
+ def api_page_size(pagination_params)
266
+ per_page = pagination_params[:size].to_i
267
+ return 30 if per_page < 1 || per_page > 100
268
+ per_page
269
+ end
270
+ ```
271
+
272
+ ### Serializer Parameters
273
+
274
+ ```ruby
275
+ def api_serializer_params
276
+ {
277
+ current_user: current_user,
278
+ include_private: params[:include_private].present?
279
+ }
280
+ end
281
+ ```
282
+
283
+ ## Configuration
284
+
285
+ ### Environment Variables
286
+
287
+ - `PAGINATION_LIMIT` - Default page size (default: 30)
288
+
289
+ ### Dependencies
290
+
291
+ This gem leverages these excellent libraries:
292
+ - [Active Model Serializers](https://github.com/rails-api/active_model_serializers) - Object serialization
293
+ - [Ransack](https://github.com/activerecord-hackery/ransack) - Advanced filtering and sorting
294
+
295
+ ## Contributing
296
+
297
+ Bug reports and pull requests are welcome on GitHub at https://github.com/iakbudak/api_kit
298
+
299
+ This project follows the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
300
+
301
+ ## Development
302
+
303
+ After checking out the repo:
304
+
305
+ ```bash
306
+ bundle install
307
+ bundle exec rspec # Run tests
308
+ bundle exec rubocop # Check code style
309
+ ```
310
+
311
+ ## License
312
+
313
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,96 @@
1
+ module ApiKit
2
+ # Serializer for JSON:API error responses
3
+
4
+ class ErrorSerializer
5
+ # The errors to be serialized
6
+ # @return [Array, ActiveModel::Errors] the errors to be serialized
7
+ attr_reader :errors
8
+
9
+ # Serialization options
10
+ # @return [Hash] serialization options
11
+ attr_reader :options
12
+
13
+ # Initialize the error serializer
14
+ #
15
+ # @param errors [ActiveModel::Errors, Array] errors to serialize
16
+ # @param options [Hash] serialization options
17
+ def initialize(errors, options = {})
18
+ @errors = if errors.is_a?(ActiveModel::Errors)
19
+ errors.errors
20
+ else
21
+ Array(errors)
22
+ end
23
+ @options = options
24
+ end
25
+
26
+ # Convert errors to JSON format
27
+ #
28
+ # @param args [Array] arguments passed to to_json
29
+ # @return [String] JSON representation of errors
30
+ def to_json(*args)
31
+ { errors: serialized_errors }.to_json(*args)
32
+ end
33
+
34
+ private
35
+
36
+ # Serialize errors into JSON:API format
37
+ #
38
+ # @return [Array<Hash>] array of serialized error objects
39
+ def serialized_errors
40
+ errors.map do |error|
41
+ if error.is_a?(Array) && error.size == 2
42
+ # Handle [attribute, error_hash] format from rails_app.rb
43
+ serialize_validation_error(error[0], error[1])
44
+ elsif error.is_a?(Hash)
45
+ # Handle direct hash format
46
+ serialize_hash_error(error)
47
+ else
48
+ # Handle generic errors
49
+ serialize_generic_error(error)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Serialize validation error from ActiveModel
55
+ #
56
+ # @param attribute [Symbol, String] the attribute with the error
57
+ # @param error [Hash] the error details
58
+ # @return [Hash] serialized error object
59
+ def serialize_validation_error(attribute, error)
60
+ {
61
+ status: error[:status] || "422",
62
+ code: error[:code] || "invalid",
63
+ title: error[:title] || "Error",
64
+ detail: error[:message],
65
+ attribute: attribute
66
+ }.compact
67
+ end
68
+
69
+ # Serialize hash-formatted error
70
+ #
71
+ # @param error [Hash] the error hash
72
+ # @return [Hash] serialized error object
73
+ def serialize_hash_error(error)
74
+ {
75
+ status: error[:status] || "422",
76
+ code: error[:code] || "invalid",
77
+ title: error[:title] || "Error",
78
+ detail: error[:detail] || error["detail"]
79
+ }.compact
80
+ end
81
+
82
+ # Serialize generic error object
83
+ #
84
+ # @param error [Object] the error object
85
+ # @return [Hash] serialized error object
86
+ def serialize_generic_error(error)
87
+ {
88
+ status: "422",
89
+ code: error.type,
90
+ title: "Error",
91
+ detail: error.full_message,
92
+ attribute: error.attribute
93
+ }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,75 @@
1
+ require "rack/utils"
2
+
3
+ module ApiKit
4
+ # Helpers to handle some error responses
5
+ #
6
+ # Most of the exceptions are handled in Rails by [ActionDispatch] middleware
7
+ # See: https://api.rubyonrails.org/classes/ActionDispatch/ExceptionWrapper.html
8
+ module Errors
9
+ # Callback will register the error handlers
10
+ #
11
+ # @return [Module]
12
+ def self.included(base)
13
+ base.class_eval do
14
+ rescue_from(
15
+ StandardError,
16
+ with: :render_api_internal_server_error
17
+ )
18
+
19
+ rescue_from(
20
+ ActiveRecord::RecordNotFound,
21
+ with: :render_api_not_found
22
+ )
23
+
24
+ rescue_from(
25
+ ActionController::ParameterMissing,
26
+ with: :render_api_unprocessable_entity
27
+ )
28
+ end
29
+ end
30
+
31
+ private
32
+ # Generic error handler callback
33
+ #
34
+ # @param exception [Exception] instance to handle
35
+ # @return [String] error response
36
+ def render_api_internal_server_error(exception)
37
+ error = {
38
+ status: "500",
39
+ code: "internal_server_error",
40
+ title: Rack::Utils::HTTP_STATUS_CODES[500],
41
+ detail: exception.message
42
+ }
43
+ render api_errors: [ error ], status: :internal_server_error
44
+ end
45
+
46
+ # Not found (404) error handler callback
47
+ #
48
+ # @param exception [Exception] instance to handle
49
+ # @return [String] error response
50
+ def render_api_not_found(exception)
51
+ error = {
52
+ status: "404",
53
+ code: "not_found",
54
+ title: Rack::Utils::HTTP_STATUS_CODES[404],
55
+ detail: "Resource not found"
56
+ }
57
+ render api_errors: [ error ], status: :not_found
58
+ end
59
+
60
+ # Unprocessable entity (422) error handler callback
61
+ #
62
+ # @param exception [Exception] instance to handle
63
+ # @return [String] error response
64
+ def render_api_unprocessable_entity(exception)
65
+ error = {
66
+ status: "422",
67
+ code: "unprocessable_entity",
68
+ title: Rack::Utils::HTTP_STATUS_CODES[422],
69
+ detail: "Required parameter missing or invalid"
70
+ }
71
+
72
+ render api_errors: [ error ], status: :unprocessable_content
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,42 @@
1
+ module ApiKit
2
+ # Inclusion and sparse fields support
3
+ module Fetching
4
+ private
5
+ # Extracts and formats sparse fieldsets for Active Model Serializers
6
+ #
7
+ # Ex.: `GET /resource?fields[relationship]=id,created_at`
8
+ #
9
+ # @return [Hash] in Active Model Serializers format
10
+ def api_fields(serializer_class = nil, model_name = nil)
11
+ return unless params[:fields].respond_to?(:each_pair)
12
+
13
+ result = []
14
+
15
+ if serializer_class
16
+ model_name ||= serializer_class.name.demodulize.delete_suffix("Serializer").underscore
17
+ end
18
+
19
+ keys = []
20
+ params[:fields].each do |k, v|
21
+ field_names = v.to_s.split(",").map(&:strip).compact.map(&:to_sym)
22
+ result << { k.to_sym => field_names }
23
+ keys << k
24
+ end
25
+
26
+ if model_name && serializer_class && !keys.include?(model_name)
27
+ result << { model_name.to_sym => serializer_class._attributes }
28
+ end
29
+
30
+ result
31
+ end
32
+
33
+ # Extracts and whitelists allowed includes
34
+ #
35
+ # Ex.: `GET /resource?include=relationship,relationship.subrelationship`
36
+ #
37
+ # @return [Array]
38
+ def api_include
39
+ params["include"].to_s.split(",").map(&:strip).compact
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,103 @@
1
+ require "ransack/predicate"
2
+ require_relative "patches"
3
+
4
+ # Filtering and sorting support
5
+ module ApiKit
6
+ module Filtering
7
+ # Parses and returns the attribute and the predicate of a ransack field
8
+ #
9
+ # @param requested_field [String] the field to parse
10
+ # @return [Array] with the fields and the predicate
11
+ def self.extract_attributes_and_predicates(requested_field)
12
+ predicates = []
13
+ field_name = requested_field.to_s.dup
14
+
15
+ while Ransack::Predicate.detect_from_string(field_name).present? do
16
+ predicate = Ransack::Predicate
17
+ .detect_and_strip_from_string!(field_name)
18
+ predicates << Ransack::Predicate.named(predicate)
19
+ end
20
+
21
+ [ field_name.split(/_and_|_or_/), predicates.reverse ]
22
+ end
23
+
24
+ private
25
+ # Applies filtering and sorting to a set of resources if requested
26
+ #
27
+ # The fields follow [Ransack] specifications.
28
+ # See: https://github.com/activerecord-hackery/ransack#search-matchers
29
+ #
30
+ # Ex.: `GET /resource?filter[region_matches_any]=Lisb%&sort=-created_at,id`
31
+ #
32
+ # @param allowed_fields [Array] a list of allowed fields to be filtered
33
+ # @param options [Hash] extra flags to enable/disable features
34
+ # @return [ActiveRecord::Base] a collection of resources
35
+ def api_filter(resources, allowed_fields, options = {})
36
+ allowed_fields = allowed_fields.map(&:to_s)
37
+ extracted_params = api_filter_params(allowed_fields)
38
+ extracted_params[:sorts] = api_sort_params(allowed_fields, options)
39
+ resources = resources.ransack(extracted_params)
40
+ block_given? ? yield(resources) : resources
41
+ end
42
+
43
+ # Extracts and whitelists allowed fields to be filtered
44
+ #
45
+ # The fields follow [Ransack] specifications.
46
+ # See: https://github.com/activerecord-hackery/ransack#search-matchers
47
+ #
48
+ # @param allowed_fields [Array] a list of allowed fields to be filtered
49
+ # @return [Hash] to be passed to [ActiveRecord::Base#order]
50
+ def api_filter_params(allowed_fields)
51
+ filtered = {}
52
+ requested = params[:filter] || {}
53
+ allowed_fields = allowed_fields.map(&:to_s)
54
+
55
+ requested.each_pair do |requested_field, to_filter|
56
+ field_names, predicates = ApiKit::Filtering
57
+ .extract_attributes_and_predicates(requested_field)
58
+
59
+ wants_array = predicates.any? && predicates.map(&:wants_array).any?
60
+
61
+ if to_filter.is_a?(String) && wants_array
62
+ to_filter = to_filter.split(",")
63
+ end
64
+
65
+ if predicates.any? && (field_names - allowed_fields).empty?
66
+ filtered[requested_field] = to_filter
67
+ end
68
+ end
69
+
70
+ filtered
71
+ end
72
+
73
+ # Extracts and whitelists allowed fields (or expressions) to be sorted
74
+ #
75
+ # @param allowed_fields [Array] a list of allowed fields to be sorted
76
+ # @param options [Hash] extra options to enable/disable features
77
+ # @return [Hash] to be passed to [ActiveRecord::Base#order]
78
+ def api_sort_params(allowed_fields, options = {})
79
+ filtered = []
80
+ requested = params[:sort].to_s.split(",")
81
+
82
+ requested.each do |requested_field|
83
+ if requested_field.to_s.start_with?("-")
84
+ dir = "desc"
85
+ requested_field = requested_field[1..-1]
86
+ else
87
+ dir = "asc"
88
+ end
89
+
90
+ field_names, predicates = ApiKit::Filtering
91
+ .extract_attributes_and_predicates(requested_field)
92
+
93
+ next unless (field_names - allowed_fields).empty?
94
+ next if !options[:sort_with_expressions] && predicates.any?
95
+
96
+ # Convert to strings instead of hashes to allow joined table columns.
97
+ filtered << [ requested_field, dir ].join(" ")
98
+ end
99
+
100
+ filtered
101
+ end
102
+ end
103
+ end