rails_api_kit 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34e70026279699c8b1e1a1a3b4bcd5fe6e35ebd30114490eabc87db67444d84b
4
- data.tar.gz: 1c24f1ad20cddc1340688d39d051caa659cfaaa35ef38e30e2b9c68603847f95
3
+ metadata.gz: 3c6f2c6b595530d2b1c63ad4a4e582c83e2555e04bc26c4d5e5c0e31e879fd34
4
+ data.tar.gz: 4c1700476844577653e7311cf315c1eaeca1c7eb82b1a33cd0dedd646e330311
5
5
  SHA512:
6
- metadata.gz: 742002a691ca1ce8bf656e13be82c495aec57cd6741bf21e3e22293857f83d61a8308904b08a9910e2d76984ba48efedf196d052627d0e8c68300ee0cd474953
7
- data.tar.gz: cf6e1acbb07f3e573f80122cad888e2409599b17f691c54933864ca49106357fa3ab3e8e58b862f5af232c13a5c555cbc3ef2be1067b57415913a6cc7af13f75
6
+ metadata.gz: 1c998192b4f161fb2cb4cd7f87253f94feb9a83e05b1e30e0f70a9e533130e2f0864da68205f9babbac0c8c8b26319606ef3567008ca25499e00b51f40566c0f
7
+ data.tar.gz: 05c8ffa12577ce6980e98750ff4e6050276c6b4cd5d4a7aaef2e2a24c693f470e39263106b0c96b28541fdd6dfe8959a15454553f61dd5a54a6088ff427efb70
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:
@@ -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|
@@ -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.1"
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
@@ -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.1
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-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ransack
@@ -244,6 +244,7 @@ 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
250
  homepage: https://github.com/iakbudak/api_kit