jsonapi-materializer 1.0.0.rc2

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.
@@ -0,0 +1,21 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ class Configuration
4
+ include(ActiveModel::Model)
5
+
6
+ attr_accessor(:default_origin)
7
+ attr_accessor(:default_identifier)
8
+ attr_accessor(:default_missing_accept_exception)
9
+ attr_accessor(:default_invalid_accept_exception)
10
+
11
+ validates_presence_of(:default_missing_accept_exception)
12
+ validates_presence_of(:default_invalid_accept_exception)
13
+
14
+ def initialize(**keyword_arguments)
15
+ super(**keyword_arguments)
16
+
17
+ validate!
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,8 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ module Context
4
+ extend(ActiveSupport::Concern)
5
+ include(ActiveModel::Model)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,13 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ module Controller
4
+ private def reject_missing_accept_header
5
+ raise(JSONAPI::Materializer.configuration.default_missing_accept_exception) unless request.headers.key?("Accept")
6
+ end
7
+
8
+ private def reject_invalid_accept_header
9
+ raise(JSONAPI::Materializer.configuration.default_invalid_accept_exception) unless request.headers.fetch("Accept").include?(JSONAPI::MEDIA_TYPE)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ class Error < StandardError
4
+ include(ActiveModel::Model)
5
+
6
+ require_relative("error/invalid_accept_header")
7
+ require_relative("error/missing_accept_header")
8
+ require_relative("error/resource_attribute_not_found")
9
+ require_relative("error/resource_relationship_not_found")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,8 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ class Error
4
+ class InvalidAcceptHeader < Error
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ class Error
4
+ class MissingAcceptHeader < Error
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ class Error
4
+ class ResourceAttributeNotFound < Error
5
+ attr_accessor(:name)
6
+ attr_accessor(:materializer)
7
+
8
+ def message
9
+ "#{materializer} 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 Materializer
3
+ class Error
4
+ class ResourceRelationshipNotFound < Error
5
+ attr_accessor(:name)
6
+ attr_accessor(:materializer)
7
+
8
+ def message
9
+ "#{materializer} doesn't define the relationship #{name}"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,231 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ module Resource
4
+ require_relative("resource/attribute")
5
+ require_relative("resource/relation")
6
+ require_relative("resource/relationship")
7
+ require_relative("resource/configuration")
8
+
9
+ extend(ActiveSupport::Concern)
10
+ include(ActiveModel::Model)
11
+
12
+ MIXIN_HOOK = ->(*) do
13
+ @attributes = {}
14
+ @relations = {}
15
+
16
+ unless const_defined?("Collection")
17
+ self::Collection = Class.new do
18
+ include(JSONAPI::Materializer::Collection)
19
+ end
20
+ end
21
+
22
+ unless const_defined?("Context")
23
+ self::Context = Class.new do
24
+ include(JSONAPI::Materializer::Context)
25
+
26
+ def initialize(**keyword_arguments)
27
+ keyword_arguments.keys.each(&singleton_class.method(:attr_accessor))
28
+
29
+ super(**keyword_arguments)
30
+ end
31
+ end
32
+ end
33
+
34
+ validates_presence_of(:object)
35
+
36
+ origin(JSONAPI::Materializer.configuration.default_origin)
37
+ identifier(JSONAPI::Materializer.configuration.default_identifier)
38
+
39
+ has(JSONAPI::Materializer.configuration.default_identifier)
40
+ end
41
+
42
+ attr_accessor(:object)
43
+ attr_writer(:selects)
44
+ attr_writer(:includes)
45
+ attr_writer(:context)
46
+ attr_reader(:raw)
47
+
48
+ def initialize(**keyword_arguments)
49
+ super(**keyword_arguments)
50
+
51
+ @raw = keyword_arguments
52
+
53
+ context.validate!
54
+ validate!
55
+ end
56
+
57
+ def as_data
58
+ {
59
+ :id => id,
60
+ :type => type,
61
+ :attributes => exposed(attributes.except(:id)).
62
+ transform_values {|attribute| object.public_send(attribute.from)},
63
+ :relationships => exposed(relations).
64
+ transform_values {|relation| relation.using(self).as_json},
65
+ :links => {
66
+ :self => links_self
67
+ }
68
+ }.transform_values(&:presence).compact
69
+ end
70
+
71
+ private def exposed(mapping)
72
+ if selects.any?
73
+ mapping.
74
+ select {|_, value| value.visible?(self)}.
75
+ slice(*selects.dig(type))
76
+ else
77
+ mapping.
78
+ select {|_, value| value.visible?(self)}
79
+ end
80
+ end
81
+
82
+ def as_json(*)
83
+ {
84
+ :links => {
85
+ :self => links_self
86
+ },
87
+ :data => as_data,
88
+ :included => included
89
+ }.transform_values(&:presence).compact
90
+ end
91
+
92
+ private def id
93
+ object.public_send(identifier).to_s
94
+ end
95
+
96
+ def type
97
+ self.class.configuration.type.to_s
98
+ end
99
+
100
+ private def attributes
101
+ self.class.configuration.attributes
102
+ end
103
+
104
+ private def origin
105
+ self.class.configuration.origin
106
+ end
107
+
108
+ private def identifier
109
+ self.class.configuration.identifier
110
+ end
111
+
112
+ private def relations
113
+ self.class.configuration.relations
114
+ end
115
+
116
+ def attribute(name)
117
+ self.class.attribute(name)
118
+ end
119
+
120
+ def relation(name)
121
+ self.class.relation(name)
122
+ end
123
+
124
+ def links_self
125
+ Addressable::Template.new(
126
+ "#{origin}/#{type}/#{object.public_send(identifier)}"
127
+ ).pattern
128
+ end
129
+
130
+ def selects
131
+ (@selects || {}).transform_values {|list| list.map(&:to_sym)}
132
+ end
133
+
134
+ def includes
135
+ @includes || []
136
+ end
137
+
138
+ def context
139
+ self.class.const_get("Context").new(**@context || {})
140
+ end
141
+
142
+ private def included
143
+ @included ||= includes.flat_map do |path|
144
+ path.reduce(self) do |subject, key|
145
+ if subject.is_a?(Array)
146
+ subject.map {|related_subject| related_subject.relation(key).for(subject)}
147
+ else
148
+ subject.relation(key).for(subject)
149
+ end
150
+ end
151
+ end.map(&:as_data)
152
+ end
153
+
154
+ included do
155
+ class_eval(&MIXIN_HOOK) unless @abstract_class
156
+ end
157
+
158
+ class_methods do
159
+ def inherited(object)
160
+ object.class_eval(&MIXIN_HOOK) unless object.instance_variable_defined?(:@abstract_class)
161
+ end
162
+
163
+ def identifier(value)
164
+ @identifier = value.to_sym
165
+ end
166
+
167
+ def origin(value)
168
+ @origin = value
169
+ end
170
+
171
+ def type(value)
172
+ @type = value.to_sym
173
+ end
174
+
175
+ def has(name, from: name, visible: true)
176
+ @attributes[name] = Attribute.new(
177
+ :owner => self,
178
+ :name => name,
179
+ :from => from,
180
+ :visible => visible
181
+ )
182
+ end
183
+
184
+ def has_one(name, from: name, class_name:, visible: true)
185
+ @relations[name] = Relation.new(
186
+ :owner => self,
187
+ :type => :one,
188
+ :name => name,
189
+ :from => from,
190
+ :class_name => class_name,
191
+ :visible => visible
192
+ )
193
+ end
194
+
195
+ def has_many(name, from: name, class_name:, visible: true)
196
+ @relations[name] = Relation.new(
197
+ :owner => self,
198
+ :type => :many,
199
+ :name => name,
200
+ :from => from,
201
+ :class_name => class_name,
202
+ :visible => visible
203
+ )
204
+ end
205
+
206
+ def context
207
+ const_get("Context")
208
+ end
209
+
210
+ def configuration
211
+ @configuration ||= Configuration.new(
212
+ :owner => self,
213
+ :type => @type,
214
+ :origin => @origin,
215
+ :identifier => @identifier,
216
+ :attributes => @attributes,
217
+ :relations => @relations
218
+ )
219
+ end
220
+
221
+ def attribute(name)
222
+ configuration.attributes.fetch(name.to_sym) {raise(Error::ResourceRelationshipNotFound, :name => name, :materializer => self)}
223
+ end
224
+
225
+ def relation(name)
226
+ configuration.relations.fetch(name.to_sym) {raise(Error::ResourceRelationshipNotFound, :name => name, :materializer => self)}
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,49 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ module Resource
4
+ class Attribute
5
+ include(ActiveModel::Model)
6
+
7
+ attr_accessor(:owner)
8
+ attr_accessor(:name)
9
+ attr_accessor(:from)
10
+ attr_accessor(:visible)
11
+
12
+ validates_presence_of(:owner)
13
+ validates_presence_of(:name)
14
+ validates_presence_of(:from)
15
+ validate(:visible_callable)
16
+
17
+ def initialize(**keyword_arguments)
18
+ super(**keyword_arguments)
19
+
20
+ validate!
21
+ end
22
+
23
+ def for(subject)
24
+ subject.object.public_send(from)
25
+ end
26
+
27
+ def visible?(subject)
28
+ return visible if [true, false].include?(visible)
29
+ return subject.send(visible, self) if visible.is_a?(Symbol)
30
+ return visible.call(self) if visible.respond_to?(:call)
31
+
32
+ true
33
+ end
34
+
35
+ private def materializer_class
36
+ class_name.constantize
37
+ end
38
+
39
+ private def visible_callable
40
+ return if [true, false].include?(visible)
41
+ return if visible.is_a?(Symbol)
42
+ return if visible.respond_to?(:call)
43
+
44
+ errors.add(:visible, "not callable or boolean")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ module Resource
4
+ class Configuration
5
+ include(ActiveModel::Model)
6
+
7
+ attr_accessor(:owner)
8
+ attr_accessor(:type)
9
+ attr_accessor(:origin)
10
+ attr_accessor(:identifier)
11
+ attr_accessor(:attributes)
12
+ attr_accessor(:relations)
13
+
14
+ validates_presence_of(:owner)
15
+ validates_presence_of(:type)
16
+ validates_presence_of(:origin)
17
+ validates_presence_of(:identifier)
18
+
19
+ def initialize(**keyword_arguments)
20
+ super(**keyword_arguments)
21
+
22
+ validate!
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,107 @@
1
+ module JSONAPI
2
+ module Materializer
3
+ module Resource
4
+ class Relation
5
+ include(ActiveModel::Model)
6
+
7
+ attr_accessor(:owner)
8
+ attr_accessor(:name)
9
+ attr_accessor(:type)
10
+ attr_accessor(:from)
11
+ attr_accessor(:class_name)
12
+ attr_accessor(:visible)
13
+
14
+ validates_presence_of(:owner)
15
+ validates_presence_of(:name)
16
+ validates_presence_of(:type)
17
+ validates_presence_of(:from)
18
+ validates_presence_of(:class_name)
19
+ validate(:visible_callable)
20
+
21
+ def initialize(**keyword_arguments)
22
+ super(**keyword_arguments)
23
+
24
+ validate!
25
+ end
26
+
27
+ def for(subject)
28
+ @for ||= {}
29
+ @for[checksum(subject)] ||= case type
30
+ when :many then
31
+ unlessing(fetch_relation(subject), -> {subject.includes.any? {|included| included.include?(from.to_s)} || fetch_relation(subject).loaded?}) do |subject|
32
+ subject.select(:id)
33
+ end.map do |related_object|
34
+ materializer_class.new(
35
+ **subject.raw,
36
+ :object => related_object
37
+ )
38
+ end
39
+ when :one then
40
+ if fetch_relation(subject).present?
41
+ materializer_class.new(
42
+ **subject.raw,
43
+ :object => fetch_relation(subject)
44
+ )
45
+ end
46
+ end
47
+ end
48
+
49
+ def visible?(subject)
50
+ return visible if [true, false].include?(visible)
51
+ return subject.send(visible, self) if visible.is_a?(Symbol)
52
+ return visible.call(self) if visible.respond_to?(:call)
53
+
54
+ true
55
+ end
56
+
57
+ private def fetch_relation(subject)
58
+ @fetch_relationship ||= {}
59
+ @fetch_relationship[checksum(subject)] ||= subject.object.public_send(from)
60
+ end
61
+
62
+ def using(parent)
63
+ Resource::Relationship.new(:related => self, :parent => parent)
64
+ end
65
+
66
+ def many?
67
+ type == :many
68
+ end
69
+
70
+ def one?
71
+ type == :one
72
+ end
73
+
74
+ private def materializer_class
75
+ class_name.constantize
76
+ end
77
+
78
+ private def visible_callable
79
+ return if [true, false].include?(visible)
80
+ return if visible.is_a?(Symbol)
81
+ return if visible.respond_to?(:call)
82
+
83
+ errors.add(:visible, "not callable or boolean")
84
+ end
85
+
86
+ private def unlessing(object, proc)
87
+ unless proc.call()
88
+ yield(object)
89
+ else
90
+ object
91
+ end
92
+ end
93
+
94
+ private def checksum(subject)
95
+ [
96
+ from,
97
+ materializer_class,
98
+ name,
99
+ owner,
100
+ subject,
101
+ type
102
+ ].hash
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end