jsonapi-realizer 4.4.0 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -104
  3. data/lib/jsonapi/realizer.rb +26 -25
  4. data/lib/jsonapi/realizer/action.rb +2 -2
  5. data/lib/jsonapi/realizer/adapter.rb +21 -58
  6. data/lib/jsonapi/realizer/adapter/active_record.rb +38 -17
  7. data/lib/jsonapi/realizer/adapter_spec.rb +3 -2
  8. data/lib/jsonapi/realizer/configuration.rb +22 -0
  9. data/lib/jsonapi/realizer/context.rb +8 -0
  10. data/lib/jsonapi/realizer/controller.rb +55 -0
  11. data/lib/jsonapi/realizer/error.rb +10 -9
  12. data/lib/jsonapi/realizer/error/invalid_content_type_header.rb +5 -0
  13. data/lib/jsonapi/realizer/error/invalid_data_type_property.rb +13 -0
  14. data/lib/jsonapi/realizer/error/invalid_root_property.rb +13 -0
  15. data/lib/jsonapi/realizer/error/{duplicate_registration.rb → missing_data_type_property.rb} +1 -1
  16. data/lib/jsonapi/realizer/error/resource_attribute_not_found.rb +14 -0
  17. data/lib/jsonapi/realizer/error/resource_relationship_not_found.rb +14 -0
  18. data/lib/jsonapi/realizer/resource.rb +278 -73
  19. data/lib/jsonapi/realizer/resource/attribute.rb +23 -0
  20. data/lib/jsonapi/realizer/resource/configuration.rb +27 -0
  21. data/lib/jsonapi/realizer/resource/relation.rb +31 -0
  22. data/lib/jsonapi/realizer/resource_spec.rb +55 -8
  23. data/lib/jsonapi/realizer/version.rb +1 -1
  24. data/lib/jsonapi/realizer_spec.rb +22 -119
  25. metadata +70 -20
  26. data/lib/jsonapi/realizer/action/create.rb +0 -36
  27. data/lib/jsonapi/realizer/action/create_spec.rb +0 -165
  28. data/lib/jsonapi/realizer/action/destroy.rb +0 -27
  29. data/lib/jsonapi/realizer/action/destroy_spec.rb +0 -81
  30. data/lib/jsonapi/realizer/action/index.rb +0 -29
  31. data/lib/jsonapi/realizer/action/index_spec.rb +0 -75
  32. data/lib/jsonapi/realizer/action/show.rb +0 -35
  33. data/lib/jsonapi/realizer/action/show_spec.rb +0 -81
  34. data/lib/jsonapi/realizer/action/update.rb +0 -37
  35. data/lib/jsonapi/realizer/action/update_spec.rb +0 -170
  36. data/lib/jsonapi/realizer/action_spec.rb +0 -46
  37. data/lib/jsonapi/realizer/adapter/memory.rb +0 -31
  38. data/lib/jsonapi/realizer/error/invalid_accept_header.rb +0 -9
  39. data/lib/jsonapi/realizer/error/malformed_data_root_property.rb +0 -9
  40. data/lib/jsonapi/realizer/error/missing_accept_header.rb +0 -9
  41. data/lib/jsonapi/realizer/error/missing_type_resource_property.rb +0 -9
@@ -0,0 +1,8 @@
1
+ module JSONAPI
2
+ module Realizer
3
+ module Context
4
+ extend(ActiveSupport::Concern)
5
+ include(ActiveModel::Model)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,55 @@
1
+ module JSONAPI
2
+ module Realizer
3
+ module Controller
4
+ private def reject_missing_content_type_header
5
+ return if request.body.size.zero?
6
+ return if request.headers.property?("Content-Type")
7
+
8
+ raise(JSONAPI::Realizer.configuration.default_missing_content_type_exception)
9
+ end
10
+
11
+ private def reject_invalid_content_type_header
12
+ reject_missing_content_type_header
13
+
14
+ return if request.headers.fetch("Content-Type").include?(JSONAPI::MEDIA_TYPE)
15
+
16
+ raise(JSONAPI::Realizer.configuration.default_invalid_content_type_exception)
17
+ end
18
+
19
+ private def reject_missing_root_property
20
+ return if request.parameters.key?("body")
21
+ return if request.paremters.key?("errors")
22
+ return if request.paremters.key?("meta")
23
+
24
+ raise(Error::MissingRootProperty)
25
+ end
26
+
27
+ private def reject_invalid_root_property
28
+ reject_missing_root_property
29
+
30
+ return unless request.parameters.key?("data") && (request.parameters.fetch("data").is_a?(Hash) || request.parameters.fetch("data").is_a?(Array))
31
+ return unless request.parameters.key?("errors") && request.parameters.fetch("errors").is_a?(Array)
32
+
33
+ raise(Error::InvalidRootProperty)
34
+ end
35
+
36
+ private def reject_missing_type_property
37
+ reject_invalid_root_property
38
+
39
+ return if request.parameters.fetch("data").is_a?(Hash) && request.parameters.fetch("data").key?("type")
40
+ return if request.parameters.fetch("data").is_a?(Array) && request.parameters.fetch("data").all? { |data| data.key?("type") }
41
+
42
+ raise(Error::MissingDataTypeProperty)
43
+ end
44
+
45
+ private def reject_invalid_type_property
46
+ reject_missing_type_property
47
+
48
+ return if request.parameters.fetch("data").is_a?(Hash) && request.parameters.fetch("data").fetch("type").is_a?(String) && request.parameters.fetch("data").fetch("type").present?
49
+ return if request.parameters.fetch("data").is_a?(Array) && request.parameters.fetch("data").map {|data| data.fetch("type")}.all? {|type| type.is_a?(String) && type.present? }
50
+
51
+ raise(Error::InvalidDataTypeProperty)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,15 +1,16 @@
1
1
  module JSONAPI
2
2
  module Realizer
3
3
  class Error < StandardError
4
- require_relative "error/include_without_data_property"
5
- require_relative "error/invalid_accept_header"
6
- require_relative "error/invalid_content_type_header"
7
- require_relative "error/malformed_data_root_property"
8
- require_relative "error/missing_accept_header"
9
- require_relative "error/missing_content_type_header"
10
- require_relative "error/missing_root_property"
11
- require_relative "error/missing_type_resource_property"
12
- require_relative "error/duplicate_registration"
4
+ include(ActiveModel::Model)
5
+
6
+ require_relative("error/invalid_content_type_header")
7
+ require_relative("error/missing_content_type_header")
8
+ require_relative("error/invalid_root_property")
9
+ require_relative("error/missing_root_property")
10
+ require_relative("error/missing_data_type_property")
11
+ require_relative("error/include_without_data_property")
12
+ require_relative("error/resource_attribute_not_found")
13
+ require_relative("error/resource_relationship_not_found")
13
14
  end
14
15
  end
15
16
  end
@@ -2,7 +2,12 @@ module JSONAPI
2
2
  module Realizer
3
3
  class Error
4
4
  class InvalidContentTypeHeader < Error
5
+ attr_accessor(:given)
6
+ attr_accessor(:wanted)
5
7
 
8
+ def message
9
+ "HTTP Content-Type Header recieved is #{given}, but expected #{wanted}"
10
+ end
6
11
  end
7
12
  end
8
13
  end
@@ -0,0 +1,13 @@
1
+ module JSONAPI
2
+ module Realizer
3
+ class Error
4
+ class InvalidDataTypeProperty < Error
5
+ attr_accessor(:given)
6
+
7
+ def message
8
+ "root.data property was #{given}, which is not an Hash, Array, or nil"
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module JSONAPI
2
+ module Realizer
3
+ class Error
4
+ class InvalidRootProperty < Error
5
+ attr_accessor(:given)
6
+
7
+ def message
8
+ "root property was #{given}, which is not an Hash"
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,7 +1,7 @@
1
1
  module JSONAPI
2
2
  module Realizer
3
3
  class Error
4
- class DuplicateRegistration < Error
4
+ class MissingDataTypeProperty < Error
5
5
 
6
6
  end
7
7
  end
@@ -0,0 +1,14 @@
1
+ module JSONAPI
2
+ module Realizer
3
+ class Error
4
+ class ResourceAttributeNotFound < Error
5
+ attr_accessor(:name)
6
+ attr_accessor(:realizer)
7
+
8
+ def message
9
+ "#{realizer} doesn't define the attribute #{name}"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module JSONAPI
2
+ module Realizer
3
+ class Error
4
+ class ResourceRelationshipNotFound < Error
5
+ attr_accessor(:name)
6
+ attr_accessor(:realizer)
7
+
8
+ def message
9
+ "#{realizer} doesn't define the relationship #{name}"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,127 +1,332 @@
1
1
  module JSONAPI
2
2
  module Realizer
3
3
  module Resource
4
- extend ActiveSupport::Concern
4
+ require_relative("resource/configuration")
5
+ require_relative("resource/attribute")
6
+ require_relative("resource/relation")
5
7
 
6
- attr_reader :model
8
+ extend(ActiveSupport::Concern)
9
+ include(ActiveModel::Model)
7
10
 
8
- def self.register(resource_class:, model_class:, adapter:, type:)
9
- @mapping ||= Set.new
10
- raise JSONAPI::Realizer::Error::DuplicateRegistration if @mapping.any? { |realizer| realizer.type == type }
11
- @mapping << OpenStruct.new({
12
- resource_class: resource_class,
13
- model_class: model_class,
14
- adapter: adapter,
15
- type: type.dasherize,
16
- attributes: OpenStruct.new({}),
17
- relationships: OpenStruct.new({})
18
- })
11
+ MIXIN_HOOK = ->(*) do
12
+ @attributes = {}
13
+ @relations = {}
14
+
15
+ unless const_defined?("Context")
16
+ self::Context = Class.new do
17
+ include(JSONAPI::Realizer::Context)
18
+
19
+ def initialize(**keyword_arguments)
20
+ keyword_arguments.keys.each(&singleton_class.method(:attr_accessor))
21
+
22
+ super(**keyword_arguments)
23
+ end
24
+ end
25
+ end
26
+
27
+ validates_presence_of(:intent)
28
+ validates_presence_of(:parameters, :allow_empty => true)
29
+ validates_presence_of(:headers, :allow_empty => true)
30
+
31
+ identifier(JSONAPI::Realizer.configuration.default_identifier)
32
+
33
+ has(JSONAPI::Realizer.configuration.default_identifier)
19
34
  end
35
+ private_constant :MIXIN_HOOK
36
+
37
+ attr_writer(:intent)
38
+ attr_accessor(:parameters)
39
+ attr_accessor(:headers)
40
+ attr_writer(:context)
41
+ attr_accessor(:scope)
42
+
43
+ def initialize(**keyword_arguments)
44
+ super(**keyword_arguments)
45
+
46
+ context.validate!
47
+ validate!
20
48
 
21
- def self.resource_mapping
22
- @mapping.index_by(&:resource_class)
49
+ if filtering?
50
+ @scope = adapter.filtering(scope, filters)
51
+ end
52
+
53
+ if include?
54
+ @scope = adapter.include_relationships(scope, includes)
55
+ end
56
+
57
+ if sorting?
58
+ @scope = adapter.sorting(scope, sorts)
59
+ end
60
+
61
+ if paginate?
62
+ @scope = adapter.paginate(scope, *pagination)
63
+ end
64
+
65
+ if writing? && data?
66
+ adapter.write_attributes(object, attributes)
67
+ adapter.write_relationships(object, relationships)
68
+ end
23
69
  end
24
70
 
25
- def self.type_mapping
26
- @mapping.index_by(&:type)
71
+ def to_hash
72
+ @native ||= {
73
+ :pagination => if paginate? then pagination end,
74
+ :selects => if selects? then selects end,
75
+ :includes => if include? then includes end,
76
+ :object => object
77
+ }.compact
27
78
  end
28
79
 
29
- def initialize(model)
30
- @model = model
80
+ private def writing?
81
+ [:create, :update].include?(intent)
31
82
  end
32
83
 
33
- private def attribute(name)
34
- attributes.public_send(name.to_sym)
84
+ def paginate?
85
+ parameters.key?("page") && (parameters.fetch("page").key?("limit") || parameters.fetch("page").key?("offset"))
35
86
  end
36
87
 
37
- private def relationship(name)
38
- relationships.public_send(name.to_sym)
88
+ def pagination
89
+ [
90
+ parameters.fetch("page").fetch("limit", nil),
91
+ parameters.fetch("page").fetch("offset", nil)
92
+ ]
39
93
  end
40
94
 
41
- private def attributes
42
- configuration.attributes
95
+ def sorting?
96
+ parameters.key?("sort")
43
97
  end
44
98
 
45
- private def relationships
46
- configuration.relationships
99
+ def sorts
100
+ @sorts ||= parameters.
101
+ # {sort: "name,-age,accounts.created_at,-accounts.updated_at"}
102
+ fetch("sort").
103
+ # "name,-age,accounts.created_at,-accounts.updated_at"
104
+ split(",").
105
+ # ["name", "-age", "accounts.created_at", "-accounts.updated_at"]
106
+ map do |token|
107
+ if token.start_with?("-") then [token.sub(/^-/, "").underscore, "-"] else [token.underscore, "+"] end
108
+ end.
109
+ # [["name", "+"], ["age", "-"], ["accounts.created_at", "+"], ["accounts.updated_at", "-"]]
110
+ map do |(path, direction)|
111
+ [if path.include?(".") then path.split(".") else [self.class.configuration.type, path] end, direction]
112
+ end
113
+ # [[["accounts", "name"], "+"], [["accounts", "age"], "-"], [["accounts", "created_at"], "+"], [["accounts", "updated_at"], "-"]]
47
114
  end
48
115
 
49
- private def model_class
50
- configuration.model_class
116
+ def filtering?
117
+ parameters.key?("filter")
51
118
  end
52
119
 
53
- private def configuration
54
- self.class.configuration
120
+ def filters
121
+ @filters ||= parameters.
122
+ # {"filter" => {"full-name" => "Abby Marquardt", "email" => "amado@goldner.com"}}
123
+ fetch("filter").
124
+ # {"full-name" => "Abby Marquardt", "email" => "amado@goldner.com"}
125
+ transform_keys(&:underscore)
126
+ # {"full_name" => "Abby Marquardt", "email" => "amado@goldner.com"}
55
127
  end
56
128
 
57
- class_methods do
58
- def attribute(name)
59
- attributes.public_send(name.to_sym)
60
- end
129
+ def include?
130
+ parameters.key?("include")
131
+ end
61
132
 
62
- def relationship(name)
63
- relationships.public_send(name.to_sym)
64
- end
133
+ def includes
134
+ @includes ||= parameters.
135
+ # {"include" => "active-photographer.photographs,comments,comments.author"}
136
+ fetch("include").
137
+ # "active-photographer.photographs,comments,comments.author"
138
+ split(/\s*,\s*/).
139
+ # ["active-photographer.photographs", "comments", "comments.author"]
140
+ map {|chain| chain.split(".")}.
141
+ # [["active-photographer", "photographs"], ["comments"], ["comments", "author"]]
142
+ map {|list| list.map(&:underscore)}.
143
+ # [["active_photographer", "photographs"], ["comments"], ["comments", "author"]]
144
+ map do |relationship_chain|
145
+ # This walks down the path of relationships and normalizes thenm to
146
+ # their defined "as", which lets us expose AccountRealizer#name, but that actually
147
+ # references Account#full_name.
148
+ relationship_chain.reduce([[], self.class]) do |(normalized_relationship_chain, realizer_class), relationship_link|
149
+ [
150
+ [
151
+ *normalized_relationship_chain,
152
+ realizer_class.relation(relationship_link).as
153
+ ],
154
+ realizer_class.relation(relationship_link).realizer_class
155
+ ]
156
+ end.first
157
+ end
158
+ # [["account", "photographs"], ["comments"], ["comments", "account"]]
159
+ end
65
160
 
66
- def valid_attribute?(name, value)
67
- attributes.respond_to?(name.to_sym)
68
- end
161
+ def selects?
162
+ parameters.key?("fields")
163
+ end
69
164
 
70
- def valid_relationship?(name, value)
71
- relationships.respond_to?(name.to_sym)
72
- end
165
+ def selects
166
+ @selects ||= parameters.
167
+ # {"fields" => {"articles" => "title,body,sub-text", "people" => "name"}}
168
+ fetch("fields").
169
+ # {"articles" => "title,body,sub-text", "people" => "name"}
170
+ transform_keys(&:underscore).
171
+ # {"articles" => "title,body,sub-text", "people" => "name"}
172
+ transform_values {|value| value.split(/\s*,\s*/)}.
173
+ # {"articles" => ["title", "body", "sub-text"], "people" => ["name"]}
174
+ transform_values {|value| value.map(&:underscore)}
175
+ # {"articles" => ["title", "body", "sub_text"], "people" => ["name"]}
176
+ end
177
+
178
+ private def data?
179
+ parameters.key?("data")
180
+ end
181
+
182
+ private def data
183
+ @data ||= parameters.fetch("data")
184
+ end
185
+
186
+ private def type
187
+ return unless data.key?("type")
188
+
189
+ @type ||= data.fetch("type")
190
+ end
191
+
192
+ def attributes
193
+ return unless data.key?("attributes")
194
+
195
+ @attributes ||= data.
196
+ fetch("attributes").
197
+ transform_keys(&:underscore).
198
+ transform_keys{|key| attribute(key).as}
199
+ end
200
+
201
+ def relationships
202
+ return unless data.key?("relationships")
73
203
 
74
- def valid_sparse_field?(name)
75
- attribute(name).selectable if attribute(name)
204
+ @relationships ||= data.
205
+ fetch("relationships").
206
+ transform_keys(&:underscore).
207
+ map(&method(:as_relationship)).to_h.
208
+ transform_keys{|key| relation(key).as}
209
+ end
210
+
211
+ private def scope
212
+ @scope ||= adapter.find_many(@scope || model_class)
213
+ end
214
+
215
+ def object
216
+ @object ||= case intent
217
+ when :create
218
+ scope.new
219
+ when :show, :update, :destroy
220
+ adapter.find_one(scope, parameters.fetch("id"))
221
+ else
222
+ scope
76
223
  end
224
+ end
77
225
 
78
- def valid_includes?(name)
79
- relationship(name).includable if relationship(name)
226
+ def intent
227
+ @intent.to_sym
228
+ end
229
+
230
+ private def as_relationship(name, value)
231
+ data = value.fetch("data")
232
+
233
+ relation_configuration = relation(name).realizer_class.configuration
234
+
235
+ if data.is_a?(Array)
236
+ [name, relation_configuration.adapter.find_many(relation_configuration.model_class, {id: data.map {|value| value.fetch("id")}})]
237
+ else
238
+ [name, relation_configuration.adapter.find_one(relation_configuration.model_class, data.fetch("id"))]
80
239
  end
240
+ end
81
241
 
82
- def has(name, selectable: true)
83
- attributes.public_send("#{name}=", OpenStruct.new({name: name, selectable: selectable}))
242
+ private def attribute(name)
243
+ self.class.attribute(name)
244
+ end
245
+
246
+ private def relation(name)
247
+ self.class.relation(name)
248
+ end
249
+
250
+ private def adapter
251
+ self.class.configuration.adapter
252
+ end
253
+
254
+ private def model_class
255
+ self.class.configuration.model_class
256
+ end
257
+
258
+ def context
259
+ self.class.const_get("Context").new(**@context || {})
260
+ end
261
+
262
+ included do
263
+ class_eval(&MIXIN_HOOK) unless @abstract_class
264
+ end
265
+
266
+ class_methods do
267
+ def inherited(object)
268
+ object.class_eval(&MIXIN_HOOK) unless object.instance_variable_defined?(:@abstract_class)
84
269
  end
85
270
 
86
- def has_related(name, as: name, includable: true)
87
- relationships.public_send("#{name}=", OpenStruct.new({name: name, as: as, includable: includable}))
271
+ def identifier(value)
272
+ @identifier ||= value.to_sym
88
273
  end
89
274
 
90
- def has_one(name, as: name.to_s.pluralize.dasherize, includable: true)
91
- has_related(name, as: as.to_s.dasherize, includable: includable)
275
+ def type(value, class_name:, adapter:)
276
+ @type ||= value.to_s
277
+ @model_class ||= class_name.constantize
278
+ @adapter ||= JSONAPI::Realizer::Adapter.new(interface: adapter)
92
279
  end
93
280
 
94
- def has_many(name, as: name.to_s.dasherize, includable: true)
95
- has_related(name, as: as.to_s.dasherize, includable: includable)
281
+ def has(name, as: name)
282
+ @attributes[name] ||= Attribute.new(
283
+ :name => name,
284
+ :as => as,
285
+ :owner => self
286
+ )
96
287
  end
97
288
 
98
- def adapter
99
- configuration.adapter
289
+ def has_one(name, as: name, class_name:)
290
+ @relations[name] ||= Relation.new(
291
+ :owner => self,
292
+ :type => :one,
293
+ :name => name,
294
+ :as => as,
295
+ :realizer_class_name => class_name
296
+ )
100
297
  end
101
298
 
102
- def attributes
103
- configuration.attributes
299
+ def has_many(name, as: name, class_name:)
300
+ @relations[name] ||= Relation.new(
301
+ :owner => self,
302
+ :type => :many,
303
+ :name => name,
304
+ :as => as,
305
+ :realizer_class_name => class_name
306
+ )
104
307
  end
105
308
 
106
- def relationships
107
- configuration.relationships
309
+ def context
310
+ const_get("Context")
108
311
  end
109
312
 
110
- def model_class
111
- configuration.model_class
313
+ def configuration
314
+ @configuration ||= Configuration.new({
315
+ :owner => self,
316
+ :type => @type,
317
+ :model_class => @model_class,
318
+ :adapter => @adapter,
319
+ :attributes => @attributes,
320
+ :relations => @relations
321
+ })
112
322
  end
113
323
 
114
- def register(type, class_name:, adapter:)
115
- JSONAPI::Realizer::Resource.register(
116
- resource_class: self,
117
- model_class: class_name.constantize,
118
- adapter: JSONAPI::Realizer::Adapter.new(adapter),
119
- type: type.to_s
120
- )
324
+ def attribute(name)
325
+ configuration.attributes.fetch(name.to_sym){raise(Error::ResourceRelationshipNotFound, name: name, realizer: self)}
121
326
  end
122
327
 
123
- def configuration
124
- JSONAPI::Realizer::Resource.resource_mapping.fetch(self)
328
+ def relation(name)
329
+ configuration.relations.fetch(name.to_sym){raise(Error::ResourceRelationshipNotFound, name: name, realizer: self)}
125
330
  end
126
331
  end
127
332
  end