jsonapi-realizer 4.4.0 → 5.0.0

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