jsonapi_compliable 0.11.34 → 1.0.alpha.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 +5 -5
- data/.ruby-version +1 -1
- data/.travis.yml +1 -2
- data/Rakefile +7 -3
- data/jsonapi_compliable.gemspec +7 -3
- data/lib/generators/jsonapi/resource_generator.rb +8 -79
- data/lib/generators/jsonapi/templates/application_resource.rb.erb +2 -1
- data/lib/generators/jsonapi/templates/controller.rb.erb +19 -64
- data/lib/generators/jsonapi/templates/resource.rb.erb +5 -47
- data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
- data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
- data/lib/jsonapi_compliable.rb +87 -18
- data/lib/jsonapi_compliable/adapters/abstract.rb +202 -45
- data/lib/jsonapi_compliable/adapters/active_record.rb +6 -130
- data/lib/jsonapi_compliable/adapters/active_record/base.rb +247 -0
- data/lib/jsonapi_compliable/adapters/active_record/belongs_to_sideload.rb +17 -0
- data/lib/jsonapi_compliable/adapters/active_record/has_many_sideload.rb +17 -0
- data/lib/jsonapi_compliable/adapters/active_record/has_one_sideload.rb +17 -0
- data/lib/jsonapi_compliable/adapters/active_record/inferrence.rb +12 -0
- data/lib/jsonapi_compliable/adapters/active_record/many_to_many_sideload.rb +30 -0
- data/lib/jsonapi_compliable/adapters/null.rb +177 -6
- data/lib/jsonapi_compliable/base.rb +33 -320
- data/lib/jsonapi_compliable/context.rb +16 -0
- data/lib/jsonapi_compliable/deserializer.rb +14 -39
- data/lib/jsonapi_compliable/errors.rb +227 -24
- data/lib/jsonapi_compliable/extensions/extra_attribute.rb +3 -1
- data/lib/jsonapi_compliable/filter_operators.rb +25 -0
- data/lib/jsonapi_compliable/hash_renderer.rb +57 -0
- data/lib/jsonapi_compliable/query.rb +190 -202
- data/lib/jsonapi_compliable/rails.rb +12 -6
- data/lib/jsonapi_compliable/railtie.rb +64 -0
- data/lib/jsonapi_compliable/renderer.rb +60 -0
- data/lib/jsonapi_compliable/resource.rb +35 -663
- data/lib/jsonapi_compliable/resource/configuration.rb +239 -0
- data/lib/jsonapi_compliable/resource/dsl.rb +138 -0
- data/lib/jsonapi_compliable/resource/interface.rb +32 -0
- data/lib/jsonapi_compliable/resource/polymorphism.rb +68 -0
- data/lib/jsonapi_compliable/resource/sideloading.rb +102 -0
- data/lib/jsonapi_compliable/resource_proxy.rb +127 -0
- data/lib/jsonapi_compliable/responders.rb +19 -0
- data/lib/jsonapi_compliable/runner.rb +25 -0
- data/lib/jsonapi_compliable/scope.rb +37 -79
- data/lib/jsonapi_compliable/scoping/extra_attributes.rb +29 -0
- data/lib/jsonapi_compliable/scoping/filter.rb +39 -58
- data/lib/jsonapi_compliable/scoping/filterable.rb +9 -14
- data/lib/jsonapi_compliable/scoping/paginate.rb +9 -3
- data/lib/jsonapi_compliable/scoping/sort.rb +16 -4
- data/lib/jsonapi_compliable/sideload.rb +221 -347
- data/lib/jsonapi_compliable/sideload/belongs_to.rb +34 -0
- data/lib/jsonapi_compliable/sideload/has_many.rb +16 -0
- data/lib/jsonapi_compliable/sideload/has_one.rb +9 -0
- data/lib/jsonapi_compliable/sideload/many_to_many.rb +24 -0
- data/lib/jsonapi_compliable/sideload/polymorphic_belongs_to.rb +108 -0
- data/lib/jsonapi_compliable/stats/payload.rb +4 -8
- data/lib/jsonapi_compliable/types.rb +172 -0
- data/lib/jsonapi_compliable/util/attribute_check.rb +88 -0
- data/lib/jsonapi_compliable/util/persistence.rb +29 -7
- data/lib/jsonapi_compliable/util/relationship_payload.rb +4 -4
- data/lib/jsonapi_compliable/util/render_options.rb +4 -32
- data/lib/jsonapi_compliable/util/serializer_attributes.rb +98 -0
- data/lib/jsonapi_compliable/util/validation_response.rb +15 -9
- data/lib/jsonapi_compliable/version.rb +1 -1
- metadata +105 -24
- data/lib/generators/jsonapi/field_generator.rb +0 -0
- data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +0 -29
- data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +0 -20
- data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +0 -22
- data/lib/generators/jsonapi/templates/payload.rb.erb +0 -39
- data/lib/generators/jsonapi/templates/serializer.rb.erb +0 -25
- data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +0 -19
- data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +0 -33
- data/lib/jsonapi_compliable/adapters/active_record_sideloading.rb +0 -152
- data/lib/jsonapi_compliable/scoping/extra_fields.rb +0 -58
@@ -1,22 +1,28 @@
|
|
1
|
-
require 'jsonapi/rails'
|
2
|
-
|
3
1
|
module JsonapiCompliable
|
4
2
|
# Rails Integration. Mix this in to ApplicationController.
|
5
3
|
#
|
6
4
|
# * Mixes in Base
|
7
5
|
# * Adds a global around_action (see Base#wrap_context)
|
8
|
-
# * Uses Rails' +render+ for rendering
|
9
6
|
#
|
10
7
|
# @see Base#render_jsonapi
|
11
8
|
# @see Base#wrap_context
|
12
9
|
module Rails
|
13
10
|
def self.included(klass)
|
14
|
-
klass.send(:include, Base)
|
15
|
-
|
16
11
|
klass.class_eval do
|
12
|
+
include JsonapiCompliable::Context
|
13
|
+
include JsonapiErrorable
|
17
14
|
around_action :wrap_context
|
18
|
-
alias_method :perform_render_jsonapi, :render
|
19
15
|
end
|
20
16
|
end
|
17
|
+
|
18
|
+
def wrap_context
|
19
|
+
JsonapiCompliable.with_context(jsonapi_context, action_name.to_sym) do
|
20
|
+
yield
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def jsonapi_context
|
25
|
+
self
|
26
|
+
end
|
21
27
|
end
|
22
28
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module JsonapiCompliable
|
2
|
+
class Railtie < ::Rails::Railtie
|
3
|
+
|
4
|
+
initializer "jsonapi_compliable.require_activerecord_adapter" do
|
5
|
+
config.after_initialize do |app|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
7
|
+
require 'jsonapi_compliable/adapters/active_record'
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
initializer 'jsonapi_compliable.init' do
|
13
|
+
if Mime[:jsonapi].nil? # rails 4
|
14
|
+
Mime::Type.register('application/vnd.api+json', :jsonapi)
|
15
|
+
end
|
16
|
+
register_parameter_parser
|
17
|
+
register_renderers
|
18
|
+
end
|
19
|
+
|
20
|
+
# from jsonapi-rails
|
21
|
+
PARSER = lambda do |body|
|
22
|
+
data = JSON.parse(body)
|
23
|
+
hash = { _jsonapi: data }
|
24
|
+
|
25
|
+
hash[:format] = :jsonapi
|
26
|
+
hash.with_indifferent_access
|
27
|
+
end
|
28
|
+
|
29
|
+
def register_parameter_parser
|
30
|
+
if ::Rails::VERSION::MAJOR >= 5
|
31
|
+
ActionDispatch::Request.parameter_parsers[:jsonapi] = PARSER
|
32
|
+
else
|
33
|
+
ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime[:jsonapi]] = PARSER
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def register_renderers
|
38
|
+
ActiveSupport.on_load(:action_controller) do
|
39
|
+
::ActionController::Renderers.add(:jsonapi) do |proxy, options|
|
40
|
+
self.content_type ||= Mime[:jsonapi]
|
41
|
+
|
42
|
+
opts = {}
|
43
|
+
if respond_to?(:default_jsonapi_render_options)
|
44
|
+
opts = default_jsonapi_render_options
|
45
|
+
end
|
46
|
+
proxy.to_jsonapi(options)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
ActiveSupport.on_load(:action_controller) do
|
51
|
+
::ActionController::Renderers.add(:jsonapi_errors) do |proxy, options|
|
52
|
+
self.content_type ||= Mime[:jsonapi]
|
53
|
+
|
54
|
+
validation = JsonapiErrorable::Serializers::Validation.new \
|
55
|
+
proxy.data, proxy.payload.relationships
|
56
|
+
|
57
|
+
render \
|
58
|
+
json: { errors: validation.errors },
|
59
|
+
status: :unprocessable_entity
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module JsonapiCompliable
|
2
|
+
class Renderer
|
3
|
+
CONTENT_TYPE = 'application/vnd.api+json'
|
4
|
+
|
5
|
+
attr_reader :proxy, :options
|
6
|
+
|
7
|
+
def initialize(proxy, options)
|
8
|
+
@proxy = proxy
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
def records
|
13
|
+
@records ||= @proxy.data
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_jsonapi
|
17
|
+
render(JSONAPI::Renderer.new).to_json
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_json
|
21
|
+
render(JsonapiCompliable::HashRenderer.new(@proxy.resource)).to_json
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_xml
|
25
|
+
render(JsonapiCompliable::HashRenderer.new(@proxy.resource)).to_xml(root: :data)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def render(implementation)
|
31
|
+
notify do
|
32
|
+
instance = JSONAPI::Serializable::Renderer.new(implementation)
|
33
|
+
options[:fields] = proxy.fields
|
34
|
+
options[:expose] ||= {}
|
35
|
+
options[:expose][:extra_fields] = proxy.extra_fields
|
36
|
+
options[:include] = proxy.include_hash
|
37
|
+
options[:meta] ||= {}
|
38
|
+
options[:meta].merge!(stats: proxy.stats) unless proxy.stats.empty?
|
39
|
+
instance.render(records, options)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# TODO: more generic notification pattern
|
44
|
+
# Likely comes out of debugger work
|
45
|
+
def notify
|
46
|
+
if defined?(ActiveSupport::Notifications)
|
47
|
+
opts = [
|
48
|
+
'render.jsonapi-compliable',
|
49
|
+
records: records,
|
50
|
+
options: options
|
51
|
+
]
|
52
|
+
ActiveSupport::Notifications.instrument(*opts) do
|
53
|
+
yield
|
54
|
+
end
|
55
|
+
else
|
56
|
+
yield
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -1,728 +1,100 @@
|
|
1
1
|
module JsonapiCompliable
|
2
|
-
# Resources hold configuration: How do you want to process incoming JSONAPI
|
3
|
-
# requests?
|
4
|
-
#
|
5
|
-
# Let's say we start with an empty hash as our scope object:
|
6
|
-
#
|
7
|
-
# render_jsonapi({})
|
8
|
-
#
|
9
|
-
# Let's define the behavior of various parameters. Here we'll merge
|
10
|
-
# options into our hash when the user filters, sorts, and paginates.
|
11
|
-
# Then, we'll pass that hash off to an HTTP Client:
|
12
|
-
#
|
13
|
-
# class PostResource < ApplicationResource
|
14
|
-
# type :posts
|
15
|
-
# use_adapter JsonapiCompliable::Adapters::Null
|
16
|
-
#
|
17
|
-
# # What do do when filter[active] parameter comes in
|
18
|
-
# allow_filter :active do |scope, value|
|
19
|
-
# scope.merge(active: value)
|
20
|
-
# end
|
21
|
-
#
|
22
|
-
# # What do do when sorting parameters come in
|
23
|
-
# sort do |scope, attribute, direction|
|
24
|
-
# scope.merge(order: { attribute => direction })
|
25
|
-
# end
|
26
|
-
#
|
27
|
-
# # What do do when pagination parameters come in
|
28
|
-
# page do |scope, current_page, per_page|
|
29
|
-
# scope.merge(page: current_page, per_page: per_page)
|
30
|
-
# end
|
31
|
-
#
|
32
|
-
# # Resolve the scope by passing the hash to an HTTP Client
|
33
|
-
# def resolve(scope)
|
34
|
-
# MyHttpClient.get(scope)
|
35
|
-
# end
|
36
|
-
# end
|
37
|
-
#
|
38
|
-
# This code can quickly become duplicative - we probably want to reuse
|
39
|
-
# this logic for other objects that use the same HTTP client.
|
40
|
-
#
|
41
|
-
# That's why we also have *Adapters*. Adapters encapsulate common, reusable
|
42
|
-
# resource configuration. That's why we don't need to specify the above code
|
43
|
-
# when using +ActiveRecord+ - the default logic is already in the adapter.
|
44
|
-
#
|
45
|
-
# class PostResource < ApplicationResource
|
46
|
-
# type :posts
|
47
|
-
# use_adapter JsonapiCompliable::Adapters::ActiveRecord
|
48
|
-
#
|
49
|
-
# allow_filter :title
|
50
|
-
# end
|
51
|
-
#
|
52
|
-
# Of course, we can always override the Resource directly for one-off
|
53
|
-
# customizations:
|
54
|
-
#
|
55
|
-
# class PostResource < ApplicationResource
|
56
|
-
# type :posts
|
57
|
-
# use_adapter JsonapiCompliable::Adapters::ActiveRecord
|
58
|
-
#
|
59
|
-
# allow_filter :title_prefix do |scope, value|
|
60
|
-
# scope.where(["title LIKE ?", "#{value}%"])
|
61
|
-
# end
|
62
|
-
# end
|
63
|
-
#
|
64
|
-
# Resources can also define *Sideloads*. Sideloads define the relationships between resources:
|
65
|
-
#
|
66
|
-
# allow_sideload :comments, resource: CommentResource do
|
67
|
-
# # How to fetch the associated objects
|
68
|
-
# # This will be further chained down the line
|
69
|
-
# scope do |posts|
|
70
|
-
# Comment.where(post_id: posts.map(&:id))
|
71
|
-
# end
|
72
|
-
#
|
73
|
-
# # Now that we've resolved everything, how to assign the objects
|
74
|
-
# assign do |posts, comments|
|
75
|
-
# posts.each do |post|
|
76
|
-
# relevant_comments = comments.select { |c| c.post_id === post.id }
|
77
|
-
# post.comments = relevant_comments
|
78
|
-
# end
|
79
|
-
# end
|
80
|
-
# end
|
81
|
-
#
|
82
|
-
# Once again, we can DRY this up using an Adapter:
|
83
|
-
#
|
84
|
-
# use_adapter JsonapiCompliable::Adapters::ActiveRecord
|
85
|
-
#
|
86
|
-
# has_many :comments,
|
87
|
-
# scope: -> { Comment.all },
|
88
|
-
# resource: CommentResource,
|
89
|
-
# foreign_key: :post_id
|
90
2
|
class Resource
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
extend Forwardable
|
96
|
-
attr_accessor :config
|
97
|
-
|
98
|
-
# @!method allow_sideload
|
99
|
-
# @see Sideload#allow_sideload
|
100
|
-
def_delegator :sideloading, :allow_sideload
|
101
|
-
# @!method has_many
|
102
|
-
# @see Adapters::ActiveRecordSideloading#has_many
|
103
|
-
def_delegator :sideloading, :has_many
|
104
|
-
# @!method has_one
|
105
|
-
# @see Adapters::ActiveRecordSideloading#has_one
|
106
|
-
def_delegator :sideloading, :has_one
|
107
|
-
# @!method belongs_to
|
108
|
-
# @see Adapters::ActiveRecordSideloading#belongs_to
|
109
|
-
def_delegator :sideloading, :belongs_to
|
110
|
-
# @!method has_and_belongs_to_many
|
111
|
-
# @see Adapters::ActiveRecordSideloading#has_and_belongs_to_many
|
112
|
-
def_delegator :sideloading, :has_and_belongs_to_many
|
113
|
-
# @!method polymorphic_belongs_to
|
114
|
-
# @see Adapters::ActiveRecordSideloading#polymorphic_belongs_to
|
115
|
-
def_delegator :sideloading, :polymorphic_belongs_to
|
116
|
-
# @!method polymorphic_has_many
|
117
|
-
# @see Adapters::ActiveRecordSideloading#polymorphic_has_many
|
118
|
-
def_delegator :sideloading, :polymorphic_has_many
|
119
|
-
end
|
120
|
-
|
121
|
-
# @!method sideload
|
122
|
-
# @see Sideload#sideload
|
123
|
-
def_delegator :sideloading, :sideload
|
124
|
-
|
125
|
-
# @private
|
126
|
-
def self.inherited(klass)
|
127
|
-
klass.config = Util::Hash.deep_dup(self.config)
|
128
|
-
end
|
129
|
-
|
130
|
-
# @api private
|
131
|
-
def self.sideloading
|
132
|
-
@sideloading ||= Sideload.new(:base, resource: self)
|
133
|
-
end
|
134
|
-
|
135
|
-
# Whitelist a filter
|
136
|
-
#
|
137
|
-
# @example Basic Filtering
|
138
|
-
# allow_filter :title
|
139
|
-
#
|
140
|
-
# # When using ActiveRecord, this code is equivalent
|
141
|
-
# allow_filter :title do |scope, value|
|
142
|
-
# scope.where(title: value)
|
143
|
-
# end
|
144
|
-
#
|
145
|
-
# @example Custom Filtering
|
146
|
-
# # All filters can be customized with a block
|
147
|
-
# allow_filter :title_prefix do |scope, value|
|
148
|
-
# scope.where('title LIKE ?', "#{value}%")
|
149
|
-
# end
|
150
|
-
#
|
151
|
-
# @example Guarding Filters
|
152
|
-
# # Only allow the current user to filter on a property
|
153
|
-
# allow_filter :title, if: :admin?
|
154
|
-
#
|
155
|
-
# def admin?
|
156
|
-
# current_user.role == 'admin'
|
157
|
-
# end
|
158
|
-
#
|
159
|
-
# If a filter is not allowed, a +Jsonapi::Errors::BadFilter+ error will be raised.
|
160
|
-
#
|
161
|
-
# @overload allow_filter(name, options = {})
|
162
|
-
# @param [Symbol] name The name of the filter
|
163
|
-
# @param [Hash] options
|
164
|
-
# @option options [Symbol] :if A method name on the current context - If the method returns false, +BadFilter+ will be raised.
|
165
|
-
# @option options [Array<Symbol>] :aliases Allow the user to specify these aliases in the URL, then match to this filter. Mainly used for backwards-compatibility.
|
166
|
-
#
|
167
|
-
# @yieldparam scope The object being scoped
|
168
|
-
# @yieldparam value The sanitized value from the URL
|
169
|
-
def self.allow_filter(name, *args, &blk)
|
170
|
-
opts = args.extract_options!
|
171
|
-
aliases = [name, opts[:aliases]].flatten.compact
|
172
|
-
config[:filters][name.to_sym] = {
|
173
|
-
aliases: aliases,
|
174
|
-
if: opts[:if],
|
175
|
-
filter: blk,
|
176
|
-
required: opts[:required].respond_to?(:call) ? opts[:required] : !!opts[:required]
|
177
|
-
}
|
178
|
-
end
|
3
|
+
include DSL
|
4
|
+
include Interface
|
5
|
+
include Configuration
|
6
|
+
include Sideloading
|
179
7
|
|
180
|
-
|
181
|
-
#
|
182
|
-
# Statistics are requested like
|
183
|
-
#
|
184
|
-
# GET /posts?stats[total]=count
|
185
|
-
#
|
186
|
-
# And returned in +meta+:
|
187
|
-
#
|
188
|
-
# {
|
189
|
-
# data: [...],
|
190
|
-
# meta: { stats: { total: { count: 100 } } }
|
191
|
-
# }
|
192
|
-
#
|
193
|
-
# Statistics take into account the current scope, *without pagination*.
|
194
|
-
#
|
195
|
-
# @example Total Count
|
196
|
-
# allow_stat total: [:count]
|
197
|
-
#
|
198
|
-
# @example Average Rating
|
199
|
-
# allow_stat rating: [:average]
|
200
|
-
#
|
201
|
-
# @example Custom Stat
|
202
|
-
# allow_stat rating: [:average] do
|
203
|
-
# standard_deviation { |scope, attr| ... }
|
204
|
-
# end
|
205
|
-
#
|
206
|
-
# @param [Symbol, Hash] symbol_or_hash The attribute and metric
|
207
|
-
# @yieldparam scope The object being scoped
|
208
|
-
# @yieldparam [Symbol] attr The name of the metric
|
209
|
-
def self.allow_stat(symbol_or_hash, &blk)
|
210
|
-
dsl = Stats::DSL.new(config[:adapter], symbol_or_hash)
|
211
|
-
dsl.instance_eval(&blk) if blk
|
212
|
-
config[:stats][dsl.name] = dsl
|
213
|
-
end
|
214
|
-
|
215
|
-
# When you want a filter to always apply, on every request.
|
216
|
-
#
|
217
|
-
# @example Only Active Posts
|
218
|
-
# default_filter :active do |scope|
|
219
|
-
# scope.where(active: true)
|
220
|
-
# end
|
221
|
-
#
|
222
|
-
# Default filters can be overridden *if* there is a corresponding +allow_filter+:
|
223
|
-
#
|
224
|
-
# @example Overriding Default Filters
|
225
|
-
# allow_filter :active
|
226
|
-
#
|
227
|
-
# default_filter :active do |scope|
|
228
|
-
# scope.where(active: true)
|
229
|
-
# end
|
230
|
-
#
|
231
|
-
# # GET /posts?filter[active]=false
|
232
|
-
# # Returns only active posts
|
233
|
-
#
|
234
|
-
# @see .allow_filter
|
235
|
-
# @param [Symbol] name The default filter name
|
236
|
-
# @yieldparam scope The object being scoped
|
237
|
-
def self.default_filter(name, &blk)
|
238
|
-
config[:default_filters][name.to_sym] = {
|
239
|
-
filter: blk
|
240
|
-
}
|
241
|
-
end
|
242
|
-
|
243
|
-
# The Model object associated with this class.
|
244
|
-
#
|
245
|
-
# This model will be utilized on write requests.
|
246
|
-
#
|
247
|
-
# Models need not be ActiveRecord ;)
|
248
|
-
#
|
249
|
-
# @example
|
250
|
-
# class PostResource < ApplicationResource
|
251
|
-
# # ... code ...
|
252
|
-
# model Post
|
253
|
-
# end
|
254
|
-
#
|
255
|
-
# @param [Class] klass The associated Model class
|
256
|
-
def self.model(klass)
|
257
|
-
config[:model] = klass
|
258
|
-
end
|
259
|
-
|
260
|
-
# Register a hook that fires AFTER all validation logic has run -
|
261
|
-
# including validation of nested objects - but BEFORE the transaction
|
262
|
-
# has closed.
|
263
|
-
#
|
264
|
-
# Helpful for things like "contact this external service after persisting
|
265
|
-
# data, but roll everything back if there's an error making the service call"
|
266
|
-
#
|
267
|
-
# @param [Hash] +only: [:create, :update, :destroy]+
|
268
|
-
def self.before_commit(only: [:create, :update, :destroy], &blk)
|
269
|
-
Array(only).each do |verb|
|
270
|
-
config[:before_commit][verb] = blk
|
271
|
-
end
|
272
|
-
end
|
273
|
-
|
274
|
-
# Actually fire the before commit hooks
|
275
|
-
#
|
276
|
-
# @see .before_commit
|
277
|
-
# @api private
|
278
|
-
def before_commit(model, method)
|
279
|
-
hook = self.class.config[:before_commit][method]
|
280
|
-
hook.call(model) if hook
|
281
|
-
end
|
282
|
-
|
283
|
-
# Define custom sorting logic
|
284
|
-
#
|
285
|
-
# @example Sort on alternate table
|
286
|
-
# # GET /employees?sort=title
|
287
|
-
# sort do |scope, att, dir|
|
288
|
-
# if att == :title
|
289
|
-
# scope.joins(:current_position).order("title #{dir}")
|
290
|
-
# else
|
291
|
-
# scope.order(att => dir)
|
292
|
-
# end
|
293
|
-
# end
|
294
|
-
#
|
295
|
-
# @yieldparam scope The current object being scoped
|
296
|
-
# @yieldparam [Symbol] att The requested sort attribute
|
297
|
-
# @yieldparam [Symbol] dir The requested sort direction (:asc/:desc)
|
298
|
-
def self.sort(&blk)
|
299
|
-
config[:sorting] = blk
|
300
|
-
end
|
301
|
-
|
302
|
-
# Define custom pagination logic
|
303
|
-
#
|
304
|
-
# @example Use will_paginate instead of Kaminari
|
305
|
-
# # GET /employees?page[size]=10&page[number]=2
|
306
|
-
# paginate do |scope, current_page, per_page|
|
307
|
-
# scope.paginate(page: current_page, per_page: per_page)
|
308
|
-
# end
|
309
|
-
#
|
310
|
-
# @yieldparam scope The current object being scoped
|
311
|
-
# @yieldparam [Integer] current_page The page[number] parameter value
|
312
|
-
# @yieldparam [Integer] per_page The page[size] parameter value
|
313
|
-
def self.paginate(&blk)
|
314
|
-
config[:pagination] = blk
|
315
|
-
end
|
316
|
-
|
317
|
-
# Perform special logic when an extra field is requested.
|
318
|
-
# Often used to eager load data that will be used to compute the
|
319
|
-
# extra field.
|
320
|
-
#
|
321
|
-
# This is *not* required if you have no custom logic.
|
322
|
-
#
|
323
|
-
# @example Eager load if extra field is required
|
324
|
-
# # GET /employees?extra_fields[employees]=net_worth
|
325
|
-
# extra_field(employees: [:net_worth]) do |scope|
|
326
|
-
# scope.includes(:assets)
|
327
|
-
# end
|
328
|
-
#
|
329
|
-
# @see Scoping::ExtraFields
|
330
|
-
#
|
331
|
-
# @param [Symbol] name Name of the extra field
|
332
|
-
# @yieldparam scope The current object being scoped
|
333
|
-
# @yieldparam [Integer] current_page The page[number] parameter value
|
334
|
-
# @yieldparam [Integer] per_page The page[size] parameter value
|
335
|
-
def self.extra_field(name, &blk)
|
336
|
-
config[:extra_fields][name] = blk
|
337
|
-
end
|
338
|
-
|
339
|
-
# Configure the adapter you want to use.
|
340
|
-
#
|
341
|
-
# @example ActiveRecord Adapter
|
342
|
-
# require 'jsonapi_compliable/adapters/active_record'
|
343
|
-
# use_adapter JsonapiCompliable::Adapters::ActiveRecord
|
344
|
-
#
|
345
|
-
# @param [Class] klass The adapter class
|
346
|
-
def self.use_adapter(klass)
|
347
|
-
config[:adapter] = klass.new
|
348
|
-
end
|
349
|
-
|
350
|
-
# Override default sort applied when not present in the query parameters.
|
351
|
-
#
|
352
|
-
# Default: [{ id: :asc }]
|
353
|
-
#
|
354
|
-
# @example Order by created_at descending by default
|
355
|
-
# # GET /employees will order by created_at descending
|
356
|
-
# default_sort([{ created_at: :desc }])
|
357
|
-
#
|
358
|
-
# @param [Array<Hash>] val Array of sorting criteria
|
359
|
-
def self.default_sort(val)
|
360
|
-
config[:default_sort] = val
|
361
|
-
end
|
362
|
-
|
363
|
-
# The JSONAPI Type. For instance if you queried:
|
364
|
-
#
|
365
|
-
# GET /employees?fields[positions]=title
|
366
|
-
#
|
367
|
-
# And/Or got back in the response
|
368
|
-
#
|
369
|
-
# { id: '1', type: 'positions' }
|
370
|
-
#
|
371
|
-
# The type would be :positions
|
372
|
-
#
|
373
|
-
# This should match the +type+ set in your serializer.
|
374
|
-
#
|
375
|
-
# @example
|
376
|
-
# class PostResource < ApplicationResource
|
377
|
-
# type :posts
|
378
|
-
# end
|
379
|
-
#
|
380
|
-
# @param [Array<Hash>] value Array of sorting criteria
|
381
|
-
def self.type(value = nil)
|
382
|
-
config[:type] = value
|
383
|
-
end
|
8
|
+
attr_reader :context
|
384
9
|
|
385
|
-
|
386
|
-
|
387
|
-
def self.default_page_number(val)
|
388
|
-
config[:default_page_number] = val
|
10
|
+
def around_scoping(scope, query_hash)
|
11
|
+
yield scope
|
389
12
|
end
|
390
13
|
|
391
|
-
|
392
|
-
|
393
|
-
# @example
|
394
|
-
# # GET /employees will only render 10 employees
|
395
|
-
# default_page_size 10
|
396
|
-
#
|
397
|
-
# @param [Integer] val The new default page size.
|
398
|
-
def self.default_page_size(val)
|
399
|
-
config[:default_page_size] = val
|
14
|
+
def serializer_for(model)
|
15
|
+
serializer
|
400
16
|
end
|
401
17
|
|
402
|
-
# This is where we store all information set via DSL.
|
403
|
-
# Useful for introspection.
|
404
|
-
# Gets dup'd when inherited.
|
405
|
-
#
|
406
|
-
# @return [Hash] the current configuration
|
407
|
-
def self.config
|
408
|
-
@config ||= begin
|
409
|
-
{
|
410
|
-
filters: {},
|
411
|
-
default_filters: {},
|
412
|
-
extra_fields: {},
|
413
|
-
stats: {},
|
414
|
-
sorting: nil,
|
415
|
-
pagination: nil,
|
416
|
-
model: nil,
|
417
|
-
before_commit: {},
|
418
|
-
adapter: Adapters::Abstract.new
|
419
|
-
}
|
420
|
-
end
|
421
|
-
end
|
422
|
-
|
423
|
-
# Run code within a given context.
|
424
|
-
# Useful for running code within, say, a Rails controller context
|
425
|
-
#
|
426
|
-
# When using Rails, controller actions are wrapped this way.
|
427
|
-
#
|
428
|
-
# @example Sinatra
|
429
|
-
# get '/api/posts' do
|
430
|
-
# resource.with_context self, :index do
|
431
|
-
# scope = jsonapi_scope(Tweet.all)
|
432
|
-
# render_jsonapi(scope.resolve, scope: false)
|
433
|
-
# end
|
434
|
-
# end
|
435
|
-
#
|
436
|
-
# @see Rails
|
437
|
-
# @see Base#wrap_context
|
438
|
-
# @param object The context (Rails controller or equivalent)
|
439
|
-
# @param namespace One of index/show/etc
|
440
18
|
def with_context(object, namespace = nil)
|
441
19
|
JsonapiCompliable.with_context(object, namespace) do
|
442
20
|
yield
|
443
21
|
end
|
444
22
|
end
|
445
23
|
|
446
|
-
# The current context **object** set by +#with_context+. If you are
|
447
|
-
# using Rails, this is a controller instance.
|
448
|
-
#
|
449
|
-
# This method is equivalent to +JsonapiCompliable.context[:object]+
|
450
|
-
#
|
451
|
-
# @see #with_context
|
452
|
-
# @return the context object
|
453
24
|
def context
|
454
25
|
JsonapiCompliable.context[:object]
|
455
26
|
end
|
456
27
|
|
457
|
-
# The current context **namespace** set by +#with_context+. If you
|
458
|
-
# are using Rails, this is the controller method name (e.g. +:index+)
|
459
|
-
#
|
460
|
-
# This method is equivalent to +JsonapiCompliable.context[:namespace]+
|
461
|
-
#
|
462
|
-
# @see #with_context
|
463
|
-
# @return [Symbol] the context namespace
|
464
28
|
def context_namespace
|
465
29
|
JsonapiCompliable.context[:namespace]
|
466
30
|
end
|
467
31
|
|
468
|
-
# Build a scope using this Resource configuration
|
469
|
-
#
|
470
|
-
# Essentially "api private", but can be useful for testing.
|
471
|
-
#
|
472
|
-
# @see Scope
|
473
|
-
# @see Query
|
474
|
-
# @param base The base scope we are going to chain
|
475
|
-
# @param query The relevant Query object
|
476
|
-
# @param opts Opts passed to +Scope.new+
|
477
|
-
# @return [Scope] a configured Scope instance
|
478
32
|
def build_scope(base, query, opts = {})
|
479
33
|
Scope.new(base, self, query, opts)
|
480
34
|
end
|
481
35
|
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
36
|
+
def base_scope
|
37
|
+
adapter.base_scope(model)
|
38
|
+
end
|
39
|
+
|
40
|
+
def typecast(name, value, flag)
|
41
|
+
att = get_attr!(name, flag)
|
42
|
+
type = JsonapiCompliable::Types[att[:type]]
|
43
|
+
begin
|
44
|
+
flag = :read if flag == :readable
|
45
|
+
flag = :write if flag == :writable
|
46
|
+
flag = :params if [:sortable, :filterable].include?(flag)
|
47
|
+
type[flag][value]
|
48
|
+
rescue Exception => e
|
49
|
+
raise Errors::TypecastFailed.new(self, name, value, e)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
497
53
|
def create(create_params)
|
498
54
|
adapter.create(model, create_params)
|
499
55
|
end
|
500
56
|
|
501
|
-
# Update the relevant model.
|
502
|
-
# You must configure a model (see .model) to update.
|
503
|
-
# If you override, you *must* return the updated instance.
|
504
|
-
#
|
505
|
-
# @example Send e-mail on update
|
506
|
-
# def update(attributes)
|
507
|
-
# instance = model.update_attributes(attributes)
|
508
|
-
# UserMailer.profile_updated_email(instance).deliver_later
|
509
|
-
# instance
|
510
|
-
# end
|
511
|
-
#
|
512
|
-
# @see .model
|
513
|
-
# @see Adapters::ActiveRecord#update
|
514
|
-
# @param [Hash] update_params The relevant attributes, including id and foreign keys
|
515
|
-
# @return [Object] an instance of the just-updated model
|
516
57
|
def update(update_params)
|
517
58
|
adapter.update(model, update_params)
|
518
59
|
end
|
519
60
|
|
520
|
-
# Destroy the relevant model.
|
521
|
-
# You must configure a model (see .model) to destroy.
|
522
|
-
# If you override, you *must* return the destroyed instance.
|
523
|
-
#
|
524
|
-
# @example Send e-mail on destroy
|
525
|
-
# def destroy(attributes)
|
526
|
-
# instance = model_class.find(id)
|
527
|
-
# instance.destroy
|
528
|
-
# UserMailer.goodbye_email(instance).deliver_later
|
529
|
-
# instance
|
530
|
-
# end
|
531
|
-
#
|
532
|
-
# @see .model
|
533
|
-
# @see Adapters::ActiveRecord#destroy
|
534
|
-
# @param [String] id The +id+ of the relevant Model
|
535
|
-
# @return [Object] an instance of the just-destroyed model
|
536
61
|
def destroy(id)
|
537
62
|
adapter.destroy(model, id)
|
538
63
|
end
|
539
64
|
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
# @see Adapters::ActiveRecord#associate
|
65
|
+
def associate_all(parent, children, association_name, type)
|
66
|
+
adapter.associate_all(parent, children, association_name, type)
|
67
|
+
end
|
68
|
+
|
545
69
|
def associate(parent, child, association_name, type)
|
546
70
|
adapter.associate(parent, child, association_name, type)
|
547
71
|
end
|
548
72
|
|
549
|
-
# Delegates #disassociate to adapter. Built for overriding.
|
550
|
-
#
|
551
|
-
# @see .use_adapter
|
552
|
-
# @see Adapters::Abstract#disassociate
|
553
|
-
# @see Adapters::ActiveRecord#disassociate
|
554
73
|
def disassociate(parent, child, association_name, type)
|
555
74
|
adapter.disassociate(parent, child, association_name, type)
|
556
75
|
end
|
557
76
|
|
558
|
-
# @api private
|
559
77
|
def persist_with_relationships(meta, attributes, relationships, caller_model = nil)
|
560
78
|
persistence = JsonapiCompliable::Util::Persistence \
|
561
79
|
.new(self, meta, attributes, relationships, caller_model)
|
562
80
|
persistence.run
|
563
81
|
end
|
564
82
|
|
565
|
-
# @see Sideload#association_names
|
566
|
-
def association_names
|
567
|
-
sideloading.association_names
|
568
|
-
end
|
569
|
-
|
570
|
-
# The relevant proc for the given attribute and calculation.
|
571
|
-
#
|
572
|
-
# @example Custom Stats
|
573
|
-
# # Given this configuration
|
574
|
-
# allow_stat :rating do
|
575
|
-
# average { |scope, attr| ... }
|
576
|
-
# end
|
577
|
-
#
|
578
|
-
# # We'd call the method like
|
579
|
-
# resource.stat(:rating, :average)
|
580
|
-
# # Which would return the custom proc
|
581
|
-
#
|
582
|
-
# Raises +JsonapiCompliable::Errors::StatNotFound+ if not corresponding
|
583
|
-
# stat has been configured.
|
584
|
-
#
|
585
|
-
# @see Errors::StatNotFound
|
586
|
-
# @param [String, Symbol] attribute The attribute we're calculating.
|
587
|
-
# @param [String, Symbol] calculation The calculation to run
|
588
|
-
# @return [Proc] the corresponding callable
|
589
83
|
def stat(attribute, calculation)
|
590
84
|
stats_dsl = stats[attribute] || stats[attribute.to_sym]
|
591
85
|
raise Errors::StatNotFound.new(attribute, calculation) unless stats_dsl
|
592
86
|
stats_dsl.calculation(calculation)
|
593
87
|
end
|
594
88
|
|
595
|
-
# Interface to the sideloads for this Resource
|
596
|
-
# @api private
|
597
|
-
def sideloading
|
598
|
-
self.class.sideloading
|
599
|
-
end
|
600
|
-
|
601
|
-
# @see .default_sort
|
602
|
-
# @api private
|
603
|
-
def default_sort
|
604
|
-
self.class.config[:default_sort] || [{ id: :asc }]
|
605
|
-
end
|
606
|
-
|
607
|
-
# @see .default_page_number
|
608
|
-
# @api private
|
609
|
-
def default_page_number
|
610
|
-
self.class.config[:default_page_number] || 1
|
611
|
-
end
|
612
|
-
|
613
|
-
# @see .default_page_size
|
614
|
-
# @api private
|
615
|
-
def default_page_size
|
616
|
-
self.class.config[:default_page_size] || 20
|
617
|
-
end
|
618
|
-
|
619
|
-
# Returns :undefined_jsonapi_type when not configured.
|
620
|
-
# @see .type
|
621
|
-
# @api private
|
622
|
-
def type
|
623
|
-
self.class.config[:type] || :undefined_jsonapi_type
|
624
|
-
end
|
625
|
-
|
626
|
-
# @see .allow_filter
|
627
|
-
# @api private
|
628
|
-
def filters
|
629
|
-
self.class.config[:filters]
|
630
|
-
end
|
631
|
-
|
632
|
-
# @see .sort
|
633
|
-
# @api private
|
634
|
-
def sorting
|
635
|
-
self.class.config[:sorting]
|
636
|
-
end
|
637
|
-
|
638
|
-
# @see .allow_stat
|
639
|
-
# @api private
|
640
|
-
def stats
|
641
|
-
self.class.config[:stats]
|
642
|
-
end
|
643
|
-
|
644
|
-
# @see .paginate
|
645
|
-
# @api private
|
646
|
-
def pagination
|
647
|
-
self.class.config[:pagination]
|
648
|
-
end
|
649
|
-
|
650
|
-
# @see .extra_field
|
651
|
-
# @api private
|
652
|
-
def extra_fields
|
653
|
-
self.class.config[:extra_fields]
|
654
|
-
end
|
655
|
-
|
656
|
-
# @see .default_filter
|
657
|
-
# @api private
|
658
|
-
def default_filters
|
659
|
-
self.class.config[:default_filters]
|
660
|
-
end
|
661
|
-
|
662
|
-
# @see .model
|
663
|
-
# @api private
|
664
|
-
def model
|
665
|
-
self.class.config[:model]
|
666
|
-
end
|
667
|
-
|
668
|
-
# @see .use_adapter
|
669
|
-
# @api private
|
670
|
-
def adapter
|
671
|
-
self.class.config[:adapter]
|
672
|
-
end
|
673
|
-
|
674
|
-
# How do you want to resolve the scope?
|
675
|
-
#
|
676
|
-
# For ActiveRecord, when we want to actually fire SQL, it's
|
677
|
-
# +#to_a+.
|
678
|
-
#
|
679
|
-
# @example Custom API Call
|
680
|
-
# # Let's build a hash and pass it off to an HTTP client
|
681
|
-
# class PostResource < ApplicationResource
|
682
|
-
# type :posts
|
683
|
-
# use_adapter JsonapiCompliable::Adapters::Null
|
684
|
-
#
|
685
|
-
# sort do |scope, attribute, direction|
|
686
|
-
# scope.merge!(order: { attribute => direction }
|
687
|
-
# end
|
688
|
-
#
|
689
|
-
# page do |scope, current_page, per_page|
|
690
|
-
# scope.merge!(page: current_page, per_page: per_page)
|
691
|
-
# end
|
692
|
-
#
|
693
|
-
# def resolve(scope)
|
694
|
-
# MyHttpClient.get(scope)
|
695
|
-
# end
|
696
|
-
# end
|
697
|
-
#
|
698
|
-
# This method *must* return an array of resolved model objects.
|
699
|
-
#
|
700
|
-
# By default, delegates to the adapter. You likely want to alter your
|
701
|
-
# adapter rather than override this directly.
|
702
|
-
#
|
703
|
-
# @see Adapters::ActiveRecord#resolve
|
704
|
-
# @param scope The scope object we've built up
|
705
|
-
# @return [Array] array of resolved model objects
|
706
89
|
def resolve(scope)
|
707
90
|
adapter.resolve(scope)
|
708
91
|
end
|
709
92
|
|
710
|
-
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
# end
|
716
|
-
#
|
717
|
-
# Should roll back the transaction, but avoid bubbling up the error,
|
718
|
-
# if +JsonapiCompliable::Errors::ValidationError+ is raised within
|
719
|
-
# the block.
|
720
|
-
#
|
721
|
-
# By default, delegates to the adapter. You likely want to alter your
|
722
|
-
# adapter rather than override this directly.
|
723
|
-
#
|
724
|
-
# @see Adapters::ActiveRecord#transaction
|
725
|
-
# @return the result of +yield+
|
93
|
+
def before_commit(model, method)
|
94
|
+
hook = self.class.config[:before_commit][method]
|
95
|
+
hook.call(model) if hook
|
96
|
+
end
|
97
|
+
|
726
98
|
def transaction
|
727
99
|
response = nil
|
728
100
|
begin
|