jsonapi_compliable 0.6.4 → 0.6.5
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 +4 -4
- data/.gitignore +1 -1
- data/.travis.yml +11 -3
- data/.yardopts +1 -0
- data/README.md +10 -1
- data/Rakefile +1 -0
- data/docs/JsonapiCompliable.html +202 -0
- data/docs/JsonapiCompliable/Adapters.html +119 -0
- data/docs/JsonapiCompliable/Adapters/Abstract.html +2285 -0
- data/docs/JsonapiCompliable/Adapters/ActiveRecord.html +2151 -0
- data/docs/JsonapiCompliable/Adapters/ActiveRecordSideloading.html +582 -0
- data/docs/JsonapiCompliable/Adapters/Null.html +1682 -0
- data/docs/JsonapiCompliable/Base.html +1395 -0
- data/docs/JsonapiCompliable/Deserializer.html +835 -0
- data/docs/JsonapiCompliable/Errors.html +115 -0
- data/docs/JsonapiCompliable/Errors/BadFilter.html +124 -0
- data/docs/JsonapiCompliable/Errors/StatNotFound.html +266 -0
- data/docs/JsonapiCompliable/Errors/UnsupportedPageSize.html +264 -0
- data/docs/JsonapiCompliable/Errors/ValidationError.html +124 -0
- data/docs/JsonapiCompliable/Extensions.html +117 -0
- data/docs/JsonapiCompliable/Extensions/BooleanAttribute.html +212 -0
- data/docs/JsonapiCompliable/Extensions/BooleanAttribute/ClassMethods.html +229 -0
- data/docs/JsonapiCompliable/Extensions/ExtraAttribute.html +242 -0
- data/docs/JsonapiCompliable/Extensions/ExtraAttribute/ClassMethods.html +237 -0
- data/docs/JsonapiCompliable/Query.html +1099 -0
- data/docs/JsonapiCompliable/Rails.html +211 -0
- data/docs/JsonapiCompliable/Resource.html +5241 -0
- data/docs/JsonapiCompliable/Scope.html +703 -0
- data/docs/JsonapiCompliable/Scoping.html +117 -0
- data/docs/JsonapiCompliable/Scoping/Base.html +843 -0
- data/docs/JsonapiCompliable/Scoping/DefaultFilter.html +318 -0
- data/docs/JsonapiCompliable/Scoping/ExtraFields.html +301 -0
- data/docs/JsonapiCompliable/Scoping/Filter.html +313 -0
- data/docs/JsonapiCompliable/Scoping/Filterable.html +364 -0
- data/docs/JsonapiCompliable/Scoping/Paginate.html +613 -0
- data/docs/JsonapiCompliable/Scoping/Sort.html +454 -0
- data/docs/JsonapiCompliable/SerializableTempId.html +216 -0
- data/docs/JsonapiCompliable/Sideload.html +2484 -0
- data/docs/JsonapiCompliable/Stats.html +117 -0
- data/docs/JsonapiCompliable/Stats/DSL.html +999 -0
- data/docs/JsonapiCompliable/Stats/Payload.html +391 -0
- data/docs/JsonapiCompliable/Util.html +117 -0
- data/docs/JsonapiCompliable/Util/FieldParams.html +228 -0
- data/docs/JsonapiCompliable/Util/Hash.html +471 -0
- data/docs/JsonapiCompliable/Util/IncludeParams.html +299 -0
- data/docs/JsonapiCompliable/Util/Persistence.html +435 -0
- data/docs/JsonapiCompliable/Util/RelationshipPayload.html +563 -0
- data/docs/JsonapiCompliable/Util/RenderOptions.html +250 -0
- data/docs/JsonapiCompliable/Util/ValidationResponse.html +532 -0
- data/docs/_index.html +527 -0
- data/docs/class_list.html +51 -0
- data/docs/css/common.css +1 -0
- data/docs/css/full_list.css +58 -0
- data/docs/css/style.css +492 -0
- data/docs/file.README.html +99 -0
- data/docs/file_list.html +56 -0
- data/docs/frames.html +17 -0
- data/docs/index.html +99 -0
- data/docs/js/app.js +248 -0
- data/docs/js/full_list.js +216 -0
- data/docs/js/jquery.js +4 -0
- data/docs/method_list.html +1731 -0
- data/docs/top-level-namespace.html +110 -0
- data/gemfiles/rails_5.gemfile +1 -1
- data/lib/jsonapi_compliable/adapters/abstract.rb +267 -4
- data/lib/jsonapi_compliable/adapters/active_record.rb +23 -1
- data/lib/jsonapi_compliable/adapters/null.rb +43 -3
- data/lib/jsonapi_compliable/base.rb +182 -33
- data/lib/jsonapi_compliable/deserializer.rb +90 -21
- data/lib/jsonapi_compliable/extensions/boolean_attribute.rb +12 -0
- data/lib/jsonapi_compliable/extensions/extra_attribute.rb +32 -0
- data/lib/jsonapi_compliable/extensions/temp_id.rb +11 -0
- data/lib/jsonapi_compliable/query.rb +94 -2
- data/lib/jsonapi_compliable/rails.rb +8 -0
- data/lib/jsonapi_compliable/resource.rb +548 -11
- data/lib/jsonapi_compliable/scope.rb +43 -1
- data/lib/jsonapi_compliable/scoping/base.rb +59 -8
- data/lib/jsonapi_compliable/scoping/default_filter.rb +33 -0
- data/lib/jsonapi_compliable/scoping/extra_fields.rb +33 -0
- data/lib/jsonapi_compliable/scoping/filter.rb +29 -2
- data/lib/jsonapi_compliable/scoping/filterable.rb +4 -0
- data/lib/jsonapi_compliable/scoping/paginate.rb +33 -0
- data/lib/jsonapi_compliable/scoping/sort.rb +18 -0
- data/lib/jsonapi_compliable/sideload.rb +229 -1
- data/lib/jsonapi_compliable/stats/dsl.rb +44 -0
- data/lib/jsonapi_compliable/stats/payload.rb +20 -0
- data/lib/jsonapi_compliable/util/field_params.rb +1 -0
- data/lib/jsonapi_compliable/util/hash.rb +18 -0
- data/lib/jsonapi_compliable/util/include_params.rb +22 -0
- data/lib/jsonapi_compliable/util/persistence.rb +13 -3
- data/lib/jsonapi_compliable/util/relationship_payload.rb +2 -0
- data/lib/jsonapi_compliable/util/render_options.rb +2 -0
- data/lib/jsonapi_compliable/util/validation_response.rb +16 -0
- data/lib/jsonapi_compliable/version.rb +1 -1
- metadata +60 -5
- data/gemfiles/rails_4.gemfile.lock +0 -208
- data/gemfiles/rails_5.gemfile.lock +0 -212
- data/lib/jsonapi_compliable/write.rb +0 -93
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# A Scope wraps an underlying object. It modifies that object
|
|
3
|
+
# using the corresponding Resource and Query, and how to resolve
|
|
4
|
+
# that underlying object scope.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic Controller usage
|
|
7
|
+
# def index
|
|
8
|
+
# base = Post.all
|
|
9
|
+
# scope = jsonapi_scope(base)
|
|
10
|
+
# scope.object == base # => true
|
|
11
|
+
# scope.object = scope.object.where(active: true)
|
|
12
|
+
#
|
|
13
|
+
# # actually fires sql
|
|
14
|
+
# scope.resolve #=> [#<Post ...>, #<Post ...>, etc]
|
|
15
|
+
# end
|
|
2
16
|
class Scope
|
|
17
|
+
# @param object - The underlying, chainable base scope object
|
|
18
|
+
# @param resource - The Resource that will process the object
|
|
19
|
+
# @param query - The Query object for the current request
|
|
20
|
+
# @param [Hash] opts Options to configure the Scope
|
|
21
|
+
# @option opts [String] :namespace The nested relationship name
|
|
22
|
+
# @option opts [Boolean] :filter Opt-out of filter scoping
|
|
23
|
+
# @option opts [Boolean] :extra_fields Opt-out of extra_fields scoping
|
|
24
|
+
# @option opts [Boolean] :sort Opt-out of sort scoping
|
|
25
|
+
# @option opts [Boolean] :pagination Opt-out of pagination scoping
|
|
26
|
+
# @option opts [Boolean] :default_paginate Opt-out of default pagination when not specified in request
|
|
3
27
|
def initialize(object, resource, query, opts = {})
|
|
4
28
|
@object = object
|
|
5
29
|
@resource = resource
|
|
@@ -7,17 +31,32 @@ module JsonapiCompliable
|
|
|
7
31
|
|
|
8
32
|
# Namespace for the 'outer' or 'main' resource is its type
|
|
9
33
|
# For its relationships, its the relationship name
|
|
10
|
-
# IOW when hitting /states, it's resource type 'states
|
|
34
|
+
# IOW when hitting /states, it's resource type 'states'
|
|
11
35
|
# when hitting /authors?include=state its 'state'
|
|
12
36
|
@namespace = opts.delete(:namespace) || resource.type
|
|
13
37
|
|
|
14
38
|
apply_scoping(opts)
|
|
15
39
|
end
|
|
16
40
|
|
|
41
|
+
# Resolve the requested stats. Returns hash like:
|
|
42
|
+
#
|
|
43
|
+
# { rating: { average: 5.5, maximum: 9 } }
|
|
44
|
+
#
|
|
45
|
+
# @return [Hash] the resolved stat info
|
|
46
|
+
# @api private
|
|
17
47
|
def resolve_stats
|
|
18
48
|
Stats::Payload.new(@resource, query_hash, @unpaginated_object).generate
|
|
19
49
|
end
|
|
20
50
|
|
|
51
|
+
# Resolve the scope. This is where SQL is actually fired, an HTTP
|
|
52
|
+
# request is actually made, etc.
|
|
53
|
+
#
|
|
54
|
+
# Does nothing if the user requested zero results, ie /posts?page[size]=0
|
|
55
|
+
#
|
|
56
|
+
# First resolves the top-level resource, then processes each relevant sideload
|
|
57
|
+
#
|
|
58
|
+
# @see Resource#resolve
|
|
59
|
+
# @return [Array] an array of resolved model instances
|
|
21
60
|
def resolve
|
|
22
61
|
if @query.zero_results?
|
|
23
62
|
[]
|
|
@@ -28,6 +67,9 @@ module JsonapiCompliable
|
|
|
28
67
|
end
|
|
29
68
|
end
|
|
30
69
|
|
|
70
|
+
# The slice of Query#to_hash for the current namespace
|
|
71
|
+
# @see Query#to_hash
|
|
72
|
+
# @api private
|
|
31
73
|
def query_hash
|
|
32
74
|
@query_hash ||= @query.to_hash[@namespace]
|
|
33
75
|
end
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
2
|
module Scoping
|
|
3
|
+
# The interface for scoping logic (filter, paginate, etc).
|
|
4
|
+
#
|
|
5
|
+
# This class defines some common behavior, such as falling back on
|
|
6
|
+
# a default if not part of the user request.
|
|
7
|
+
#
|
|
8
|
+
# @attr_reader [Resource] resource The corresponding Resource instance
|
|
9
|
+
# @attr_reader [Hash] query_hash the Query#to_hash node relevant to the current resource
|
|
10
|
+
#
|
|
11
|
+
# @see Scoping::DefaultFilter
|
|
12
|
+
# @see Scoping::ExtraFields
|
|
13
|
+
# @see Scoping::Filter
|
|
14
|
+
# @see Scoping::Paginate
|
|
15
|
+
# @see Scoping::Sort
|
|
16
|
+
# @see Scope#initialize
|
|
17
|
+
# @see Scope#query_hash
|
|
18
|
+
# @see Query#to_hash
|
|
3
19
|
class Base
|
|
4
20
|
attr_reader :resource, :query_hash
|
|
5
21
|
|
|
22
|
+
# @param [Resource] resource the Resource instance
|
|
23
|
+
# @param [Hash] query_hash the Query#to_hash node relevant to the current resource
|
|
24
|
+
# @param scope the base scope object to chain/modify
|
|
25
|
+
# @param [Hash] opts configuration options used by subclasses
|
|
6
26
|
def initialize(resource, query_hash, scope, opts = {})
|
|
7
27
|
@query_hash = query_hash
|
|
8
28
|
@resource = resource
|
|
@@ -10,6 +30,23 @@ module JsonapiCompliable
|
|
|
10
30
|
@opts = opts
|
|
11
31
|
end
|
|
12
32
|
|
|
33
|
+
# Apply this scoping criteria.
|
|
34
|
+
# This is where we would chain on pagination, sorting, etc.
|
|
35
|
+
#
|
|
36
|
+
# If #apply? returns false, does nothing. Otherwise will apply
|
|
37
|
+
# the default logic:
|
|
38
|
+
#
|
|
39
|
+
# # no block, run default logic via adapter
|
|
40
|
+
# allow_filter :name
|
|
41
|
+
#
|
|
42
|
+
# Or the customized proc:
|
|
43
|
+
#
|
|
44
|
+
# allow_filter :name do |scope, value|
|
|
45
|
+
# scope.where("upper(name) = ?", value.upcase)
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# @see #apply?
|
|
49
|
+
# @return the scope object
|
|
13
50
|
def apply
|
|
14
51
|
if apply?
|
|
15
52
|
apply_standard_or_override
|
|
@@ -18,10 +55,31 @@ module JsonapiCompliable
|
|
|
18
55
|
end
|
|
19
56
|
end
|
|
20
57
|
|
|
58
|
+
# Should we process this scope logic?
|
|
59
|
+
#
|
|
60
|
+
# Useful for when we want to explicitly opt-out on
|
|
61
|
+
# certain requests, or avoid a default in certain contexts.
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] if we should apply this scope logic
|
|
21
64
|
def apply?
|
|
22
65
|
true
|
|
23
66
|
end
|
|
24
67
|
|
|
68
|
+
# Defines how to call/apply the default scoping logic
|
|
69
|
+
def apply_standard_scope
|
|
70
|
+
raise 'override in subclass'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Defines how to call/apply the custom scoping logic provided by the
|
|
74
|
+
# user.
|
|
75
|
+
def apply_custom_scope
|
|
76
|
+
raise 'override in subclass'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# If the user customized (by providing a block in the Resource DSL)
|
|
82
|
+
# then call the custom proc. Else, call the default proc.
|
|
25
83
|
def apply_standard_or_override
|
|
26
84
|
if apply_standard_scope?
|
|
27
85
|
@scope = apply_standard_scope
|
|
@@ -32,17 +90,10 @@ module JsonapiCompliable
|
|
|
32
90
|
@scope
|
|
33
91
|
end
|
|
34
92
|
|
|
93
|
+
# Should we apply the default proc, or a custom one?
|
|
35
94
|
def apply_standard_scope?
|
|
36
95
|
custom_scope.nil?
|
|
37
96
|
end
|
|
38
|
-
|
|
39
|
-
def apply_standard_scope
|
|
40
|
-
raise 'override in subclass'
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def apply_custom_scope
|
|
44
|
-
raise 'override in subclass'
|
|
45
|
-
end
|
|
46
97
|
end
|
|
47
98
|
end
|
|
48
99
|
end
|
|
@@ -1,7 +1,40 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# Default filters apply to every request, unless specifically overridden in
|
|
3
|
+
# the request.
|
|
4
|
+
#
|
|
5
|
+
# Maybe we only want to show active posts:
|
|
6
|
+
#
|
|
7
|
+
# class PostResource < ApplicationResource
|
|
8
|
+
# # ... code ...
|
|
9
|
+
# default_filter :active do |scope|
|
|
10
|
+
# scope.where(active: true)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# But if the user is an admin and specifically requests inactive posts:
|
|
15
|
+
#
|
|
16
|
+
# class PostResource < ApplicationResource
|
|
17
|
+
# # ... code ...
|
|
18
|
+
# allow_filter :active, if: admin?
|
|
19
|
+
#
|
|
20
|
+
# default_filter :active do |scope|
|
|
21
|
+
# scope.where(active: true)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# # Now a GET /posts?filter[active]=false will return inactive posts
|
|
26
|
+
# # if the user is an admin.
|
|
27
|
+
#
|
|
28
|
+
# @see Resource.default_filter
|
|
29
|
+
# @see Resource.allow_filter
|
|
2
30
|
class Scoping::DefaultFilter < Scoping::Base
|
|
3
31
|
include Scoping::Filterable
|
|
4
32
|
|
|
33
|
+
# Apply the default filtering logic.
|
|
34
|
+
# Loop through each defined default filter, and apply the default
|
|
35
|
+
# proc unless an explicit override is requested
|
|
36
|
+
#
|
|
37
|
+
# @return scope the scope object we are chaining/modifying
|
|
5
38
|
def apply
|
|
6
39
|
resource.default_filters.each_pair do |name, opts|
|
|
7
40
|
next if overridden?(name)
|
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# Apply logic when an extra field is requested. Useful for eager loading
|
|
3
|
+
# associations used to compute the extra field.
|
|
4
|
+
#
|
|
5
|
+
# Given a Resource
|
|
6
|
+
#
|
|
7
|
+
# class PersonResource < ApplicationResource
|
|
8
|
+
# extra_field :net_worth do |scope|
|
|
9
|
+
# scope.includes(:assets)
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# And a corresponding serializer:
|
|
14
|
+
#
|
|
15
|
+
# class SerializablePerson < JSONAPI::Serializable::Resource
|
|
16
|
+
# extra_attribute :net_worth do
|
|
17
|
+
# @object.assets.sum(&:value)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# When the user requests the extra field 'net_worth':
|
|
22
|
+
#
|
|
23
|
+
# GET /people?extra_fields[people]=net_worth
|
|
24
|
+
#
|
|
25
|
+
# The +assets+ will be eager loaded and the 'net_worth' attribute
|
|
26
|
+
# will be present in the response. If this field is not explicitly
|
|
27
|
+
# requested, none of this logic fires.
|
|
28
|
+
#
|
|
29
|
+
# @see Resource.extra_field
|
|
30
|
+
# @see Extensions::ExtraAttribute
|
|
2
31
|
class Scoping::ExtraFields < Scoping::Base
|
|
32
|
+
# Loop through all requested extra fields. If custom scoping
|
|
33
|
+
# logic is define for that field, run it. Otherwise, do nothing.
|
|
34
|
+
#
|
|
35
|
+
# @return the scope object we are chaining/modofying
|
|
3
36
|
def apply
|
|
4
37
|
each_extra_field do |callable|
|
|
5
38
|
@scope = callable.call(@scope)
|
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# Apply filtering logic to the scope
|
|
3
|
+
#
|
|
4
|
+
# If the user requests to filter a field that has not been whitelisted,
|
|
5
|
+
# a +JsonapiCompliable::Errors::BadFilter+ error will be raised.
|
|
6
|
+
#
|
|
7
|
+
# allow_filter :title # :title now whitelisted
|
|
8
|
+
#
|
|
9
|
+
# If the user requests a filter field that has been whitelisted, but
|
|
10
|
+
# does not pass the associated `+:if+ clause, +BadFilter+ will be raised.
|
|
11
|
+
#
|
|
12
|
+
# allow_filter :title, if: :admin?
|
|
13
|
+
#
|
|
14
|
+
# This will also honor filter aliases.
|
|
15
|
+
#
|
|
16
|
+
# # GET /posts?filter[headline]=foo will filter on title
|
|
17
|
+
# allow_filter :title, aliases: [:headline]
|
|
18
|
+
#
|
|
19
|
+
# @see Adapters::Abstract#filter
|
|
20
|
+
# @see Adapters::ActiveRecord#filter
|
|
21
|
+
# @see Resource.allow_filter
|
|
2
22
|
class Scoping::Filter < Scoping::Base
|
|
3
23
|
include Scoping::Filterable
|
|
4
24
|
|
|
25
|
+
# Apply the filtering logic.
|
|
26
|
+
#
|
|
27
|
+
# Loop and parse all requested filters, taking into account guards and
|
|
28
|
+
# aliases. If valid, call either the default or custom filtering logic.
|
|
29
|
+
# @return the scope we are chaining/modifying
|
|
5
30
|
def apply
|
|
6
31
|
each_filter do |filter, value|
|
|
7
32
|
@scope = filter_scope(filter, value)
|
|
@@ -10,6 +35,10 @@ module JsonapiCompliable
|
|
|
10
35
|
@scope
|
|
11
36
|
end
|
|
12
37
|
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# If there's custom logic, run it, otherwise run the default logic
|
|
41
|
+
# specified in the adapter.
|
|
13
42
|
def filter_scope(filter, value)
|
|
14
43
|
if custom_scope = filter.values.first[:filter]
|
|
15
44
|
custom_scope.call(@scope, value)
|
|
@@ -18,8 +47,6 @@ module JsonapiCompliable
|
|
|
18
47
|
end
|
|
19
48
|
end
|
|
20
49
|
|
|
21
|
-
private
|
|
22
|
-
|
|
23
50
|
def each_filter
|
|
24
51
|
filter_param.each_pair do |param_name, param_value|
|
|
25
52
|
filter = find_filter!(param_name.to_sym)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# @api private
|
|
2
3
|
module Scoping::Filterable
|
|
4
|
+
# @api private
|
|
3
5
|
def find_filter(name)
|
|
4
6
|
find_filter!(name)
|
|
5
7
|
rescue JsonapiCompliable::Errors::BadFilter
|
|
6
8
|
nil
|
|
7
9
|
end
|
|
8
10
|
|
|
11
|
+
# @api private
|
|
9
12
|
def find_filter!(name)
|
|
10
13
|
filter_name, filter_value = \
|
|
11
14
|
resource.filters.find { |_name, opts| opts[:aliases].include?(name.to_sym) }
|
|
@@ -16,6 +19,7 @@ module JsonapiCompliable
|
|
|
16
19
|
{ filter_name => filter_value }
|
|
17
20
|
end
|
|
18
21
|
|
|
22
|
+
# @api private
|
|
19
23
|
def filter_param
|
|
20
24
|
query_hash[:filter]
|
|
21
25
|
end
|
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# Apply pagination logic to the scope
|
|
3
|
+
#
|
|
4
|
+
# If the user requests a page size greater than +MAX_PAGE_SIZE+,
|
|
5
|
+
# a +JsonapiCompliable::Errors::UnsupportedPageSize+ error will be raised.
|
|
6
|
+
#
|
|
7
|
+
# Notably, this will not fire when the `default: false` option is passed.
|
|
8
|
+
# This is the case for sideloads - if the user requests "give me the post
|
|
9
|
+
# and its comments", we shouldn't implicitly limit those comments to 20.
|
|
10
|
+
# *BUT* if the user requests, "give me the post and 3 of its comments", we
|
|
11
|
+
# *should* honor that pagination.
|
|
12
|
+
#
|
|
13
|
+
# This can be confusing because there are also 'default' and 'customized'
|
|
14
|
+
# pagination procs. The default comes 'for free'. Customized pagination
|
|
15
|
+
# looks like
|
|
16
|
+
#
|
|
17
|
+
# class PostResource < ApplicationResource
|
|
18
|
+
# paginate do |scope, current_page, per_page|
|
|
19
|
+
# # ... the custom logic ...
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# We should use the default unless the user has customized.
|
|
24
|
+
# @see Resource.paginate
|
|
2
25
|
class Scoping::Paginate < Scoping::Base
|
|
3
26
|
MAX_PAGE_SIZE = 1_000
|
|
4
27
|
|
|
28
|
+
# Apply the pagination logic. Raise error if over the max page size.
|
|
29
|
+
# @return the scope object we are chaining/modifying
|
|
5
30
|
def apply
|
|
6
31
|
if size > MAX_PAGE_SIZE
|
|
7
32
|
raise JsonapiCompliable::Errors::UnsupportedPageSize
|
|
@@ -11,6 +36,11 @@ module JsonapiCompliable
|
|
|
11
36
|
end
|
|
12
37
|
end
|
|
13
38
|
|
|
39
|
+
# We want to apply this logic unless we've explicitly received the
|
|
40
|
+
# +default: false+ option. In that case, only apply if pagination
|
|
41
|
+
# was explicitly specified in the request.
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] should we apply this logic?
|
|
14
44
|
def apply?
|
|
15
45
|
if @opts[:default] == false
|
|
16
46
|
not [page_param[:size], page_param[:number]].all?(&:nil?)
|
|
@@ -19,14 +49,17 @@ module JsonapiCompliable
|
|
|
19
49
|
end
|
|
20
50
|
end
|
|
21
51
|
|
|
52
|
+
# @return [Proc, Nil] the custom pagination proc
|
|
22
53
|
def custom_scope
|
|
23
54
|
resource.pagination
|
|
24
55
|
end
|
|
25
56
|
|
|
57
|
+
# Apply default pagination proc via the Resource adapter
|
|
26
58
|
def apply_standard_scope
|
|
27
59
|
resource.adapter.paginate(@scope, number, size)
|
|
28
60
|
end
|
|
29
61
|
|
|
62
|
+
# Apply the custom pagination proc
|
|
30
63
|
def apply_custom_scope
|
|
31
64
|
custom_scope.call(@scope, number, size)
|
|
32
65
|
end
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# Apply sorting logic to the scope.
|
|
3
|
+
#
|
|
4
|
+
# By default, sorting comes 'for free'. To specify a custom sorting proc:
|
|
5
|
+
#
|
|
6
|
+
# class PostResource < ApplicationResource
|
|
7
|
+
# sort do |scope, att, dir|
|
|
8
|
+
# int = dir == :desc ? -1 : 1
|
|
9
|
+
# scope.sort_by { |x| x[att] * int }
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The sorting proc will be called once for each sort att/dir requested.
|
|
14
|
+
# @see Resource.sort
|
|
2
15
|
class Scoping::Sort < Scoping::Base
|
|
16
|
+
# @return [Proc, Nil] The custom proc specified by Resource DSL
|
|
3
17
|
def custom_scope
|
|
4
18
|
resource.sorting
|
|
5
19
|
end
|
|
6
20
|
|
|
21
|
+
# Apply default scope logic via Resource adapter
|
|
22
|
+
# @return the scope we are chaining/modifying
|
|
7
23
|
def apply_standard_scope
|
|
8
24
|
each_sort do |attribute, direction|
|
|
9
25
|
@scope = resource.adapter.order(@scope, attribute, direction)
|
|
@@ -11,6 +27,8 @@ module JsonapiCompliable
|
|
|
11
27
|
@scope
|
|
12
28
|
end
|
|
13
29
|
|
|
30
|
+
# Apply custom scoping proc configured via Resource DSL
|
|
31
|
+
# @return the scope we are chaining/modifying
|
|
14
32
|
def apply_custom_scope
|
|
15
33
|
each_sort do |attribute, direction|
|
|
16
34
|
@scope = custom_scope.call(@scope, attribute, direction)
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
module JsonapiCompliable
|
|
2
|
+
# @attr_reader [Symbol] name The name of the sideload
|
|
3
|
+
# @attr_reader [Class] resource_class The corresponding Resource class
|
|
4
|
+
# @attr_reader [Boolean] polymorphic Is this a polymorphic sideload?
|
|
5
|
+
# @attr_reader [Hash] polymorphic_groups The subgroups, when polymorphic
|
|
6
|
+
# @attr_reader [Hash] sideloads The associated sibling sideloads
|
|
7
|
+
# @attr_reader [Proc] scope_proc The configured 'scope' block
|
|
8
|
+
# @attr_reader [Proc] assign_proc The configured 'assign' block
|
|
9
|
+
# @attr_reader [Proc] grouper The configured 'group_by' proc
|
|
10
|
+
# @attr_reader [Symbol] foreign_key The attribute used to match objects - need not be a true database foreign key.
|
|
11
|
+
# @attr_reader [Symbol] primary_key The attribute used to match objects - need not be a true database primary key.
|
|
12
|
+
# @attr_reader [Symbol] type One of :has_many, :belongs_to, etc
|
|
2
13
|
class Sideload
|
|
3
14
|
attr_reader :name,
|
|
4
15
|
:resource_class,
|
|
@@ -12,6 +23,11 @@ module JsonapiCompliable
|
|
|
12
23
|
:primary_key,
|
|
13
24
|
:type
|
|
14
25
|
|
|
26
|
+
# NB - the adapter's +#sideloading_module+ is mixed in on instantiation
|
|
27
|
+
#
|
|
28
|
+
# An anonymous Resource will be assigned when none provided.
|
|
29
|
+
#
|
|
30
|
+
# @see Adapters::Abstract#sideloading_module
|
|
15
31
|
def initialize(name, type: nil, resource: nil, polymorphic: false, primary_key: :id, foreign_key: nil)
|
|
16
32
|
@name = name
|
|
17
33
|
@resource_class = (resource || Class.new(Resource))
|
|
@@ -25,30 +41,178 @@ module JsonapiCompliable
|
|
|
25
41
|
extend @resource_class.config[:adapter].sideloading_module
|
|
26
42
|
end
|
|
27
43
|
|
|
44
|
+
# @see #resource_class
|
|
45
|
+
# @return [Resource] an instance of +#resource_class+
|
|
28
46
|
def resource
|
|
29
47
|
@resource ||= resource_class.new
|
|
30
48
|
end
|
|
31
49
|
|
|
50
|
+
# Is this sideload polymorphic?
|
|
51
|
+
#
|
|
52
|
+
# Polymorphic sideloads group the parent objects in some fashion,
|
|
53
|
+
# so different 'types' can be resolved differently. Let's say an
|
|
54
|
+
# +Office+ has a polymorphic +Organization+, which can be either a
|
|
55
|
+
# +Business+ or +Government+:
|
|
56
|
+
#
|
|
57
|
+
# allow_sideload :organization, :polymorphic: true do
|
|
58
|
+
# group_by { |record| record.organization_type }
|
|
59
|
+
#
|
|
60
|
+
# allow_sideload 'Business', resource: BusinessResource do
|
|
61
|
+
# # ... code ...
|
|
62
|
+
# end
|
|
63
|
+
#
|
|
64
|
+
# allow_sideload 'Governemnt', resource: GovernmentResource do
|
|
65
|
+
# # ... code ...
|
|
66
|
+
# end
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# You probably want to extract this code into an Adapter. For instance,
|
|
70
|
+
# with ActiveRecord:
|
|
71
|
+
#
|
|
72
|
+
# polymorphic_belongs_to :organization,
|
|
73
|
+
# group_by: ->(office) { office.organization_type },
|
|
74
|
+
# groups: {
|
|
75
|
+
# 'Business' => {
|
|
76
|
+
# scope: -> { Business.all },
|
|
77
|
+
# resource: BusinessResource,
|
|
78
|
+
# foreign_key: :organization_id
|
|
79
|
+
# },
|
|
80
|
+
# 'Government' => {
|
|
81
|
+
# scope: -> { Government.all },
|
|
82
|
+
# resource: GovernmentResource,
|
|
83
|
+
# foreign_key: :organization_id
|
|
84
|
+
# }
|
|
85
|
+
# }
|
|
86
|
+
#
|
|
87
|
+
# @see Adapters::ActiveRecordSideloading#polymorphic_belongs_to
|
|
88
|
+
# @return [Boolean] is this sideload polymorphic?
|
|
32
89
|
def polymorphic?
|
|
33
90
|
@polymorphic == true
|
|
34
91
|
end
|
|
35
92
|
|
|
93
|
+
# Build a scope that will be used to fetch the related records
|
|
94
|
+
# This scope will be further chained with filtering/sorting/etc
|
|
95
|
+
#
|
|
96
|
+
# You probably want to wrap this logic in an Adapter, instead of
|
|
97
|
+
# specifying in your resource directly.
|
|
98
|
+
#
|
|
99
|
+
# @example Default ActiveRecord
|
|
100
|
+
# class PostResource < ApplicationResource
|
|
101
|
+
# # ... code ...
|
|
102
|
+
# allow_sideload :comments, resource: CommentResource do
|
|
103
|
+
# scope do |posts|
|
|
104
|
+
# Comment.where(post_id: posts.map(&:id))
|
|
105
|
+
# end
|
|
106
|
+
# # ... code ...
|
|
107
|
+
# end
|
|
108
|
+
# end
|
|
109
|
+
#
|
|
110
|
+
# @example Custom Scope
|
|
111
|
+
# # In this example, our base scope is a Hash
|
|
112
|
+
# scope do |posts|
|
|
113
|
+
# { post_ids: posts.map(&:id) }
|
|
114
|
+
# end
|
|
115
|
+
#
|
|
116
|
+
# @example ActiveRecord via Adapter
|
|
117
|
+
# class PostResource < ApplicationResource
|
|
118
|
+
# # ... code ...
|
|
119
|
+
# has_many :comments,
|
|
120
|
+
# scope: -> { Comment.all },
|
|
121
|
+
# resource: CommentResource,
|
|
122
|
+
# foreign_key: :post_id
|
|
123
|
+
# end
|
|
124
|
+
#
|
|
125
|
+
# @see Adapters::Abstract
|
|
126
|
+
# @see Adapters::ActiveRecordSideloading#has_many
|
|
127
|
+
# @see #allow_sideload
|
|
128
|
+
# @yieldparam parents - The resolved parent records
|
|
36
129
|
def scope(&blk)
|
|
37
130
|
@scope_proc = blk
|
|
38
131
|
end
|
|
39
132
|
|
|
133
|
+
# The proc used to assign the resolved parents and children.
|
|
134
|
+
#
|
|
135
|
+
# You probably want to wrap this logic in an Adapter, instead of
|
|
136
|
+
# specifying in your resource directly.
|
|
137
|
+
#
|
|
138
|
+
# @example Default ActiveRecord
|
|
139
|
+
# class PostResource < ApplicationResource
|
|
140
|
+
# # ... code ...
|
|
141
|
+
# allow_sideload :comments, resource: CommentResource do
|
|
142
|
+
# # ... code ...
|
|
143
|
+
# assign do |posts, comments|
|
|
144
|
+
# posts.each do |post|
|
|
145
|
+
# relevant_comments = comments.select { |c| c.post_id == post.id }
|
|
146
|
+
# post.comments = relevant_comments
|
|
147
|
+
# end
|
|
148
|
+
# end
|
|
149
|
+
# end
|
|
150
|
+
# end
|
|
151
|
+
#
|
|
152
|
+
# @example ActiveRecord via Adapter
|
|
153
|
+
# class PostResource < ApplicationResource
|
|
154
|
+
# # ... code ...
|
|
155
|
+
# has_many :comments,
|
|
156
|
+
# scope: -> { Comment.all },
|
|
157
|
+
# resource: CommentResource,
|
|
158
|
+
# foreign_key: :post_id
|
|
159
|
+
# end
|
|
160
|
+
#
|
|
161
|
+
# @see Adapters::Abstract
|
|
162
|
+
# @see Adapters::ActiveRecordSideloading#has_many
|
|
163
|
+
# @see #allow_sideload
|
|
164
|
+
# @yieldparam parents - The resolved parent records
|
|
165
|
+
# @yieldparam children - The resolved child records
|
|
40
166
|
def assign(&blk)
|
|
41
167
|
@assign_proc = blk
|
|
42
168
|
end
|
|
43
169
|
|
|
170
|
+
# Configure how to associate parent and child records.
|
|
171
|
+
#
|
|
172
|
+
# @example Basic attr_accessor
|
|
173
|
+
# def associate(parent, child)
|
|
174
|
+
# if type == :has_many
|
|
175
|
+
# parent.send(:"#{name}").push(child)
|
|
176
|
+
# else
|
|
177
|
+
# child.send(:"#{name}=", parent)
|
|
178
|
+
# end
|
|
179
|
+
# end
|
|
180
|
+
#
|
|
181
|
+
# @see #name
|
|
182
|
+
# @see #type
|
|
44
183
|
def associate(parent, child)
|
|
45
184
|
resource_class.config[:adapter].associate(parent, child, name, type)
|
|
46
185
|
end
|
|
47
186
|
|
|
187
|
+
# Define a proc that groups the parent records. For instance, with
|
|
188
|
+
# an ActiveRecord polymorphic belongs_to there will be a +parent_id+
|
|
189
|
+
# and +parent_type+. We would want to group on +parent_type+:
|
|
190
|
+
#
|
|
191
|
+
# allow_sideload :organization, polymorphic: true do
|
|
192
|
+
# # group parent_type, parent here is 'organization'
|
|
193
|
+
# group_by ->(office) { office.organization_type }
|
|
194
|
+
# end
|
|
195
|
+
#
|
|
196
|
+
# @see #polymorphic?
|
|
48
197
|
def group_by(&grouper)
|
|
49
198
|
@grouper = grouper
|
|
50
199
|
end
|
|
51
200
|
|
|
201
|
+
# Resolve the sideload.
|
|
202
|
+
#
|
|
203
|
+
# * Uses the 'scope' proc to build a 'base scope'
|
|
204
|
+
# * Chains additional criteria onto that 'base scope'
|
|
205
|
+
# * Resolves that scope (see Scope#resolve)
|
|
206
|
+
# * Assigns the resulting child objects to their corresponding parents
|
|
207
|
+
#
|
|
208
|
+
# @see Scope#resolve
|
|
209
|
+
# @param [Object] parents The resolved parent models
|
|
210
|
+
# @param [Query] query The Query instance
|
|
211
|
+
# @param [Symbol] namespace The current namespace (see Resource#with_context)
|
|
212
|
+
# @see Query
|
|
213
|
+
# @see Resource#with_context
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @api private
|
|
52
216
|
def resolve(parents, query, namespace = nil)
|
|
53
217
|
namespace ||= name
|
|
54
218
|
|
|
@@ -59,6 +223,44 @@ module JsonapiCompliable
|
|
|
59
223
|
end
|
|
60
224
|
end
|
|
61
225
|
|
|
226
|
+
# Configure a relationship between Resource objects
|
|
227
|
+
#
|
|
228
|
+
# You probably want to extract this logic into an adapter
|
|
229
|
+
# rather than using directly
|
|
230
|
+
#
|
|
231
|
+
# @example Default ActiveRecord
|
|
232
|
+
# # What happens 'under the hood'
|
|
233
|
+
# class CommentResource < ApplicationResource
|
|
234
|
+
# # ... code ...
|
|
235
|
+
# allow_sideload :post, resource: PostResource do
|
|
236
|
+
# scope do |comments|
|
|
237
|
+
# Post.where(id: comments.map(&:post_id))
|
|
238
|
+
# end
|
|
239
|
+
#
|
|
240
|
+
# assign do |comments, posts|
|
|
241
|
+
# comments.each do |comment|
|
|
242
|
+
# relevant_post = posts.find { |p| p.id == comment.post_id }
|
|
243
|
+
# comment.post = relevant_post
|
|
244
|
+
# end
|
|
245
|
+
# end
|
|
246
|
+
# end
|
|
247
|
+
# end
|
|
248
|
+
#
|
|
249
|
+
# # Rather than writing that code directly, go through the adapter:
|
|
250
|
+
# class CommentResource < ApplicationResource
|
|
251
|
+
# # ... code ...
|
|
252
|
+
# use_adapter JsonapiCompliable::Adapters::ActiveRecord
|
|
253
|
+
#
|
|
254
|
+
# belongs_to :post,
|
|
255
|
+
# scope: -> { Post.all },
|
|
256
|
+
# resource: PostResource,
|
|
257
|
+
# foreign_key: :post_id
|
|
258
|
+
# end
|
|
259
|
+
#
|
|
260
|
+
# @see Adapters::ActiveRecordSideloading#belongs_to
|
|
261
|
+
# @see #assign
|
|
262
|
+
# @see #scope
|
|
263
|
+
# @return void
|
|
62
264
|
def allow_sideload(name, opts = {}, &blk)
|
|
63
265
|
sideload = Sideload.new(name, opts)
|
|
64
266
|
sideload.instance_eval(&blk) if blk
|
|
@@ -70,11 +272,37 @@ module JsonapiCompliable
|
|
|
70
272
|
end
|
|
71
273
|
end
|
|
72
274
|
|
|
275
|
+
# Fetch a Sideload object by its name
|
|
276
|
+
# @param [Symbol] name The name of the corresponding sideload
|
|
277
|
+
# @see +allow_sideload
|
|
278
|
+
# @return the corresponding Sideload object
|
|
73
279
|
def sideload(name)
|
|
74
280
|
@sideloads[name]
|
|
75
281
|
end
|
|
76
282
|
|
|
77
|
-
#
|
|
283
|
+
# Looks at all nested sideload, and all nested sideloads for the
|
|
284
|
+
# corresponding Resources, and returns an Include Directive hash
|
|
285
|
+
#
|
|
286
|
+
# For instance, this configuration:
|
|
287
|
+
#
|
|
288
|
+
# class BarResource < ApplicationResource
|
|
289
|
+
# allow_sideload :baz do
|
|
290
|
+
# end
|
|
291
|
+
# end
|
|
292
|
+
#
|
|
293
|
+
# class PostResource < ApplicationResource
|
|
294
|
+
# allow_sideload :foo do
|
|
295
|
+
# allow_sideload :bar, resource: BarResource do
|
|
296
|
+
# end
|
|
297
|
+
# end
|
|
298
|
+
# end
|
|
299
|
+
#
|
|
300
|
+
# +post_resource.sideloading.to_hash+ would return
|
|
301
|
+
#
|
|
302
|
+
# { base: { foo: { bar: { baz: {} } } } }
|
|
303
|
+
#
|
|
304
|
+
# @return [Hash] The nested include hash
|
|
305
|
+
# @api private
|
|
78
306
|
def to_hash(processed = [])
|
|
79
307
|
return { name => {} } if processed.include?(self)
|
|
80
308
|
processed << self
|