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.
Files changed (73) hide show
  1. checksums.yaml +5 -5
  2. data/.ruby-version +1 -1
  3. data/.travis.yml +1 -2
  4. data/Rakefile +7 -3
  5. data/jsonapi_compliable.gemspec +7 -3
  6. data/lib/generators/jsonapi/resource_generator.rb +8 -79
  7. data/lib/generators/jsonapi/templates/application_resource.rb.erb +2 -1
  8. data/lib/generators/jsonapi/templates/controller.rb.erb +19 -64
  9. data/lib/generators/jsonapi/templates/resource.rb.erb +5 -47
  10. data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
  11. data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
  12. data/lib/jsonapi_compliable.rb +87 -18
  13. data/lib/jsonapi_compliable/adapters/abstract.rb +202 -45
  14. data/lib/jsonapi_compliable/adapters/active_record.rb +6 -130
  15. data/lib/jsonapi_compliable/adapters/active_record/base.rb +247 -0
  16. data/lib/jsonapi_compliable/adapters/active_record/belongs_to_sideload.rb +17 -0
  17. data/lib/jsonapi_compliable/adapters/active_record/has_many_sideload.rb +17 -0
  18. data/lib/jsonapi_compliable/adapters/active_record/has_one_sideload.rb +17 -0
  19. data/lib/jsonapi_compliable/adapters/active_record/inferrence.rb +12 -0
  20. data/lib/jsonapi_compliable/adapters/active_record/many_to_many_sideload.rb +30 -0
  21. data/lib/jsonapi_compliable/adapters/null.rb +177 -6
  22. data/lib/jsonapi_compliable/base.rb +33 -320
  23. data/lib/jsonapi_compliable/context.rb +16 -0
  24. data/lib/jsonapi_compliable/deserializer.rb +14 -39
  25. data/lib/jsonapi_compliable/errors.rb +227 -24
  26. data/lib/jsonapi_compliable/extensions/extra_attribute.rb +3 -1
  27. data/lib/jsonapi_compliable/filter_operators.rb +25 -0
  28. data/lib/jsonapi_compliable/hash_renderer.rb +57 -0
  29. data/lib/jsonapi_compliable/query.rb +190 -202
  30. data/lib/jsonapi_compliable/rails.rb +12 -6
  31. data/lib/jsonapi_compliable/railtie.rb +64 -0
  32. data/lib/jsonapi_compliable/renderer.rb +60 -0
  33. data/lib/jsonapi_compliable/resource.rb +35 -663
  34. data/lib/jsonapi_compliable/resource/configuration.rb +239 -0
  35. data/lib/jsonapi_compliable/resource/dsl.rb +138 -0
  36. data/lib/jsonapi_compliable/resource/interface.rb +32 -0
  37. data/lib/jsonapi_compliable/resource/polymorphism.rb +68 -0
  38. data/lib/jsonapi_compliable/resource/sideloading.rb +102 -0
  39. data/lib/jsonapi_compliable/resource_proxy.rb +127 -0
  40. data/lib/jsonapi_compliable/responders.rb +19 -0
  41. data/lib/jsonapi_compliable/runner.rb +25 -0
  42. data/lib/jsonapi_compliable/scope.rb +37 -79
  43. data/lib/jsonapi_compliable/scoping/extra_attributes.rb +29 -0
  44. data/lib/jsonapi_compliable/scoping/filter.rb +39 -58
  45. data/lib/jsonapi_compliable/scoping/filterable.rb +9 -14
  46. data/lib/jsonapi_compliable/scoping/paginate.rb +9 -3
  47. data/lib/jsonapi_compliable/scoping/sort.rb +16 -4
  48. data/lib/jsonapi_compliable/sideload.rb +221 -347
  49. data/lib/jsonapi_compliable/sideload/belongs_to.rb +34 -0
  50. data/lib/jsonapi_compliable/sideload/has_many.rb +16 -0
  51. data/lib/jsonapi_compliable/sideload/has_one.rb +9 -0
  52. data/lib/jsonapi_compliable/sideload/many_to_many.rb +24 -0
  53. data/lib/jsonapi_compliable/sideload/polymorphic_belongs_to.rb +108 -0
  54. data/lib/jsonapi_compliable/stats/payload.rb +4 -8
  55. data/lib/jsonapi_compliable/types.rb +172 -0
  56. data/lib/jsonapi_compliable/util/attribute_check.rb +88 -0
  57. data/lib/jsonapi_compliable/util/persistence.rb +29 -7
  58. data/lib/jsonapi_compliable/util/relationship_payload.rb +4 -4
  59. data/lib/jsonapi_compliable/util/render_options.rb +4 -32
  60. data/lib/jsonapi_compliable/util/serializer_attributes.rb +98 -0
  61. data/lib/jsonapi_compliable/util/validation_response.rb +15 -9
  62. data/lib/jsonapi_compliable/version.rb +1 -1
  63. metadata +105 -24
  64. data/lib/generators/jsonapi/field_generator.rb +0 -0
  65. data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +0 -29
  66. data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +0 -20
  67. data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +0 -22
  68. data/lib/generators/jsonapi/templates/payload.rb.erb +0 -39
  69. data/lib/generators/jsonapi/templates/serializer.rb.erb +0 -25
  70. data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +0 -19
  71. data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +0 -33
  72. data/lib/jsonapi_compliable/adapters/active_record_sideloading.rb +0 -152
  73. data/lib/jsonapi_compliable/scoping/extra_fields.rb +0 -58
@@ -0,0 +1,25 @@
1
+ module JsonapiCompliable
2
+ class FilterOperators
3
+ class Catchall
4
+ attr_reader :procs
5
+
6
+ def initialize
7
+ @procs = {}
8
+ end
9
+
10
+ def method_missing(name, *args, &blk)
11
+ @procs[name] = blk
12
+ end
13
+
14
+ def to_hash
15
+ @procs
16
+ end
17
+ end
18
+
19
+ def self.build(&blk)
20
+ c = Catchall.new
21
+ c.instance_eval(&blk) if blk
22
+ c.to_hash
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ module JsonapiCompliable
2
+ module SerializableHash
3
+ def to_hash(fields: nil, include: {})
4
+ {}.tap do |hash|
5
+ _fields = fields[jsonapi_type] if fields
6
+ attrs = requested_attributes(_fields).each_with_object({}) do |(k, v), h|
7
+ h[k] = instance_eval(&v)
8
+ end
9
+ rels = @_relationships.select { |k,v| !!include[k] }
10
+ rels.each_with_object({}) do |(k, v), h|
11
+ serializers = v.send(:resources)
12
+ attrs[k] = if serializers.is_a?(Array)
13
+ serializers.map do |rr| # use private method to avoid array casting
14
+ rr.to_hash(fields: fields, include: include[k])
15
+ end
16
+ elsif serializers.nil?
17
+ nil
18
+ else
19
+ serializers.to_hash(fields: fields, include: include[k])
20
+ end
21
+ end
22
+
23
+ hash[:id] = jsonapi_id
24
+ hash.merge!(attrs) if attrs.any?
25
+ end
26
+ end
27
+ end
28
+ JSONAPI::Serializable::Resource.send(:include, SerializableHash)
29
+
30
+ class HashRenderer
31
+ def initialize(resource)
32
+ @resource = resource
33
+ end
34
+
35
+ def render(options)
36
+ serializers = options[:data]
37
+ opts = options.slice(:fields, :include)
38
+ to_hash(serializers, opts).tap do |hash|
39
+ hash.merge!(options.slice(:meta)) if !options[:meta].empty?
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def to_hash(serializers, opts)
46
+ {}.tap do |hash|
47
+ if serializers.is_a?(Array)
48
+ hash[@resource.type] = serializers.map do |s|
49
+ s.to_hash(opts)
50
+ end
51
+ else
52
+ hash[@resource.type] = serializers.to_hash(opts)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,263 +1,251 @@
1
1
  module JsonapiCompliable
2
- # @attr_reader [Hash] params hash of query parameters with symbolized keys
3
- # @attr_reader [Resource] resource the corresponding Resource object
4
2
  class Query
5
- # TODO: This class could use some refactoring love!
6
- attr_reader :params, :resource
7
-
8
- # This is the structure of +Query#to_hash+ used elsewhere in the library
9
- # @see #to_hash
10
- # @api private
11
- # @return [Hash] the default hash
12
- def self.default_hash
13
- {
14
- filter: {},
15
- sort: [],
16
- page: {},
17
- include: {},
18
- stats: {},
19
- fields: {},
20
- extra_fields: {}
21
- }
22
- end
3
+ attr_reader :resource, :include_hash, :association_name
23
4
 
24
- def initialize(resource, params)
5
+ def initialize(resource, params, association_name = nil, nested_include = nil, parents = [])
25
6
  @resource = resource
7
+ @association_name = association_name
26
8
  @params = params
27
9
  @params = @params.permit! if @params.respond_to?(:permit!)
10
+ @params = @params.to_h if @params.respond_to?(:to_h)
11
+ @params = @params.deep_symbolize_keys
12
+ @include_param = nested_include || @params[:include]
13
+ @parents = parents
28
14
  end
29
15
 
30
- # The relevant include directive
31
- # @see http://jsonapi-rb.org
32
- # @return [JSONAPI::IncludeDirective]
33
- def include_directive
34
- @include_directive ||= JSONAPI::IncludeDirective.new(params[:include])
35
- end
36
-
37
- # The include, directive, as a hash. For instance
38
- #
39
- # { posts: { comments: {} } }
40
- #
41
- # This will only include relationships that are
42
- #
43
- # * Available on the Resource
44
- # * Whitelisted (when specified)
45
- #
46
- # So that users can't simply request your entire object graph.
47
- #
48
- # @see Util::IncludeParams
49
- # @return [Hash] the scrubbed include directive as a hash
50
- def include_hash
51
- @include_hash ||= begin
52
- requested = include_directive.to_hash
53
-
54
- whitelist = nil
55
- if resource.context
56
- whitelist = resource.context.sideload_whitelist
57
- whitelist = whitelist[resource.context_namespace] if whitelist
58
- end
59
-
60
- whitelist ? Util::IncludeParams.scrub(requested, whitelist) : requested
61
- end
16
+ def association?
17
+ !!@association_name
62
18
  end
63
19
 
64
- # All the keys of the #include_hash
65
- #
66
- # For example, let's say we had
67
- #
68
- # { posts: { comments: {} }
69
- #
70
- # +#association_names+ would return
71
- #
72
- # [:posts, :comments]
73
- #
74
- # @return [Array<Symbol>] all association names, recursive
75
- def association_names
76
- @association_names ||= Util::Hash.keys(include_hash)
20
+ def top_level?
21
+ not association?
77
22
  end
78
23
 
79
- # A flat hash of sanitized query parameters.
80
- # All relationship names are top-level:
81
- #
82
- # {
83
- # posts: { filter, sort, ... }
84
- # comments: { filter, sort, ... }
85
- # }
86
- #
87
- # @example sorting
88
- # # GET /posts?sort=-title
89
- # { posts: { sort: { title: :desc } } }
90
- #
91
- # @example pagination
92
- # # GET /posts?page[number]=2&page[size]=10
93
- # { posts: { page: { number: 2, size: 10 } }
94
- #
95
- # @example filtering
96
- # # GET /posts?filter[title]=Foo
97
- # { posts: { filter: { title: 'Foo' } }
98
- #
99
- # @example include
100
- # # GET /posts?include=comments.author
101
- # { posts: { include: { comments: { author: {} } } } }
102
- #
103
- # @example stats
104
- # # GET /posts?stats[likes]=count,average
105
- # { posts: { stats: [:count, :average] } }
106
- #
107
- # @example fields
108
- # # GET /posts?fields=foo,bar
109
- # { posts: { fields: [:foo, :bar] } }
110
- #
111
- # @example extra fields
112
- # # GET /posts?fields=foo,bar
113
- # { posts: { extra_fields: [:foo, :bar] } }
114
- #
115
- # @example nested parameters
116
- # # GET /posts?include=comments&sort=comments.created_at&page[comments][size]=3
117
- # {
118
- # posts: { ... },
119
- # comments: { page: { size: 3 }, sort: { created_at: :asc } }
120
- #
121
- # @see #default_hash
122
- # @see Base#query_hash
123
- # @return [Hash] the normalized query hash
124
24
  def to_hash
125
- hash = { resource.type => self.class.default_hash }
126
-
127
- association_names.each do |name|
128
- hash[name] = self.class.default_hash
129
- end
130
-
131
- fields = parse_fields({}, :fields)
132
- extra_fields = parse_fields({}, :extra_fields)
133
- hash.each_pair do |type, query_hash|
134
- hash[type][:fields] = fields
135
- hash[type][:extra_fields] = extra_fields
25
+ {}.tap do |hash|
26
+ hash[:filter] = filters unless filters.empty?
27
+ hash[:sort] = sorts unless sorts.empty?
28
+ hash[:page] = pagination unless pagination.empty?
29
+ unless association?
30
+ hash[:fields] = fields unless fields.empty?
31
+ hash[:extra_fields] = extra_fields unless extra_fields.empty?
32
+ end
33
+ hash[:stats] = stats unless stats.empty?
34
+ hash[:include] = sideload_hash unless sideload_hash.empty?
136
35
  end
137
-
138
- parse_filter(hash)
139
- parse_sort(hash)
140
- parse_pagination(hash)
141
-
142
- parse_include(hash, include_hash, resource.type)
143
- parse_stats(hash)
144
-
145
- hash
146
36
  end
147
37
 
148
- # Check if the user has requested 0 actual results
149
- # They may have done this to get, say, the total count
150
- # without the overhead of fetching actual records.
151
- #
152
- # @example Total Count, 0 Results
153
- # # GET /posts?page[size]=0&stats[total]=count
154
- # # Response:
155
- # {
156
- # data: [],
157
- # meta: {
158
- # stats: { total: { count: 100 } }
159
- # }
160
- # }
161
- #
162
- # @return [Boolean] were 0 results requested?
163
38
  def zero_results?
164
39
  !@params[:page].nil? &&
165
40
  !@params[:page][:size].nil? &&
166
41
  @params[:page][:size].to_i == 0
167
42
  end
168
43
 
169
- private
44
+ def sideload_hash
45
+ @sideload_hash = begin
46
+ {}.tap do |hash|
47
+ sideloads.each_pair do |key, value|
48
+ hash[key] = sideloads[key].to_hash
49
+ end
50
+ end
51
+ end
52
+ end
170
53
 
171
- def association?(name)
172
- resource.association_names.include?(name)
54
+ def sideloads
55
+ @sideloads ||= begin
56
+ {}.tap do |hash|
57
+ include_hash.each_pair do |key, sub_hash|
58
+ sideload = @resource.class.sideload(key)
59
+ if sideload
60
+ _parents = parents + [self]
61
+ hash[key] = Query.new(sideload.resource, @params, key, sub_hash, _parents)
62
+ else
63
+ handle_missing_sideload(key)
64
+ end
65
+ end
66
+ end
67
+ end
173
68
  end
174
69
 
175
- def parse_include(memo, incl_hash, namespace)
176
- memo[namespace] ||= self.class.default_hash
177
- memo[namespace].merge!(include: incl_hash)
70
+ def parents
71
+ @parents ||= []
72
+ end
178
73
 
179
- incl_hash.each_pair do |key, sub_hash|
180
- key = Util::Sideload.namespace(namespace, key)
181
- memo.merge!(parse_include(memo, sub_hash, key))
74
+ def fields
75
+ @fields ||= begin
76
+ hash = parse_fieldset(@params[:fields] || {})
77
+ hash.each_pair do |type, fields|
78
+ hash[type] += extra_fields[type] if extra_fields[type]
79
+ end
80
+ hash
182
81
  end
82
+ end
183
83
 
184
- memo
84
+ def extra_fields
85
+ @extra_fields ||= parse_fieldset(@params[:extra_fields] || {})
185
86
  end
186
87
 
187
- def parse_stats(hash)
188
- if params[:stats]
189
- params[:stats].each_pair do |namespace, calculations|
190
- if namespace == resource.type || association?(namespace)
191
- calculations.each_pair do |name, calcs|
192
- hash[namespace][:stats][name] = calcs.split(',').map(&:to_sym)
88
+ def filters
89
+ @filters ||= begin
90
+ {}.tap do |hash|
91
+ (@params[:filter] || {}).each_pair do |name, value|
92
+ name = name.to_sym
93
+
94
+ if legacy_nested?(name)
95
+ filter_name = value.keys.first.to_sym
96
+ filter_value = value.values.first
97
+ if @resource.get_attr!(filter_name, :filterable, request: true)
98
+ hash[filter_name] = filter_value
99
+ end
100
+ elsif nested?(name)
101
+ name = name.to_s.split('.').last.to_sym
102
+ validate!(name, :filterable)
103
+ hash[name] = value
104
+ elsif top_level? && validate!(name, :filterable)
105
+ hash[name] = value
193
106
  end
194
- else
195
- hash[resource.type][:stats][namespace] = calculations.split(',').map(&:to_sym)
196
107
  end
197
108
  end
198
109
  end
199
110
  end
200
111
 
201
- def parse_fields(hash, type)
202
- field_params = Util::FieldParams.parse(params[type])
203
- hash[type] = field_params
112
+ def sorts
113
+ @sorts ||= begin
114
+ return @params[:sort] if @params[:sort].is_a?(Array)
115
+ return [] if @params[:sort].nil?
116
+
117
+ [].tap do |arr|
118
+ sort_hashes do |key, value, type|
119
+ if legacy_nested?(type)
120
+ @resource.get_attr!(key, :sortable, request: true)
121
+ arr << { key => value }
122
+ elsif !type && top_level? && validate!(key, :sortable)
123
+ arr << { key => value }
124
+ end
125
+ end
126
+ end
127
+ end
204
128
  end
205
129
 
206
- def parse_filter(hash)
207
- if filter = params[:filter]
208
- filter.each_pair do |key, value|
209
- key = key.to_sym
210
-
211
- if association?(key)
212
- symbolized_hash = value.to_h.each_with_object({}) do |(k, v), hash|
213
- hash[k.to_sym] = v
130
+ def pagination
131
+ @pagination ||= begin
132
+ {}.tap do |hash|
133
+ (@params[:page] || {}).each_pair do |name, value|
134
+ if legacy_nested?(name)
135
+ value.each_pair do |k,v|
136
+ hash[k.to_sym] = v.to_i
137
+ end
138
+ elsif top_level? && [:number, :size].include?(name.to_sym)
139
+ hash[name.to_sym] = value.to_i
214
140
  end
215
- hash[key][:filter].merge!(symbolized_hash)
216
- else
217
- hash[resource.type][:filter][key] = value
218
141
  end
219
142
  end
220
143
  end
221
144
  end
222
145
 
223
- def parse_sort(hash)
224
- if sort = params[:sort]
225
- sorts = sort.split(',')
226
- sorts.each do |s|
227
- if s.include?('.')
228
- type, attr = s.split('.')
229
- if type.starts_with?('-')
230
- type = type.sub('-', '')
231
- attr = "-#{attr}"
232
- end
146
+ def include_hash
147
+ @include_hash ||= begin
148
+ requested = include_directive.to_hash
149
+
150
+ whitelist = nil
151
+ if @resource.context && @resource.context.respond_to?(:sideload_whitelist)
152
+ whitelist = @resource.context.sideload_whitelist
153
+ whitelist = whitelist[@resource.context_namespace] if whitelist
154
+ end
155
+
156
+ whitelist ? Util::IncludeParams.scrub(requested, whitelist) : requested
157
+ end
158
+ end
233
159
 
234
- hash[type.to_sym][:sort] << sort_attr(attr)
235
- else
236
- hash[resource.type][:sort] << sort_attr(s)
160
+ def stats
161
+ @stats ||= begin
162
+ {}.tap do |hash|
163
+ (@params[:stats] || {}).each_pair do |k, v|
164
+ if legacy_nested?(k)
165
+ raise NotImplementedError.new('Association statistics are not currently supported')
166
+ elsif top_level?
167
+ v = v.split(',') if v.is_a?(String)
168
+ hash[k.to_sym] = Array(v).flatten.map(&:to_sym)
169
+ end
237
170
  end
238
171
  end
239
172
  end
240
173
  end
241
174
 
242
- def parse_pagination(hash)
243
- if pagination = params[:page]
244
- pagination.each_pair do |key, value|
245
- key = key.to_sym
175
+ private
246
176
 
247
- if [:number, :size].include?(key)
248
- hash[resource.type][:page][key] = value.to_i
249
- else
250
- hash[key][:page] = { number: value[:number].to_i, size: value[:size].to_i }
251
- end
177
+ def validate!(name, flag)
178
+ return false if name.to_s.include?('.') # nested
179
+
180
+ not_associated_name = !@resource.class.association_names.include?(name)
181
+ not_associated_type = !@resource.class.association_types.include?(name)
182
+
183
+ if not_associated_name && not_associated_type
184
+ @resource.get_attr!(name, flag, request: true)
185
+ return true
186
+ end
187
+ false
188
+ end
189
+
190
+ def nested?(name)
191
+ return false unless association?
192
+ split = name.to_s.split('.')
193
+ query_names = split[0..split.length-2].map(&:to_sym)
194
+ my_names = parents.map(&:association_name).compact + [association_name].compact
195
+ query_names == my_names
196
+ end
197
+
198
+ def legacy_nested?(name)
199
+ association? &&
200
+ (name == @resource.type || name == @association_name)
201
+ end
202
+
203
+ def parse_fieldset(fieldset)
204
+ {}.tap do |hash|
205
+ fieldset.each_pair do |type, fields|
206
+ type = type.to_sym
207
+ fields = fields.split(',') unless fields.is_a?(Array)
208
+ hash[type] = fields.map(&:to_sym)
252
209
  end
253
210
  end
254
211
  end
255
212
 
256
- def sort_attr(attr)
257
- value = attr.starts_with?('-') ? :desc : :asc
213
+ def include_directive
214
+ @include_directive ||= JSONAPI::IncludeDirective.new(@include_param)
215
+ end
216
+
217
+ def handle_missing_sideload(name)
218
+ if JsonapiCompliable.config.raise_on_missing_sideload
219
+ raise JsonapiCompliable::Errors::InvalidInclude
220
+ .new(name, @resource.type)
221
+ end
222
+ end
223
+
224
+ def sort_hash(attr)
225
+ value = attr[0] == '-' ? :desc : :asc
258
226
  key = attr.sub('-', '').to_sym
259
227
 
260
228
  { key => value }
261
229
  end
230
+
231
+ def sort_hashes
232
+ sorts = @params[:sort].split(',')
233
+ sorts.each do |s|
234
+ type, attr = s.split('.')
235
+
236
+ if attr.nil? # top-level
237
+ next if @association_name
238
+ hash = sort_hash(type)
239
+ yield hash.keys.first.to_sym, hash.values.first
240
+ else
241
+ if type[0] == '-'
242
+ type = type.sub('-', '')
243
+ attr = "-#{attr}"
244
+ end
245
+ hash = sort_hash(attr)
246
+ yield hash.keys.first.to_sym, hash.values.first, type.to_sym
247
+ end
248
+ end
249
+ end
262
250
  end
263
251
  end