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.
- checksums.yaml +7 -0
- data/README.md +211 -0
- data/Rakefile +12 -0
- data/lib/jsonapi-materializer.rb +3 -0
- data/lib/jsonapi/materializer.rb +36 -0
- data/lib/jsonapi/materializer/collection.rb +87 -0
- data/lib/jsonapi/materializer/collection_spec.rb +103 -0
- data/lib/jsonapi/materializer/configuration.rb +21 -0
- data/lib/jsonapi/materializer/context.rb +8 -0
- data/lib/jsonapi/materializer/controller.rb +13 -0
- data/lib/jsonapi/materializer/error.rb +12 -0
- data/lib/jsonapi/materializer/error/invalid_accept_header.rb +8 -0
- data/lib/jsonapi/materializer/error/missing_accept_header.rb +8 -0
- data/lib/jsonapi/materializer/error/resource_attribute_not_found.rb +14 -0
- data/lib/jsonapi/materializer/error/resource_relationship_not_found.rb +14 -0
- data/lib/jsonapi/materializer/resource.rb +231 -0
- data/lib/jsonapi/materializer/resource/attribute.rb +49 -0
- data/lib/jsonapi/materializer/resource/configuration.rb +27 -0
- data/lib/jsonapi/materializer/resource/relation.rb +107 -0
- data/lib/jsonapi/materializer/resource/relationship.rb +66 -0
- data/lib/jsonapi/materializer/resource_spec.rb +65 -0
- data/lib/jsonapi/materializer/version.rb +5 -0
- data/lib/jsonapi/materializer/version_spec.rb +7 -0
- data/lib/jsonapi/materializer_spec.rb +4 -0
- metadata +289 -0
@@ -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,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,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
|