graphiti-rb 1.0.alpha.1

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 (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