jsonapi-materializer 1.0.0.rc2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|