rails_api_kit 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34e70026279699c8b1e1a1a3b4bcd5fe6e35ebd30114490eabc87db67444d84b
4
- data.tar.gz: 1c24f1ad20cddc1340688d39d051caa659cfaaa35ef38e30e2b9c68603847f95
3
+ metadata.gz: 398056ff21cfe533837881bebaf65d87fff8c5abba87d5cdf8a9c708d55c90ef
4
+ data.tar.gz: 0b9de9c9f63c4312b898a03d5b8b142a57f82f7a353588f656a0b3cc8da2d290
5
5
  SHA512:
6
- metadata.gz: 742002a691ca1ce8bf656e13be82c495aec57cd6741bf21e3e22293857f83d61a8308904b08a9910e2d76984ba48efedf196d052627d0e8c68300ee0cd474953
7
- data.tar.gz: cf6e1acbb07f3e573f80122cad888e2409599b17f691c54933864ca49106357fa3ab3e8e58b862f5af232c13a5c555cbc3ef2be1067b57415913a6cc7af13f75
6
+ metadata.gz: d965e94656b77eec9b146898305aed13c67aa744c589e101065f87a828433e1a99e9677145d79285b0e630d7c26d31c0f1e0c63365b281f84007477e235a1afe
7
+ data.tar.gz: 45b408f991bbf95bc9341396249e2d12900a906cc0359120a6e1877607c4c41f1898fb79862db405fc13a874087ddca3aecafee5014beabf40b6421d841ad079
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2019 Stas Suscov
3
+ Copyright (c) 2026 Ismail Akbudak
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,9 +1,7 @@
1
- # ApiKit
1
+ # RailsApiKit
2
2
 
3
3
  A lightweight Rails toolkit for building standardized, structured API responses with serialization, error handling, filtering, sorting, and pagination.
4
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
5
  ## Features
8
6
 
9
7
  ApiKit offers a collection of lightweight modules that integrate seamlessly with your Rails controllers:
@@ -294,7 +292,7 @@ This gem leverages these excellent libraries:
294
292
 
295
293
  ## Contributing
296
294
 
297
- Bug reports and pull requests are welcome on GitHub at https://github.com/iakbudak/api_kit
295
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ismailakbudak/rails_api_kit
298
296
 
299
297
  This project follows the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
300
298
 
@@ -12,9 +12,7 @@ module ApiKit
12
12
 
13
13
  result = []
14
14
 
15
- if serializer_class
16
- model_name ||= serializer_class.name.demodulize.delete_suffix("Serializer").underscore
17
- end
15
+ model_name ||= ApiKit::RailsApp.serializer_name(serializer_class)
18
16
 
19
17
  keys = []
20
18
  params[:fields].each do |k, v|
@@ -49,7 +49,9 @@ module ApiKit
49
49
  *api_path_parameters.keys.map(&:to_s)
50
50
  ).as_json.with_indifferent_access
51
51
 
52
- original_params[:page] = original_params[:page].dup || {}
52
+ # A blank `page=` passed `api_pagination_params` as "no page"; links build a fresh one.
53
+ page = original_params[:page]
54
+ original_params[:page] = page.is_a?(Hash) ? page.dup : {}
53
55
  original_url = "?"
54
56
 
55
57
  pagination.each do |page_name, number|
@@ -132,7 +134,16 @@ module ApiKit
132
134
  #
133
135
  # @return [Array] with the offset, limit and the current page number
134
136
  def api_pagination_params
135
- pagination = params[:page].try(:slice, :number, :size) || {}
137
+ page = params[:page]
138
+ # `page=` names no page: the defaults, like a missing `page`.
139
+ page = nil if page.is_a?(String) && page.blank?
140
+ # `page=2` (or a JSON array / number) is not `page[number]=2`: a client error like
141
+ # `params.expect`, not a 500 from #slice.
142
+ unless page.nil? || page.is_a?(ActionController::Parameters) || page.is_a?(Hash)
143
+ raise ActionController::ParameterMissing, :page
144
+ end
145
+
146
+ pagination = page.try(:slice, :number, :size) || {}
136
147
  per_page = api_page_size(pagination)
137
148
  num = [ 1, pagination[:number].to_f.to_i ].max
138
149
 
@@ -75,6 +75,7 @@ module ApiKit
75
75
  options[:fields] = api_fields(serializer_class, ApiKit::RailsApp.fetch_name(many, resource))
76
76
  options[:adapter] = :attributes
77
77
  options[:each_serializer] = serializer_class
78
+ ApiKit::RailsApp.assign_collection_root!(options, resource, serializer_class) if many
78
79
  data = ActiveModelSerializers::SerializableResource.new(resource, options).as_json
79
80
  result[:data] = data
80
81
  result.to_json
@@ -107,6 +108,7 @@ module ApiKit
107
108
  options[:adapter] = :attributes
108
109
  options[:each_serializer] = serializer_class
109
110
  if many
111
+ ApiKit::RailsApp.assign_collection_root!(options, resource, serializer_class)
110
112
  data = ActiveModelSerializers::SerializableResource.new(resource, options).as_json
111
113
  else
112
114
  data = ActiveModelSerializers::SerializableResource.new([ resource ], options).as_json[0]
@@ -137,21 +139,79 @@ module ApiKit
137
139
  "#{klass.name}Serializer".constantize
138
140
  end
139
141
 
140
- # Resolves the singular model name for sparse fieldsets
142
+ # Resolves the model name used as the sparse-fieldset type key
143
+ #
144
+ # Mirrors `ActiveModel::Serializer#json_key`, which AMS uses to look a
145
+ # type up in the fieldset: `object.class.model_name.to_s.underscore`.
146
+ # NOT `model_name.singular` — that tr()s the namespace separator to an
147
+ # underscore (`manufacturing_work_order`), so for a namespaced model the
148
+ # key never matched, the primary type went unconstrained, and AMS fell
149
+ # through to the pluralised collection key whose value is an empty list:
150
+ # every attribute of the primary resource was silently dropped.
141
151
  #
142
152
  # @param many [Boolean] indicates whether the resource is a collection
143
153
  # @param resource [Object] serialized resource or collection
144
- # @return [String, nil] singular model name when available
154
+ # @return [String, nil] model name when available
145
155
  def self.fetch_name(many, resource)
146
- if many
147
- if resource.is_a?(ActiveRecord::Relation)
148
- resource&.model_name&.singular
149
- else
150
- resource.first&.model_name&.singular
151
- end
152
- else
153
- resource&.model_name&.singular
154
- end
156
+ record = many ? collection_model(resource) : resource
157
+ model_name = record&.model_name
158
+ model_name && model_name.to_s.underscore
159
+ end
160
+
161
+ # Root key for a collection AMS cannot infer one for
162
+ #
163
+ # `AMS::CollectionSerializer#json_key` reads the root from its first
164
+ # element, or from a named collection (`ActiveRecord::Relation` answers
165
+ # `#name` through its klass). An EMPTY plain Array offers neither, so it
166
+ # raises `CannotInferRootKeyError` the moment sparse fieldsets are
167
+ # requested. Aggregation endpoints that render POROs hit exactly that.
168
+ #
169
+ # Returns nil whenever AMS can infer the key itself, so a collection that
170
+ # already works keeps its own root and item serializers keep their
171
+ # `json_key`.
172
+ #
173
+ # @param resource [Object] the collection being serialized
174
+ # @param serializer_class [Class, NilClass] serializer for its members
175
+ # @return [String, nil] pluralised root key, or nil to leave it to AMS
176
+ def self.collection_root(resource, serializer_class)
177
+ return nil unless resource.respond_to?(:empty?) && resource.empty?
178
+ return nil if resource.respond_to?(:name)
179
+
180
+ name = serializer_name(serializer_class)
181
+ name && name.pluralize
182
+ end
183
+
184
+ # Sets the collection root only when AMS could not work one out
185
+ #
186
+ # @param options [Hash] render options, mutated in place
187
+ # @param resource [Object] the collection being serialized
188
+ # @param serializer_class [Class, NilClass] serializer for its members
189
+ # @return [NilClass]
190
+ def self.assign_collection_root!(options, resource, serializer_class)
191
+ return if options[:root]
192
+
193
+ root = collection_root(resource, serializer_class)
194
+ options[:root] = root if root
195
+ nil
196
+ end
197
+
198
+ # Resolves the type name a serializer class stands for
199
+ #
200
+ # @param serializer_class [Class, NilClass] e.g. `V1::UserSerializer`
201
+ # @return [String, nil] e.g. `"user"`
202
+ def self.serializer_name(serializer_class)
203
+ name = serializer_class && serializer_class.name
204
+ name && name.demodulize.delete_suffix("Serializer").underscore
205
+ end
206
+
207
+ # Resolves the record a collection's model name should come from
208
+ #
209
+ # @param resource [Object] the collection
210
+ # @return [Object, NilClass] a record responding to `model_name`
211
+ def self.collection_model(resource)
212
+ return resource if resource.is_a?(ActiveRecord::Relation)
213
+
214
+ resource.respond_to?(:first) ? resource.first : nil
155
215
  end
156
216
  end
157
217
  end
@@ -1,3 +1,3 @@
1
1
  module ApiKit
2
- VERSION = "1.0.0"
2
+ VERSION = "1.0.2"
3
3
  end
data/spec/dummy.rb CHANGED
@@ -28,6 +28,12 @@ ActiveRecord::Schema.define do
28
28
  t.integer :quantity
29
29
  t.timestamps
30
30
  end
31
+
32
+ create_table :inventory_items, force: true do |t|
33
+ t.string :name
34
+ t.integer :quantity
35
+ t.timestamps
36
+ end
31
37
  end
32
38
 
33
39
 
@@ -94,6 +100,32 @@ class UserSerializer < ActiveModel::Serializer
94
100
  end
95
101
  end
96
102
 
103
+ # Namespaced model — `model_name.to_s.underscore` is slash-separated
104
+ # (`inventory/item`) while `model_name.singular` is not
105
+ # (`inventory_item`). The sparse-fieldset type key is the former.
106
+ module Inventory
107
+ class Item < ApplicationRecord
108
+ self.table_name = 'inventory_items'
109
+ end
110
+
111
+ class ItemSerializer < ActiveModel::Serializer
112
+ attributes :id, :name, :quantity
113
+ end
114
+ end
115
+
116
+ # A row of an aggregation endpoint: a PORO, not an AR record, rendered as
117
+ # a plain Array. An empty one gives AMS nothing to infer a root key from.
118
+ class ReportRow
119
+ include ActiveModel::Model
120
+ include ActiveModel::Serialization
121
+
122
+ attr_accessor :label, :total
123
+ end
124
+
125
+ class ReportRowSerializer < ActiveModel::Serializer
126
+ attributes :label, :total
127
+ end
128
+
97
129
  class MyUserSerializer < UserSerializer
98
130
  attribute :full_name
99
131
 
@@ -110,6 +142,8 @@ class Dummy < Rails::Application
110
142
  scope defaults: { format: :api } do
111
143
  resources :users, only: [ :index, :show ]
112
144
  resources :notes, only: [ :update ]
145
+ resources :inventory_items, only: [ :index ]
146
+ resources :reports, only: [ :index ]
113
147
  end
114
148
  end
115
149
  end
@@ -170,6 +204,29 @@ class UsersController < BaseApplicationController
170
204
  end
171
205
  end
172
206
 
207
+ class InventoryItemsController < BaseApplicationController
208
+ include ApiKit::Fetching
209
+
210
+ def index
211
+ render api: Inventory::Item.all, serializer_class: Inventory::ItemSerializer
212
+ end
213
+ end
214
+
215
+ class ReportsController < BaseApplicationController
216
+ include ApiKit::Fetching
217
+
218
+ def index
219
+ rows =
220
+ if params[:empty]
221
+ []
222
+ else
223
+ [ ReportRow.new(label: 'first', total: 1) ]
224
+ end
225
+
226
+ render api: rows, serializer_class: ReportRowSerializer
227
+ end
228
+ end
229
+
173
230
  class NotesController < ActionController::Base
174
231
  include ApiKit::Fetching
175
232
  include ApiKit::Errors
@@ -40,11 +40,18 @@ RSpec.describe UsersController, type: :request do
40
40
  let(:user) { users.last }
41
41
 
42
42
  context 'returns filtered users' do
43
+ # FFaker names are random, so filter on a token that no other user
44
+ # can match by accident
45
+ let(:name_prefix) { 'Filterable' }
46
+ let(:filterable_name) { "#{name_prefix}#{third_user.id}" }
47
+
43
48
  let(:params) do
49
+ third_user.update(first_name: filterable_name)
50
+
44
51
  {
45
52
  filter: {
46
53
  last_name_or_first_name_cont_any: (
47
- "#{third_user.first_name[0..5]}%,#{self.class.name}"
54
+ "#{name_prefix},#{self.class.name}"
48
55
  )
49
56
  }
50
57
  }
@@ -56,6 +63,27 @@ RSpec.describe UsersController, type: :request do
56
63
  expect(response_json['data'][0]['id']).to eql(third_user.id)
57
64
  end
58
65
 
66
+ # `cont` escapes LIKE wildcards, `matches` passes them through
67
+ context 'with a wildcard' do
68
+ let(:params) do
69
+ third_user.update(first_name: filterable_name)
70
+
71
+ {
72
+ filter: {
73
+ last_name_or_first_name_matches_any: (
74
+ "#{name_prefix}%,#{self.class.name}"
75
+ )
76
+ }
77
+ }
78
+ end
79
+
80
+ it do
81
+ expect(response).to have_http_status(:ok)
82
+ expect(response_json['data'].size).to eq(1)
83
+ expect(response_json['data'][0]['id']).to eql(third_user.id)
84
+ end
85
+ end
86
+
59
87
  context 'with a comma' do
60
88
  let(:params) do
61
89
  third_user.update(first_name: third_user.first_name + ',')
@@ -332,4 +332,50 @@ RSpec.describe UsersController, type: :request do
332
332
  end
333
333
  end
334
334
  end
335
+
336
+ describe 'GET /users with a scalar page' do
337
+ # `page=2` is not `page[number]=2`: ParameterMissing, a client error, not a 500 from
338
+ # String#slice. This controller has no ApiKit::Errors, so Rails answers 400; with it, 422.
339
+ [ 'page=2', 'page[]=2' ].each do |query|
340
+ it "answers #{query} with a client error" do
341
+ get("#{users_path}?#{query}", headers: api_headers)
342
+
343
+ expect(response).to have_http_status(:bad_request)
344
+ end
345
+ end
346
+ end
347
+
348
+ describe 'GET /users with a blank page' do
349
+ # `page=` names no page: the defaults, like a missing `page`.
350
+ [ 'page=', 'page=%20' ].each do |query|
351
+ it "answers #{query} with the first page" do
352
+ get("#{users_path}?#{query}", headers: api_headers)
353
+
354
+ expect(response).to have_http_status(:ok)
355
+ expect(response_json.dig('meta', 'pagination', 'current')).to eq(1)
356
+ end
357
+ end
358
+ end
359
+
360
+ describe '#api_pagination_params with a typed non-hash page' do
361
+ # A JSON body reaches it typed: an array or a number is malformed too, never a #slice error.
362
+ let(:controller_class) do
363
+ Class.new do
364
+ include ApiKit::Pagination
365
+
366
+ attr_reader :params
367
+
368
+ def initialize(params)
369
+ @params = ActionController::Parameters.new(params)
370
+ end
371
+ end
372
+ end
373
+
374
+ [ [], 2 ].each do |page|
375
+ it "raises ParameterMissing for #{page.inspect}" do
376
+ expect { controller_class.new(page: page).send(:api_pagination_params) }
377
+ .to raise_error(ActionController::ParameterMissing)
378
+ end
379
+ end
380
+ end
335
381
  end
@@ -0,0 +1,87 @@
1
+ require 'spec_helper'
2
+
3
+ # The sparse-fieldset type key AMS looks a serializer up by is
4
+ # `model_name.to_s.underscore`. api_kit has to derive the same string when it
5
+ # injects the fallback "everything for the primary type" entry, or AMS falls
6
+ # through to the pluralised collection key — whose value is an empty list.
7
+ RSpec.describe 'sparse fieldsets', type: :request do
8
+ describe 'GET /inventory_items' do
9
+ let!(:item) { Inventory::Item.create!(name: 'Bolt', quantity: 7) }
10
+
11
+ before { get(inventory_items_path, params: params, headers: api_headers) }
12
+
13
+ context 'when fields are sent for the primary type' do
14
+ let(:params) { { fields: { 'inventory/item' => 'id,name' } } }
15
+
16
+ it 'trims the primary type to the requested attributes' do
17
+ expect(response).to have_http_status(:ok)
18
+ expect(response_json['data'].first.keys).to contain_exactly('id', 'name')
19
+ end
20
+ end
21
+
22
+ context 'when fields are sent only for another type' do
23
+ let(:params) { { fields: { user: 'id' } } }
24
+
25
+ it 'leaves the namespaced primary type fully populated' do
26
+ expect(response).to have_http_status(:ok)
27
+ expect(response_json['data'].first.keys)
28
+ .to contain_exactly('id', 'name', 'quantity')
29
+ end
30
+ end
31
+
32
+ context 'without any fields param' do
33
+ let(:params) { {} }
34
+
35
+ it 'returns every attribute' do
36
+ expect(response).to have_http_status(:ok)
37
+ expect(response_json['data'].first.keys)
38
+ .to contain_exactly('id', 'name', 'quantity')
39
+ end
40
+ end
41
+ end
42
+
43
+ # `AMS::CollectionSerializer#json_key` infers the root from the first element,
44
+ # or from a collection that answers `#name` (an `ActiveRecord::Relation` does,
45
+ # through its klass). An empty plain Array answers neither, and raises
46
+ # `CannotInferRootKeyError` as soon as a fieldset has to be built.
47
+ describe 'GET /reports' do
48
+ before { get(reports_path, params: params, headers: api_headers) }
49
+
50
+ context 'with rows and a fields param' do
51
+ let(:params) { { fields: { report_row: 'label' } } }
52
+
53
+ it 'trims the PORO row to the requested attributes' do
54
+ expect(response).to have_http_status(:ok)
55
+ expect(response_json['data'].first.keys).to contain_exactly('label')
56
+ end
57
+ end
58
+
59
+ context 'with rows and no fields param' do
60
+ let(:params) { {} }
61
+
62
+ it 'returns every attribute' do
63
+ expect(response).to have_http_status(:ok)
64
+ expect(response_json['data'].first.keys)
65
+ .to contain_exactly('label', 'total')
66
+ end
67
+ end
68
+
69
+ context 'with no rows and a fields param' do
70
+ let(:params) { { empty: true, fields: { report_row: 'label' } } }
71
+
72
+ it 'renders an empty collection instead of failing to infer a root' do
73
+ expect(response).to have_http_status(:ok)
74
+ expect(response_json['data']).to eq([])
75
+ end
76
+ end
77
+
78
+ context 'with no rows and no fields param' do
79
+ let(:params) { { empty: true } }
80
+
81
+ it 'renders an empty collection' do
82
+ expect(response).to have_http_status(:ok)
83
+ expect(response_json['data']).to eq([])
84
+ end
85
+ end
86
+ end
87
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_api_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismail Akbudak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-06 00:00:00.000000000 Z
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ransack
@@ -244,9 +244,10 @@ files:
244
244
  - spec/fetching_spec.rb
245
245
  - spec/filtering_spec.rb
246
246
  - spec/pagination_spec.rb
247
+ - spec/sparse_fields_type_key_spec.rb
247
248
  - spec/spec_helper.rb
248
249
  - spec/support/api_kit_rspec.rb
249
- homepage: https://github.com/iakbudak/api_kit
250
+ homepage: https://github.com/ismailakbudak/rails_api_kit
250
251
  licenses:
251
252
  - MIT
252
253
  metadata: {}
@@ -265,7 +266,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
265
266
  - !ruby/object:Gem::Version
266
267
  version: '0'
267
268
  requirements: []
268
- rubygems_version: 3.5.22
269
+ rubygems_version: 3.4.19
269
270
  signing_key:
270
271
  specification_version: 4
271
272
  summary: A lightweight toolkit for building JSON:API compliant APIs with Rails.