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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +313 -0
- data/lib/api_kit/error_serializer.rb +96 -0
- data/lib/api_kit/errors.rb +75 -0
- data/lib/api_kit/fetching.rb +42 -0
- data/lib/api_kit/filtering.rb +103 -0
- data/lib/api_kit/pagination.rb +168 -0
- data/lib/api_kit/patches.rb +71 -0
- data/lib/api_kit/rails_app.rb +157 -0
- data/lib/api_kit/version.rb +3 -0
- data/lib/api_kit.rb +13 -0
- data/lib/rails_api_kit.rb +1 -0
- data/spec/dummy.rb +201 -0
- data/spec/errors_spec.rb +126 -0
- data/spec/fetching_spec.rb +171 -0
- data/spec/filtering_spec.rb +101 -0
- data/spec/pagination_spec.rb +335 -0
- data/spec/spec_helper.rb +87 -0
- data/spec/support/api_kit_rspec.rb +41 -0
- metadata +272 -0
|
@@ -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
|
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
|