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.
@@ -0,0 +1,168 @@
1
+ module ApiKit
2
+ # Pagination support
3
+ module Pagination
4
+ private
5
+ # Default number of items per page.
6
+ API_PAGE_SIZE = ENV.fetch("PAGINATION_LIMIT") { 30 }
7
+ # Default number of items per page.
8
+ PAGINATION_IGNORE_KEYS = %i[total_count total_page]
9
+
10
+ # Applies pagination to a set of resources
11
+ #
12
+ # Ex.: `GET /resource?page[number]=2&page[size]=10`
13
+ #
14
+ # @return [ActiveRecord::Base] a collection of resources
15
+ def api_paginate(resources, options = {})
16
+ offset, limit, _ = api_pagination_params
17
+
18
+ if resources.respond_to?(:offset)
19
+ resources = resources.offset(offset).limit(limit)
20
+ else
21
+ original_size = resources.size
22
+ resources = resources[(offset)..(offset + limit - 1)] || []
23
+
24
+ # Cache the original resources size to be used for pagination meta
25
+ resources.instance_variable_set(:@original_size, original_size)
26
+ end
27
+
28
+ if options[:total_count]
29
+ resources.instance_variable_set(
30
+ :@_predefined_total_count,
31
+ options[:total_count]
32
+ )
33
+ end
34
+
35
+ block_given? ? yield(resources) : resources
36
+ end
37
+
38
+ # Generates the pagination links
39
+ #
40
+ # @return [Array]
41
+ def api_pagination(resources)
42
+ links = {}
43
+
44
+ pagination = api_pagination_builder(resources)
45
+
46
+ return links if pagination.blank?
47
+
48
+ original_params = params.except(
49
+ *api_path_parameters.keys.map(&:to_s)
50
+ ).as_json.with_indifferent_access
51
+
52
+ original_params[:page] = original_params[:page].dup || {}
53
+ original_url = "?"
54
+
55
+ pagination.each do |page_name, number|
56
+ next if PAGINATION_IGNORE_KEYS.include?(page_name)
57
+
58
+ original_params[:page][:number] = number
59
+ arg_params = original_params
60
+ if page_name == :first || (number && number == 1)
61
+ arg_params = original_params.except(:page)
62
+ end
63
+
64
+ links[page_name] = number.nil? ? nil : (
65
+ original_url + CGI.unescape(arg_params.to_query)
66
+ )
67
+ end
68
+
69
+ links
70
+ end
71
+
72
+ # Generates pagination numbers
73
+ #
74
+ # @return [Hash] with the first, previous, next, current, last page,
75
+ # total_count, total_page numbers
76
+ def api_pagination_builder(resources)
77
+ return @_numbers if @_numbers
78
+ return {} unless ApiKit::RailsApp.is_collection?(resources)
79
+
80
+ _, limit, page = api_pagination_params
81
+
82
+ numbers = {
83
+ current: page,
84
+ first: nil,
85
+ prev: nil,
86
+ next: nil,
87
+ last: nil
88
+ }
89
+
90
+ total = resources.instance_variable_get(:@_predefined_total_count)
91
+ if total
92
+ # do nothing for this condition
93
+ elsif resources.respond_to?(:unscope)
94
+ total = resources.unscope(:limit, :offset, :order).size
95
+ total = total.size if total.is_a?(Hash)
96
+ else
97
+ # Try to fetch the cached size first
98
+ total = resources.instance_variable_get(:@original_size)
99
+ total ||= resources.size
100
+ end
101
+
102
+ last_page = [ 1, (total.to_f / limit).ceil ].max
103
+
104
+ numbers[:first] = 1
105
+ numbers[:last] = last_page
106
+
107
+ if page > 1
108
+ numbers[:prev] = page - 1
109
+ end
110
+
111
+ if page < last_page
112
+ numbers[:next] = page + 1
113
+ end
114
+
115
+ if total.present?
116
+ numbers[:total_count] = total
117
+ numbers[:total_page] = last_page
118
+ end
119
+
120
+ @_numbers = numbers
121
+ end
122
+
123
+ # Extracts the pagination meta
124
+ #
125
+ # @return [Hash] with the first, previous, next, current, last page numbers
126
+ def api_pagination_meta(resources)
127
+ pagination = api_pagination_builder(resources)
128
+ pagination.slice(:total_count, :total_page, :current)
129
+ end
130
+
131
+ # Extracts the pagination params
132
+ #
133
+ # @return [Array] with the offset, limit and the current page number
134
+ def api_pagination_params
135
+ pagination = params[:page].try(:slice, :number, :size) || {}
136
+ per_page = api_page_size(pagination)
137
+ num = [ 1, pagination[:number].to_f.to_i ].max
138
+
139
+ [ (num - 1) * per_page, per_page, num ]
140
+ end
141
+
142
+ # Retrieves the default page size
143
+ #
144
+ # @param per_page_param [Hash] opts the paginations params
145
+ # @option opts [String] :number the page number requested
146
+ # @option opts [String] :size the page size requested
147
+ #
148
+ # @return [Integer]
149
+ def api_page_size(pagination_params)
150
+ per_page = pagination_params[:size].to_f.to_i
151
+
152
+ return self.class
153
+ .const_get(:API_PAGE_SIZE)
154
+ .to_i if per_page < 1
155
+
156
+ per_page
157
+ end
158
+
159
+ # Fallback to Rack's parsed query string when Rails is not available
160
+ #
161
+ # @return [Hash]
162
+ def api_path_parameters
163
+ return request.path_parameters if request.respond_to?(:path_parameters)
164
+
165
+ request.send(:parse_query, request.query_string, "&;")
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,71 @@
1
+ require "ransack"
2
+
3
+ Ransack.configure do |config|
4
+ # Raise errors if a query contains an unknown predicate or attribute.
5
+ # Default is true (do not raise error on unknown conditions).
6
+ config.ignore_unknown_conditions = true
7
+
8
+ # Enable expressions
9
+ # See: https://www.rubydoc.info/github/rails/rails/Arel/Expressions
10
+ config.add_predicate(
11
+ "count", arel_predicate: "count",
12
+ validator: ->(_) { true }, compounds: false
13
+ )
14
+ config.add_predicate(
15
+ "count_distinct", arel_predicate: "count",
16
+ validator: ->(_) { true }, formatter: ->(_) { true }, compounds: false
17
+ )
18
+ config.add_predicate(
19
+ "sum", arel_predicate: "sum",
20
+ validator: ->(v) { true }, compounds: false
21
+ )
22
+ config.add_predicate(
23
+ "avg", arel_predicate: "average",
24
+ validator: ->(v) { true }, compounds: false
25
+ )
26
+ config.add_predicate(
27
+ "min", arel_predicate: "minimum",
28
+ validator: ->(v) { true }, compounds: false
29
+ )
30
+ config.add_predicate(
31
+ "max", arel_predicate: "maximum",
32
+ validator: ->(v) { true }, compounds: false
33
+ )
34
+ end
35
+
36
+ Ransack::Visitor.class_eval do
37
+ alias_method :original_visit_Ransack_Nodes_Sort, :visit_Ransack_Nodes_Sort
38
+
39
+ private
40
+ # Original method assumes sorting is done only by attributes
41
+ def visit_Ransack_Nodes_Sort(node)
42
+ # Try the default sorting visitor method...
43
+ binded = original_visit_Ransack_Nodes_Sort(node)
44
+ valid = (binded.valid? if binded.respond_to?(:valid?)) || true
45
+ return binded if binded.present? && valid
46
+
47
+ # Fallback to support the expressions...
48
+ binded = Ransack::Nodes::Condition.extract(node.context, node.name, nil)
49
+ valid = (binded.valid? if binded.respond_to?(:valid?)) || true
50
+ return unless binded.present? && valid
51
+
52
+ arel_pred = binded.arel_predicate
53
+ # Remove any alias when sorting...
54
+ arel_pred.alias = nil if arel_pred.respond_to?(:alias=)
55
+ arel_pred.public_send(node.dir)
56
+ end
57
+ end
58
+
59
+ Ransack::Nodes::Condition.class_eval do
60
+ alias_method :original_format_predicate, :format_predicate
61
+
62
+ private
63
+ # Original method doesn't respect the arity of expressions
64
+ # See: lib/ransack/adapters/active_record/ransack/nodes/condition.rb#L30-L42
65
+ def format_predicate(attribute)
66
+ original_format_predicate(attribute)
67
+ rescue ArgumentError
68
+ arel_pred = arel_predicate_for_attribute(attribute)
69
+ attribute.attr.public_send(arel_pred)
70
+ end
71
+ end
@@ -0,0 +1,157 @@
1
+ require "ostruct"
2
+
3
+ # Rails integration
4
+ module ApiKit
5
+ module RailsApp
6
+ API_PAGINATE_METHODS_MAPPING = {
7
+ meta: :api_meta,
8
+ links: :api_pagination,
9
+ fields: :api_fields,
10
+ include: :api_include,
11
+ params: :api_serializer_params
12
+ }
13
+
14
+ API_METHODS_MAPPING = {
15
+ meta: :api_meta,
16
+ fields: :api_fields,
17
+ include: :api_include,
18
+ params: :api_serializer_params
19
+ }
20
+
21
+ # Updates the mime types and registers the renderers
22
+ #
23
+ # @return [NilClass]
24
+ def self.install!
25
+ return unless defined?(::Rails)
26
+
27
+ parser = ActionDispatch::Request.parameter_parsers[:json]
28
+ ActionDispatch::Request.parameter_parsers[:api] = parser
29
+
30
+ self.add_renderer!
31
+ self.add_errors_renderer!
32
+ end
33
+
34
+
35
+ # Adds the error renderer
36
+ #
37
+ # @return [NilClass]
38
+ def self.add_errors_renderer!
39
+ ActionController::Renderers.add(:api_errors) do |resource, options|
40
+ self.content_type ||= Mime[:json]
41
+
42
+ many = ApiKit::RailsApp.is_collection?(resource, options[:is_collection])
43
+ resource = [ resource ] unless many
44
+
45
+ ApiKit::ErrorSerializer.new(resource, options).to_json
46
+ end
47
+ end
48
+
49
+ # Adds the default renderer
50
+ #
51
+ # @return [NilClass]
52
+ def self.add_renderer!
53
+ ActionController::Renderers.add(:api_paginate) do |resource, options|
54
+ self.content_type ||= Mime[:json]
55
+
56
+ result = {}
57
+ API_PAGINATE_METHODS_MAPPING.to_a[0..1].each do |opt, method_name|
58
+ next unless respond_to?(method_name, true)
59
+ result[opt] ||= send(method_name, resource)
60
+ end
61
+
62
+ # If it's an empty collection, return it directly.
63
+ many = ApiKit::RailsApp.is_collection?(resource, options[:is_collection])
64
+
65
+ API_PAGINATE_METHODS_MAPPING.to_a[2..-1].each do |opt, method_name|
66
+ options[opt] ||= send(method_name) if respond_to?(method_name, true)
67
+ end
68
+
69
+ if options[:serializer_class]
70
+ serializer_class = options[:serializer_class]
71
+ else
72
+ serializer_class = ApiKit::RailsApp.serializer_class(resource, many)
73
+ end
74
+
75
+ options[:fields] = api_fields(serializer_class, ApiKit::RailsApp.fetch_name(many, resource))
76
+ options[:adapter] = :attributes
77
+ options[:each_serializer] = serializer_class
78
+ data = ActiveModelSerializers::SerializableResource.new(resource, options).as_json
79
+ result[:data] = data
80
+ result.to_json
81
+ end
82
+
83
+ ActionController::Renderers.add(:api) do |resource, options|
84
+ self.content_type ||= Mime[:json]
85
+
86
+ result = {}
87
+ API_METHODS_MAPPING.to_a[0..0].each do |opt, method_name|
88
+ next unless respond_to?(method_name, true)
89
+ result[opt] ||= send(method_name, resource)
90
+ end
91
+
92
+ # If it's an empty collection, return it directly.
93
+ many = ApiKit::RailsApp.is_collection?(resource, options[:is_collection])
94
+
95
+ API_METHODS_MAPPING.to_a[1..-1].each do |opt, method_name|
96
+ options[opt] ||= send(method_name) if respond_to?(method_name, true)
97
+ end
98
+
99
+ if options[:serializer_class]
100
+ serializer_class = options[:serializer_class]
101
+ else
102
+ serializer_class = ApiKit::RailsApp.serializer_class(resource, many)
103
+ end
104
+
105
+ # Use Active Model Serializers properly with fallback
106
+ options[:fields] = api_fields(serializer_class, ApiKit::RailsApp.fetch_name(many, resource))
107
+ options[:adapter] = :attributes
108
+ options[:each_serializer] = serializer_class
109
+ if many
110
+ data = ActiveModelSerializers::SerializableResource.new(resource, options).as_json
111
+ else
112
+ data = ActiveModelSerializers::SerializableResource.new([ resource ], options).as_json[0]
113
+ end
114
+ result[:data] = data
115
+ result.to_json
116
+ end
117
+ end
118
+
119
+ # Checks if an object is a collection
120
+ #
121
+ # @param resource [Object] to check
122
+ # @param force_is_collection [NilClass] flag to overwrite
123
+ # @return [TrueClass] upon success
124
+ def self.is_collection?(resource, force_is_collection = nil)
125
+ return force_is_collection unless force_is_collection.nil?
126
+
127
+ resource.respond_to?(:size) && !resource.respond_to?(:each_pair)
128
+ end
129
+
130
+ # Resolves resource serializer class
131
+ #
132
+ # @return [Class]
133
+ def self.serializer_class(resource, is_collection)
134
+ klass = resource.class
135
+ klass = resource.first.class if is_collection
136
+
137
+ "#{klass.name}Serializer".constantize
138
+ end
139
+
140
+ # Resolves the singular model name for sparse fieldsets
141
+ #
142
+ # @param many [Boolean] indicates whether the resource is a collection
143
+ # @param resource [Object] serialized resource or collection
144
+ # @return [String, nil] singular model name when available
145
+ 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
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,3 @@
1
+ module ApiKit
2
+ VERSION = "1.0.0"
3
+ end
data/lib/api_kit.rb ADDED
@@ -0,0 +1,13 @@
1
+ require "api_kit/errors"
2
+ require "api_kit/error_serializer"
3
+ require "api_kit/fetching"
4
+ require "api_kit/filtering"
5
+ require "api_kit/pagination"
6
+ require "api_kit/rails_app"
7
+ require "api_kit/version"
8
+
9
+ # ApiKit
10
+ module ApiKit
11
+ # ApiKit media type.
12
+ MEDIA_TYPE = "application/json".freeze
13
+ end
@@ -0,0 +1 @@
1
+ require "api_kit"
data/spec/dummy.rb ADDED
@@ -0,0 +1,201 @@
1
+ require 'securerandom'
2
+ require 'active_record'
3
+ require 'action_controller/railtie'
4
+ require 'api_kit'
5
+ require 'ransack'
6
+ require 'active_model_serializers'
7
+
8
+ Rails.logger = Logger.new(STDOUT)
9
+ Rails.logger.level = ENV['LOG_LEVEL'] || Logger::WARN
10
+
11
+ ApiKit::RailsApp.install!
12
+
13
+ ActiveRecord::Base.logger = Rails.logger
14
+ ActiveRecord::Base.establish_connection(
15
+ ENV['DATABASE_URL'] || 'sqlite3::memory:'
16
+ )
17
+
18
+ ActiveRecord::Schema.define do
19
+ create_table :users, force: true do |t|
20
+ t.string :first_name
21
+ t.string :last_name
22
+ t.timestamps
23
+ end
24
+
25
+ create_table :notes, force: true do |t|
26
+ t.string :title
27
+ t.integer :user_id
28
+ t.integer :quantity
29
+ t.timestamps
30
+ end
31
+ end
32
+
33
+
34
+ class ApplicationRecord < ActiveRecord::Base
35
+ primary_abstract_class
36
+
37
+ def self.ransackable_associations(auth_object = nil)
38
+ @ransackable_associations ||= reflect_on_all_associations.map { |a| a.name.to_s }
39
+ end
40
+
41
+ def self.ransackable_attributes(auth_object = nil)
42
+ @ransackable_attributes ||= column_names + _ransackers.keys + _ransack_aliases.keys + attribute_aliases.keys
43
+ end
44
+ end
45
+
46
+ class User < ApplicationRecord
47
+ has_many :notes
48
+ end
49
+
50
+ class Note < ApplicationRecord
51
+ validates_format_of :title, without: /BAD_TITLE/
52
+ validates_numericality_of :quantity, less_than: 100, if: :quantity?
53
+ belongs_to :user, required: true
54
+ end
55
+
56
+ class RuntimeCustomNote
57
+ include ActiveModel::Model
58
+ include ActiveModel::Serialization
59
+
60
+ ATTRIBUTES = %w[id title quantity created_at updated_at user].freeze
61
+
62
+ attr_accessor(*ATTRIBUTES.map(&:to_sym))
63
+
64
+ def attributes
65
+ ATTRIBUTES.index_with { nil }
66
+ end
67
+ end
68
+
69
+ class CustomNoteSerializer < ActiveModel::Serializer
70
+ attributes :id, :title, :quantity, :created_at, :updated_at
71
+ belongs_to :user
72
+ end
73
+
74
+ class UserSerializer < ActiveModel::Serializer
75
+ attributes :id, :last_name, :created_at, :updated_at, :first_name
76
+ has_many :notes, serializer: CustomNoteSerializer
77
+ belongs_to :custom_note, serializer: CustomNoteSerializer do
78
+ RuntimeCustomNote.new(
79
+ id: 1,
80
+ title: "My",
81
+ quantity: 1,
82
+ created_at: Time.current,
83
+ updated_at: Time.current,
84
+ user: nil
85
+ )
86
+ end
87
+
88
+ def first_name
89
+ if @instance_options.dig(:params, :first_name_upcase)
90
+ object.first_name.upcase
91
+ else
92
+ object.first_name
93
+ end
94
+ end
95
+ end
96
+
97
+ class MyUserSerializer < UserSerializer
98
+ attribute :full_name
99
+
100
+ def full_name
101
+ "#{object.first_name} #{object.last_name}"
102
+ end
103
+ end
104
+
105
+ class Dummy < Rails::Application
106
+ # secrets.secret_key_base = '_'
107
+ config.hosts << 'www.example.com' if config.respond_to?(:hosts)
108
+
109
+ routes.draw do
110
+ scope defaults: { format: :api } do
111
+ resources :users, only: [ :index, :show ]
112
+ resources :notes, only: [ :update ]
113
+ end
114
+ end
115
+ end
116
+
117
+ class BaseApplicationController < ActionController::Base
118
+ def serialize_array(data, options = {})
119
+ options[:adapter] = :attributes
120
+ ActiveModelSerializers::SerializableResource.new(data, options).as_json
121
+ end
122
+ end
123
+
124
+ class UsersController < BaseApplicationController
125
+ include ApiKit::Fetching
126
+ include ApiKit::Filtering
127
+ include ApiKit::Pagination
128
+
129
+ def index
130
+ allowed_fields = [
131
+ :first_name, :last_name, :created_at,
132
+ :notes_created_at, :notes_quantity
133
+ ]
134
+ options = { sort_with_expressions: true }
135
+
136
+ api_filter(User.all, allowed_fields, options) do |filtered|
137
+ result = filtered.result
138
+
139
+ if params[:sort].to_s.include?('notes_quantity')
140
+ render api_paginate: result.group('id').to_a
141
+ return
142
+ end
143
+
144
+ result = result.to_a if params[:as_list]
145
+
146
+ api_paginate(result) do |paginated|
147
+ render api_paginate: paginated,
148
+ serializer_class: MyUserSerializer
149
+ end
150
+ end
151
+ end
152
+
153
+ def show
154
+ render api: User.find(params[:id]),
155
+ serializer_class: MyUserSerializer
156
+ end
157
+
158
+ private
159
+ def api_meta(resources)
160
+ {
161
+ many: true,
162
+ pagination: api_pagination_meta(resources)
163
+ }
164
+ end
165
+
166
+ def api_serializer_params
167
+ {
168
+ first_name_upcase: params[:upcase]
169
+ }
170
+ end
171
+ end
172
+
173
+ class NotesController < ActionController::Base
174
+ include ApiKit::Fetching
175
+ include ApiKit::Errors
176
+
177
+ def update
178
+ raise StandardError.new("tada") if params[:id] == 'tada'
179
+
180
+ note = Note.find(params[:id])
181
+
182
+ if note.update(note_params)
183
+ render api: note,
184
+ serializer_class: CustomNoteSerializer
185
+ else
186
+ note.errors.add(:title, message: 'has typos') if note.errors.key?(:title)
187
+
188
+ render api_errors: note.errors, status: :unprocessable_content
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ def note_params
195
+ params.require(:note).permit(:title, :user_id, :quantity, :created_at)
196
+ end
197
+
198
+ def api_meta(resources)
199
+ { single: true }
200
+ end
201
+ end