graphiti-rb 1.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +20 -0
  6. data/.yardopts +2 -0
  7. data/Appraisals +11 -0
  8. data/CODE_OF_CONDUCT.md +49 -0
  9. data/Gemfile +12 -0
  10. data/Guardfile +32 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +75 -0
  13. data/Rakefile +15 -0
  14. data/bin/appraisal +17 -0
  15. data/bin/console +14 -0
  16. data/bin/rspec +17 -0
  17. data/bin/setup +8 -0
  18. data/gemfiles/rails_4.gemfile +17 -0
  19. data/gemfiles/rails_5.gemfile +17 -0
  20. data/graphiti.gemspec +34 -0
  21. data/lib/generators/jsonapi/resource_generator.rb +169 -0
  22. data/lib/generators/jsonapi/templates/application_resource.rb.erb +15 -0
  23. data/lib/generators/jsonapi/templates/controller.rb.erb +61 -0
  24. data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +30 -0
  25. data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +20 -0
  26. data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +22 -0
  27. data/lib/generators/jsonapi/templates/resource.rb.erb +11 -0
  28. data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
  29. data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
  30. data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +21 -0
  31. data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +34 -0
  32. data/lib/graphiti-rb.rb +1 -0
  33. data/lib/graphiti.rb +121 -0
  34. data/lib/graphiti/adapters/abstract.rb +516 -0
  35. data/lib/graphiti/adapters/active_record.rb +6 -0
  36. data/lib/graphiti/adapters/active_record/base.rb +249 -0
  37. data/lib/graphiti/adapters/active_record/belongs_to_sideload.rb +17 -0
  38. data/lib/graphiti/adapters/active_record/has_many_sideload.rb +17 -0
  39. data/lib/graphiti/adapters/active_record/has_one_sideload.rb +17 -0
  40. data/lib/graphiti/adapters/active_record/inferrence.rb +12 -0
  41. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +30 -0
  42. data/lib/graphiti/adapters/null.rb +236 -0
  43. data/lib/graphiti/base.rb +70 -0
  44. data/lib/graphiti/configuration.rb +21 -0
  45. data/lib/graphiti/context.rb +16 -0
  46. data/lib/graphiti/deserializer.rb +208 -0
  47. data/lib/graphiti/errors.rb +309 -0
  48. data/lib/graphiti/extensions/boolean_attribute.rb +33 -0
  49. data/lib/graphiti/extensions/extra_attribute.rb +70 -0
  50. data/lib/graphiti/extensions/temp_id.rb +26 -0
  51. data/lib/graphiti/filter_operators.rb +25 -0
  52. data/lib/graphiti/hash_renderer.rb +57 -0
  53. data/lib/graphiti/jsonapi_serializable_ext.rb +50 -0
  54. data/lib/graphiti/query.rb +251 -0
  55. data/lib/graphiti/rails.rb +28 -0
  56. data/lib/graphiti/railtie.rb +74 -0
  57. data/lib/graphiti/renderer.rb +60 -0
  58. data/lib/graphiti/resource.rb +110 -0
  59. data/lib/graphiti/resource/configuration.rb +239 -0
  60. data/lib/graphiti/resource/dsl.rb +138 -0
  61. data/lib/graphiti/resource/interface.rb +32 -0
  62. data/lib/graphiti/resource/polymorphism.rb +68 -0
  63. data/lib/graphiti/resource/sideloading.rb +102 -0
  64. data/lib/graphiti/resource_proxy.rb +127 -0
  65. data/lib/graphiti/responders.rb +19 -0
  66. data/lib/graphiti/runner.rb +25 -0
  67. data/lib/graphiti/scope.rb +98 -0
  68. data/lib/graphiti/scoping/base.rb +99 -0
  69. data/lib/graphiti/scoping/default_filter.rb +58 -0
  70. data/lib/graphiti/scoping/extra_attributes.rb +29 -0
  71. data/lib/graphiti/scoping/filter.rb +93 -0
  72. data/lib/graphiti/scoping/filterable.rb +36 -0
  73. data/lib/graphiti/scoping/paginate.rb +87 -0
  74. data/lib/graphiti/scoping/sort.rb +64 -0
  75. data/lib/graphiti/sideload.rb +281 -0
  76. data/lib/graphiti/sideload/belongs_to.rb +34 -0
  77. data/lib/graphiti/sideload/has_many.rb +16 -0
  78. data/lib/graphiti/sideload/has_one.rb +9 -0
  79. data/lib/graphiti/sideload/many_to_many.rb +24 -0
  80. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +108 -0
  81. data/lib/graphiti/stats/dsl.rb +89 -0
  82. data/lib/graphiti/stats/payload.rb +49 -0
  83. data/lib/graphiti/types.rb +172 -0
  84. data/lib/graphiti/util/attribute_check.rb +88 -0
  85. data/lib/graphiti/util/field_params.rb +16 -0
  86. data/lib/graphiti/util/hash.rb +51 -0
  87. data/lib/graphiti/util/hooks.rb +33 -0
  88. data/lib/graphiti/util/include_params.rb +39 -0
  89. data/lib/graphiti/util/persistence.rb +219 -0
  90. data/lib/graphiti/util/relationship_payload.rb +64 -0
  91. data/lib/graphiti/util/serializer_attributes.rb +97 -0
  92. data/lib/graphiti/util/sideload.rb +33 -0
  93. data/lib/graphiti/util/validation_response.rb +78 -0
  94. data/lib/graphiti/version.rb +3 -0
  95. metadata +317 -0
@@ -0,0 +1,70 @@
1
+ module Graphiti
2
+ module Base
3
+ extend ActiveSupport::Concern
4
+
5
+ def jsonapi_resource
6
+ @jsonapi_resource
7
+ end
8
+
9
+ def query
10
+ @query ||= Query.new(jsonapi_resource, params)
11
+ end
12
+
13
+ def query_hash
14
+ @query_hash ||= query.to_hash
15
+ end
16
+
17
+ def wrap_context
18
+ Graphiti.with_context(jsonapi_context, action_name.to_sym) do
19
+ yield
20
+ end
21
+ end
22
+
23
+ def jsonapi_context
24
+ self
25
+ end
26
+
27
+ def jsonapi_scope(scope, opts = {})
28
+ jsonapi_resource.build_scope(scope, query, opts)
29
+ end
30
+
31
+ def normalized_params
32
+ normalized = params
33
+ if normalized.respond_to?(:to_unsafe_h)
34
+ normalized = normalized.to_unsafe_h.deep_symbolize_keys
35
+ end
36
+ normalized
37
+ end
38
+
39
+ def deserialized_params
40
+ @deserialized_params ||= begin
41
+ payload = normalized_params
42
+ if payload[:data] && payload[:data][:type]
43
+ Graphiti::Deserializer.new(payload)
44
+ end
45
+ end
46
+ end
47
+
48
+ def jsonapi_render_options
49
+ options = {}
50
+ options.merge!(default_jsonapi_render_options)
51
+ options[:meta] ||= {}
52
+ options[:expose] ||= {}
53
+ options[:expose][:context] = jsonapi_context
54
+ options
55
+ end
56
+
57
+ def proxy(base = nil, opts = {})
58
+ base ||= jsonapi_resource.base_scope
59
+ scope_opts = opts.slice :sideload_parent_length,
60
+ :default_paginate, :after_resolve
61
+ scope = jsonapi_scope(base, scope_opts)
62
+ ResourceProxy.new jsonapi_resource,
63
+ scope,
64
+ query,
65
+ payload: deserialized_params,
66
+ single: opts[:single],
67
+ raise_on_missing: opts[:raise_on_missing]
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,21 @@
1
+ # https://robots.thoughtbot.com/mygem-configure-block
2
+ module Graphiti
3
+ class Configuration
4
+ # @return [Boolean] Should we raise when the client requests a relationship not defined on the server?
5
+ # Defaults to true.
6
+ attr_accessor :raise_on_missing_sideload
7
+ # @return [Boolean] Concurrently fetch sideloads?
8
+ # Defaults to false OR if classes are cached (Rails-only)
9
+ attr_accessor :concurrency
10
+
11
+ attr_accessor :respond_to
12
+
13
+ # Set defaults
14
+ # @api private
15
+ def initialize
16
+ @raise_on_missing_sideload = true
17
+ @concurrency = false
18
+ @respond_to = [:json, :jsonapi, :xml]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ module Graphiti
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
@@ -0,0 +1,208 @@
1
+ # Responsible for parsing incoming write payloads
2
+ #
3
+ # Given a PUT payload like:
4
+ #
5
+ # {
6
+ # data: {
7
+ # id: '1',
8
+ # type: 'posts',
9
+ # attributes: { title: 'My Title' },
10
+ # relationships: {
11
+ # author: {
12
+ # data: {
13
+ # id: '1',
14
+ # type: 'authors'
15
+ # }
16
+ # }
17
+ # }
18
+ # },
19
+ # included: [
20
+ # {
21
+ # id: '1'
22
+ # type: 'authors',
23
+ # attributes: { name: 'Joe Author' }
24
+ # }
25
+ # ]
26
+ # }
27
+ #
28
+ # You can now easily deal with this payload:
29
+ #
30
+ # deserializer.attributes
31
+ # # => { id: '1', title: 'My Title' }
32
+ # deserializer.meta
33
+ # # => { type: 'posts', method: :update }
34
+ # deserializer.relationships
35
+ # # {
36
+ # # author: {
37
+ # # meta: { ... },
38
+ # # attributes: { ... },
39
+ # # relationships: { ... }
40
+ # # }
41
+ # # }
42
+ #
43
+ # When creating objects, we accept a +temp-id+ so that the client can track
44
+ # the object it just created. Expect this in +meta+:
45
+ #
46
+ # { type: 'authors', method: :create, temp_id: 'abc123' }
47
+ class Graphiti::Deserializer
48
+ def initialize(payload)
49
+ @payload = payload
50
+ @payload = @payload[:_jsonapi] if @payload.has_key?(:_jsonapi)
51
+ end
52
+
53
+ def params
54
+ @payload
55
+ end
56
+
57
+ # @return [Hash] the raw :data value of the payload
58
+ def data
59
+ @payload[:data]
60
+ end
61
+
62
+ # @return [String] the raw :id value of the payload
63
+ def id
64
+ data[:id]
65
+ end
66
+
67
+ # @return [Hash] the raw :attributes hash + +id+
68
+ def attributes
69
+ @attributes ||= raw_attributes.tap do |hash|
70
+ hash[:id] = id if id
71
+ end
72
+ end
73
+
74
+ # Override the attributes
75
+ # # @see #attributes
76
+ def attributes=(attrs)
77
+ @attributes = attrs
78
+ end
79
+
80
+ # 'meta' information about this resource. Includes:
81
+ #
82
+ # +type+: the jsonapi type
83
+ # +method+: create/update/destroy/disassociate. Based on the request env or the +method+ within the +relationships+ hash
84
+ # +temp_id+: the +temp-id+, if specified
85
+ #
86
+ # @return [Hash]
87
+ def meta(action: :update)
88
+ {
89
+ type: data[:type],
90
+ temp_id: data[:'temp-id'],
91
+ method: action
92
+ }
93
+ end
94
+
95
+ # @return [Hash] the relationships hash
96
+ def relationships
97
+ @relationships ||= process_relationships(raw_relationships)
98
+ end
99
+
100
+ def include_hash(memo = {}, relationship_node = nil)
101
+ relationship_node ||= relationships
102
+
103
+ relationship_node.each_pair do |name, relationship_payload|
104
+ merge_include_hash(memo, name, relationship_payload)
105
+ end
106
+
107
+ memo
108
+ end
109
+
110
+ private
111
+
112
+ def merge_include_hash(memo, name, relationship_payload)
113
+ arrayified = [relationship_payload].flatten
114
+ return if arrayified.all? { |rp| removed?(rp) }
115
+
116
+ memo[name] ||= {}
117
+ deep_merge!(memo[name], nested_include_hashes(memo[name], arrayified))
118
+ memo
119
+ end
120
+
121
+ def included
122
+ @payload[:included] || []
123
+ end
124
+
125
+ def removed?(relationship_payload)
126
+ method = relationship_payload[:meta][:method]
127
+ [:disassociate, :destroy].include?(method)
128
+ end
129
+
130
+ def nested_include_hashes(memo, relationship_payloads)
131
+ {}.tap do |subs|
132
+ relationship_payloads.each do |rp|
133
+ nested = include_hash(memo, rp[:relationships])
134
+ deep_merge!(subs, nested)
135
+ end
136
+ end
137
+ end
138
+
139
+ def deep_merge!(a, b)
140
+ Graphiti::Util::Hash.deep_merge!(a, b)
141
+ end
142
+
143
+ def process_relationships(relationship_hash)
144
+ {}.tap do |hash|
145
+ relationship_hash.each_pair do |name, relationship_payload|
146
+ name = name.to_sym
147
+
148
+ if relationship_payload[:data]
149
+ hash[name] = process_relationship(relationship_payload[:data])
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ def process_relationship(relationship_data)
156
+ if relationship_data.is_a?(Array)
157
+ relationship_data.map do |rd|
158
+ process_relationship_datum(rd)
159
+ end
160
+ else
161
+ process_relationship_datum(relationship_data)
162
+ end
163
+ end
164
+
165
+ def process_relationship_datum(datum)
166
+ temp_id = datum[:'temp-id']
167
+ included_object = included.find do |i|
168
+ next unless i[:type] == datum[:type]
169
+
170
+ (i[:id] && i[:id] == datum[:id]) ||
171
+ (i[:'temp-id'] && i[:'temp-id'] == temp_id)
172
+ end
173
+ included_object ||= {}
174
+ included_object[:relationships] ||= {}
175
+
176
+ attributes = included_object[:attributes] || {}
177
+ attributes[:id] = datum[:id] if datum[:id]
178
+ relationships = process_relationships(included_object[:relationships] || {})
179
+ method = datum[:method]
180
+ method = method.to_sym if method
181
+
182
+ {
183
+ meta: {
184
+ jsonapi_type: datum[:type],
185
+ temp_id: temp_id,
186
+ method: method
187
+ },
188
+ attributes: attributes,
189
+ relationships: relationships
190
+ }
191
+ end
192
+
193
+ def raw_attributes
194
+ if data
195
+ data[:attributes] || {}
196
+ else
197
+ {}
198
+ end
199
+ end
200
+
201
+ def raw_relationships
202
+ if data
203
+ data[:relationships] || {}
204
+ else
205
+ {}
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,309 @@
1
+ module Graphiti
2
+ module Errors
3
+ class Base < StandardError;end
4
+
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?(Graphiti::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
96
+ attr_reader :validation_response
97
+
98
+ def initialize(validation_response)
99
+ @validation_response = validation_response
100
+ end
101
+ end
102
+
103
+ class ImplicitFilterTypeMissing < Base
104
+ def initialize(resource_class, name)
105
+ @resource_class = resource_class
106
+ @name = name
107
+ end
108
+
109
+ def message
110
+ <<-MSG
111
+ Tried to add filter-only attribute #{@name.inspect}, but type was missing!
112
+
113
+ If you are adding a filter that does not have a corresponding attribute, you must pass a type:
114
+
115
+ filter :name, :string do <--- like this
116
+ # ... code ...
117
+ end
118
+ MSG
119
+ end
120
+ end
121
+
122
+ class ImplicitSortTypeMissing < Base
123
+ def initialize(resource_class, name)
124
+ @resource_class = resource_class
125
+ @name = name
126
+ end
127
+
128
+ def message
129
+ <<-MSG
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: #{Graphiti::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.field_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.field_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
241
+
242
+ This is a limitation of most datastores; the same issue exists in ActiveRecord.
243
+
244
+ Consider using a named relationship instead, e.g. 'has_one :top_comment'
245
+ MSG
246
+ end
247
+ end
248
+
249
+ class UnsupportedPageSize < Base
250
+ def initialize(size, max)
251
+ @size, @max = size, max
252
+ end
253
+
254
+ def message
255
+ "Requested page size #{@size} is greater than max supported size #{@max}"
256
+ end
257
+ end
258
+
259
+ class InvalidInclude < Base
260
+ def initialize(relationship, parent_resource)
261
+ @relationship = relationship
262
+ @parent_resource = parent_resource
263
+ end
264
+
265
+ def message
266
+ "The requested included relationship \"#{@relationship}\" is not supported on resource \"#{@parent_resource}\""
267
+ end
268
+ end
269
+
270
+ class StatNotFound < Base
271
+ def initialize(attribute, calculation)
272
+ @attribute = attribute
273
+ @calculation = calculation
274
+ end
275
+
276
+ def message
277
+ "No stat configured for calculation #{pretty(@calculation)} on attribute #{pretty(@attribute)}"
278
+ end
279
+
280
+ private
281
+
282
+ def pretty(input)
283
+ if input.is_a?(Symbol)
284
+ ":#{input}"
285
+ else
286
+ "'#{input}'"
287
+ end
288
+ end
289
+ end
290
+
291
+ class RecordNotFound < Base
292
+ end
293
+
294
+ class RequiredFilter < Base
295
+ def initialize(resource, attributes)
296
+ @resource = resource
297
+ @attributes = Array(attributes)
298
+ end
299
+
300
+ def message
301
+ if @attributes.length > 1
302
+ "The required filters \"#{@attributes.join(', ')}\" on resource #{@resource.class} were not provided"
303
+ else
304
+ "The required filter \"#{@attributes[0]}\" on resource #{@resource.class} was not provided"
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end