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,16 @@
1
+ module JsonapiCompliable
2
+ module Context
3
+ extend ActiveSupport::Concern
4
+
5
+ module Overrides
6
+ def sideload_whitelist=(val)
7
+ super(JSONAPI::IncludeDirective.new(val).to_hash)
8
+ end
9
+ end
10
+
11
+ included do
12
+ class_attribute :sideload_whitelist
13
+ class << self;prepend Overrides;end
14
+ end
15
+ end
16
+ end
@@ -45,26 +45,18 @@
45
45
  #
46
46
  # { type: 'authors', method: :create, temp_id: 'abc123' }
47
47
  class JsonapiCompliable::Deserializer
48
- # @param payload [Hash] The incoming payload with symbolized keys
49
- # @param env [Hash] the Rack env (e.g. +request.env+).
50
- def initialize(payload, env)
51
- @payload = payload || {}
48
+ def initialize(payload)
49
+ @payload = payload
52
50
  @payload = @payload[:_jsonapi] if @payload.has_key?(:_jsonapi)
53
- @env = env
54
- validate_content_type
55
51
  end
56
52
 
57
- # checks Content-Type header and prints a warning if it doesn't seem correct
58
- def validate_content_type
59
- content_type = @env['CONTENT_TYPE'] || ""
60
- if !(content_type.include?("application/json") || content_type.include?("application/vnd.api+json"))
61
- print("WARNING - JSONAPI Compliable :: Content-Type header appears to be set to an invalid value: #{content_type}\n")
62
- end
53
+ def params
54
+ @payload
63
55
  end
64
56
 
65
57
  # @return [Hash] the raw :data value of the payload
66
58
  def data
67
- @payload[:data] || {}
59
+ @payload[:data]
68
60
  end
69
61
 
70
62
  # @return [String] the raw :id value of the payload
@@ -92,11 +84,11 @@ class JsonapiCompliable::Deserializer
92
84
  # +temp_id+: the +temp-id+, if specified
93
85
  #
94
86
  # @return [Hash]
95
- def meta
87
+ def meta(action: :update)
96
88
  {
97
89
  type: data[:type],
98
90
  temp_id: data[:'temp-id'],
99
- method: method
91
+ method: action
100
92
  }
101
93
  end
102
94
 
@@ -105,20 +97,11 @@ class JsonapiCompliable::Deserializer
105
97
  @relationships ||= process_relationships(raw_relationships)
106
98
  end
107
99
 
108
- # Parses the +relationships+ recursively and builds an all-hash
109
- # include directive like
110
- #
111
- # { posts: { comments: {} } }
112
- #
113
- # Relationships that have been marked for destruction will NOT
114
- # be part of the include directive.
115
- #
116
- # @return [Hash] the include directive
117
- def include_directive(memo = {}, relationship_node = nil)
100
+ def include_hash(memo = {}, relationship_node = nil)
118
101
  relationship_node ||= relationships
119
102
 
120
103
  relationship_node.each_pair do |name, relationship_payload|
121
- merge_include_directive(memo, name, relationship_payload)
104
+ merge_include_hash(memo, name, relationship_payload)
122
105
  end
123
106
 
124
107
  memo
@@ -126,12 +109,12 @@ class JsonapiCompliable::Deserializer
126
109
 
127
110
  private
128
111
 
129
- def merge_include_directive(memo, name, relationship_payload)
112
+ def merge_include_hash(memo, name, relationship_payload)
130
113
  arrayified = [relationship_payload].flatten
131
114
  return if arrayified.all? { |rp| removed?(rp) }
132
115
 
133
116
  memo[name] ||= {}
134
- deep_merge!(memo[name], sub_directives(memo[name], arrayified))
117
+ deep_merge!(memo[name], nested_include_hashes(memo[name], arrayified))
135
118
  memo
136
119
  end
137
120
 
@@ -139,24 +122,16 @@ class JsonapiCompliable::Deserializer
139
122
  @payload[:included] || []
140
123
  end
141
124
 
142
- def method
143
- case @env['REQUEST_METHOD']
144
- when 'POST' then :create
145
- when 'PUT', 'PATCH' then :update
146
- when 'DELETE' then :destroy
147
- end
148
- end
149
-
150
125
  def removed?(relationship_payload)
151
126
  method = relationship_payload[:meta][:method]
152
127
  [:disassociate, :destroy].include?(method)
153
128
  end
154
129
 
155
- def sub_directives(memo, relationship_payloads)
130
+ def nested_include_hashes(memo, relationship_payloads)
156
131
  {}.tap do |subs|
157
132
  relationship_payloads.each do |rp|
158
- sub_directive = include_directive(memo, rp[:relationships])
159
- deep_merge!(subs, sub_directive)
133
+ nested = include_hash(memo, rp[:relationships])
134
+ deep_merge!(subs, nested)
160
135
  end
161
136
  end
162
137
  end
@@ -1,8 +1,98 @@
1
1
  module JsonapiCompliable
2
2
  module Errors
3
- class BadFilter < StandardError; end
3
+ class Base < StandardError;end
4
4
 
5
- class ValidationError < StandardError
5
+ class AdapterNotImplemented < Base
6
+ def initialize(adapter, attribute, method)
7
+ @adapter = adapter
8
+ @attribute = attribute
9
+ @method = method
10
+ end
11
+
12
+ def message
13
+ <<-MSG
14
+ The adapter #{@adapter.class} does not implement method '#{@method}', which was requested for attribute '#{@attribute}'. Add this method to your adapter to support this filter operator.
15
+ MSG
16
+ end
17
+ end
18
+
19
+ class AttributeError < Base
20
+ attr_reader :resource,
21
+ :name,
22
+ :flag,
23
+ :exists,
24
+ :request,
25
+ :guard
26
+
27
+ def initialize(resource, name, flag, **opts)
28
+ @resource = resource
29
+ @name = name
30
+ @flag = flag
31
+ @exists = opts[:exists] || false
32
+ @request = opts[:request] || false
33
+ @guard = opts[:guard]
34
+ end
35
+
36
+ def action
37
+ if @request
38
+ {
39
+ sortable: 'sort on',
40
+ filterable: 'filter on',
41
+ readable: 'read',
42
+ writable: 'write'
43
+ }[@flag]
44
+ else
45
+ {
46
+ sortable: 'add sort',
47
+ filterable: 'add filter',
48
+ readable: 'read',
49
+ writable: 'write'
50
+ }[@flag]
51
+ end
52
+ end
53
+
54
+ def resource_name
55
+ name = if @resource.is_a?(JsonapiCompliable::Resource)
56
+ @resource.class.name
57
+ else
58
+ @resource.name
59
+ end
60
+ name || 'AnonymousResourceClass'
61
+ end
62
+
63
+ def message
64
+ msg = "#{resource_name}: Tried to #{action} attribute #{@name.inspect}"
65
+ if @exists
66
+ if @guard
67
+ msg << ", but the guard #{@guard.inspect} did not pass."
68
+ else
69
+ msg << ", but the attribute was marked #{@flag.inspect} => false."
70
+ end
71
+ else
72
+ msg << ", but could not find an attribute with that name."
73
+ end
74
+ msg
75
+ end
76
+ end
77
+
78
+ class PolymorphicChildNotFound < Base
79
+ def initialize(resource_class, model)
80
+ @resource_class = resource_class
81
+ @model = model
82
+ end
83
+
84
+ def message
85
+ <<-MSG
86
+ #{@resource_class}: Tried to find subclass with model #{@model.class}, but nothing found!
87
+
88
+ Make sure all your child classes are assigned and associated to the right models:
89
+
90
+ self.polymorphic = ['Subclass1Resource', 'Subclass2Resource']
91
+ MSG
92
+ end
93
+ end
94
+
95
+ class ValidationError < Base
6
96
  attr_reader :validation_response
7
97
 
8
98
  def initialize(validation_response)
@@ -10,41 +100,153 @@ module JsonapiCompliable
10
100
  end
11
101
  end
12
102
 
13
- class MissingSerializer < StandardError
14
- def initialize(class_name, serializer_name)
15
- @class_name = class_name
16
- @serializer_name = serializer_name
103
+ class ImplicitFilterTypeMissing < Base
104
+ def initialize(resource_class, name)
105
+ @resource_class = resource_class
106
+ @name = name
17
107
  end
18
108
 
19
109
  def message
20
110
  <<-MSG
21
- Could not find serializer for class '#{@class_name}'!
111
+ Tried to add filter-only attribute #{@name.inspect}, but type was missing!
22
112
 
23
- Looked for '#{@serializer_name}' but doesn't appear to exist.
113
+ If you are adding a filter that does not have a corresponding attribute, you must pass a type:
24
114
 
25
- Use a custom Inferrer if you'd like different lookup logic.
115
+ filter :name, :string do <--- like this
116
+ # ... code ...
117
+ end
26
118
  MSG
27
119
  end
28
120
  end
29
121
 
30
- class MissingSerializer < StandardError
31
- def initialize(class_name, serializer_name)
32
- @class_name = class_name
33
- @serializer_name = serializer_name
122
+ class ImplicitSortTypeMissing < Base
123
+ def initialize(resource_class, name)
124
+ @resource_class = resource_class
125
+ @name = name
34
126
  end
35
127
 
36
128
  def message
37
129
  <<-MSG
38
- Could not find serializer for class '#{@class_name}'!
130
+ Tried to add sort-only attribute #{@name.inspect}, but type was missing!
131
+
132
+ If you are adding a sort that does not have a corresponding attribute, you must pass a type:
133
+
134
+ sort :name, :string do <--- like this
135
+ # ... code ...
136
+ end
137
+ MSG
138
+ end
139
+ end
140
+
141
+ class TypecastFailed < Base
142
+ def initialize(resource, name, value, error)
143
+ @resource = resource
144
+ @name = name
145
+ @value = value
146
+ @error = error
147
+ end
148
+
149
+ def message
150
+ <<-MSG
151
+ #{@resource.class}: Failed typecasting #{@name.inspect}! Given #{@value.inspect} but the following error was raised:
152
+
153
+ #{@error.message}
154
+
155
+ #{@error.backtrace.join("\n")}
156
+ MSG
157
+ end
158
+ end
159
+
160
+ class ModelNotFound < Base
161
+ def initialize(resource_class)
162
+ @resource_class = resource_class
163
+ end
164
+
165
+ def message
166
+ <<-MSG
167
+ Could not find model for Resource '#{@resource_class}'
168
+
169
+ Manually set model (self.model = MyModel) if it does not match name of the Resource.
170
+ MSG
171
+ end
172
+ end
173
+
174
+ class TypeNotFound < Base
175
+ def initialize(resource, attribute, type)
176
+ @resource = resource
177
+ @attribute = attribute
178
+ @type = type
179
+ end
180
+
181
+ def message
182
+ <<-MSG
183
+ Could not find type #{@type.inspect}! This was specified on attribute #{@attribute.inspect} within resource #{@resource.name}
184
+
185
+ Valid types are: #{JsonapiCompliable::Types.map.keys.inspect}
186
+ MSG
187
+ end
188
+ end
189
+
190
+ class PolymorphicChildNotFound < Base
191
+ def initialize(sideload, name)
192
+ @sideload = sideload
193
+ @name = name
194
+ end
195
+
196
+ def message
197
+ <<-MSG
198
+ #{@sideload.parent_resource}: Found record with #{@sideload.grouper.column_name.inspect} == #{@name.inspect}, which is not registered!
199
+
200
+ Register the behavior of different types like so:
201
+
202
+ polymorphic_belongs_to #{@sideload.name.inspect} do
203
+ group_by(#{@sideload.grouper.column_name.inspect}) do
204
+ on(#{@name.to_sym.inspect}) <---- this is what's missing
205
+ on(:foo).belongs_to :foo, resource: FooResource (long-hand example)
206
+ end
207
+ end
208
+ MSG
209
+ end
210
+ end
211
+
212
+ class ResourceNotFound < Base
213
+ def initialize(resource, sideload_name)
214
+ @resource = resource
215
+ @sideload_name = sideload_name
216
+ end
217
+
218
+ def message
219
+ <<-MSG
220
+ Could not find resource class for sideload '#{@sideload_name}' on Resource '#{@resource.class.name}'!
221
+
222
+ If this follows a non-standard naming convention, use the :resource option to pass it directly:
223
+
224
+ has_many :comments, resource: SpecialCommentResource
225
+ MSG
226
+ end
227
+ end
228
+
229
+ class UnsupportedPagination < Base
230
+ def message
231
+ <<-MSG
232
+ It looks like you are requesting pagination of a sideload, but there are > 1 parents.
233
+
234
+ This is not supported. In other words, you can do
235
+
236
+ /employees/1?include=positions&page[positions][size]=2
237
+
238
+ But not
239
+
240
+ /employees?include=positions&page[positions][size]=2
39
241
 
40
- Looked for '#{@serializer_name}' but doesn't appear to exist.
242
+ This is a limitation of most datastores; the same issue exists in ActiveRecord.
41
243
 
42
- Use a custom Inferrer if you'd like different lookup logic.
244
+ Consider using a named relationship instead, e.g. 'has_one :top_comment'
43
245
  MSG
44
246
  end
45
247
  end
46
248
 
47
- class UnsupportedPageSize < StandardError
249
+ class UnsupportedPageSize < Base
48
250
  def initialize(size, max)
49
251
  @size, @max = size, max
50
252
  end
@@ -54,7 +256,7 @@ Use a custom Inferrer if you'd like different lookup logic.
54
256
  end
55
257
  end
56
258
 
57
- class InvalidInclude < StandardError
259
+ class InvalidInclude < Base
58
260
  def initialize(relationship, parent_resource)
59
261
  @relationship = relationship
60
262
  @parent_resource = parent_resource
@@ -65,7 +267,7 @@ Use a custom Inferrer if you'd like different lookup logic.
65
267
  end
66
268
  end
67
269
 
68
- class StatNotFound < StandardError
270
+ class StatNotFound < Base
69
271
  def initialize(attribute, calculation)
70
272
  @attribute = attribute
71
273
  @calculation = calculation
@@ -86,19 +288,20 @@ Use a custom Inferrer if you'd like different lookup logic.
86
288
  end
87
289
  end
88
290
 
89
- class RecordNotFound < StandardError
291
+ class RecordNotFound < Base
90
292
  end
91
293
 
92
- class RequiredFilter < StandardError
93
- def initialize(attributes)
294
+ class RequiredFilter < Base
295
+ def initialize(resource, attributes)
296
+ @resource = resource
94
297
  @attributes = Array(attributes)
95
298
  end
96
299
 
97
300
  def message
98
301
  if @attributes.length > 1
99
- "The required filters \"#{@attributes.join(', ')}\" were not provided"
302
+ "The required filters \"#{@attributes.join(', ')}\" on resource #{@resource.class} were not provided"
100
303
  else
101
- "The required filter \"#{@attributes[0]}\" was not provided"
304
+ "The required filter \"#{@attributes[0]}\" on resource #{@resource.class} was not provided"
102
305
  end
103
306
  end
104
307
  end
@@ -46,7 +46,9 @@ module JsonapiCompliable
46
46
  next false unless instance_eval(&options[:if])
47
47
  end
48
48
 
49
- @extra_fields[@_type] && @extra_fields[@_type].include?(name)
49
+ @extra_fields &&
50
+ @extra_fields[@_type] &&
51
+ @extra_fields[@_type].include?(name)
50
52
  }
51
53
 
52
54
  attribute name, if: allow_field, &blk