jsonapi_compliable 0.11.34 → 1.0.alpha.2

Sign up to get free protection for your applications and to get access to all the features.
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