jsonapi-materializer 1.0.0.rc6 → 1.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.
- checksums.yaml +4 -4
- data/LICENSE +440 -0
- data/README.md +1 -17
- data/Rakefile +2 -1
- data/lib/jsonapi/materializer/collection.rb +22 -19
- data/lib/jsonapi/materializer/collection_spec.rb +79 -77
- data/lib/jsonapi/materializer/configuration.rb +2 -0
- data/lib/jsonapi/materializer/controller.rb +2 -0
- data/lib/jsonapi/materializer/error/invalid_accept_header.rb +2 -0
- data/lib/jsonapi/materializer/error/missing_accept_header.rb +2 -0
- data/lib/jsonapi/materializer/error/resource_attribute_not_found.rb +2 -0
- data/lib/jsonapi/materializer/error/resource_relationship_not_found.rb +2 -0
- data/lib/jsonapi/materializer/error.rb +2 -0
- data/lib/jsonapi/materializer/resource/attribute.rb +2 -0
- data/lib/jsonapi/materializer/resource/configuration.rb +2 -0
- data/lib/jsonapi/materializer/resource/relation.rb +29 -26
- data/lib/jsonapi/materializer/resource/relationship.rb +23 -20
- data/lib/jsonapi/materializer/resource.rb +86 -78
- data/lib/jsonapi/materializer/resource_spec.rb +10 -8
- data/lib/jsonapi/materializer/version.rb +3 -1
- data/lib/jsonapi/materializer.rb +6 -4
- data/lib/jsonapi/materializer_spec.rb +2 -3
- data/lib/jsonapi-materializer.rb +2 -0
- metadata +25 -205
- data/lib/jsonapi/materializer/version_spec.rb +0 -7
@@ -1,101 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require("spec_helper")
|
2
4
|
|
3
5
|
RSpec.describe(JSONAPI::Materializer::Collection) do
|
4
|
-
let(:described_class) {ArticleMaterializer::Collection}
|
5
|
-
let(:collection) {described_class.new(
|
6
|
+
let(:described_class) { ArticleMaterializer::Collection }
|
7
|
+
let(:collection) { described_class.new(object:, includes: [["comments"], ["author"]]) }
|
6
8
|
|
7
9
|
describe("#as_json") do
|
8
|
-
subject {collection.as_json.deep_stringify_keys}
|
10
|
+
subject { collection.as_json.deep_stringify_keys }
|
9
11
|
|
10
12
|
before do
|
11
|
-
Account.create!(:
|
12
|
-
Account.create!(:
|
13
|
-
Article.create!(:
|
14
|
-
Article.create!(:
|
15
|
-
Article.create!(:
|
16
|
-
Comment.create!(:
|
17
|
-
Comment.create!(:
|
13
|
+
Account.create!(id: 9, name: "Dan Gebhardt", twitter: "dgeb")
|
14
|
+
Account.create!(id: 2, name: "DHH", twitter: "DHH")
|
15
|
+
Article.create!(id: 1, title: "JSON API paints my bikeshed!", account: Account.find(9))
|
16
|
+
Article.create!(id: 2, title: "Rails is Omakase", account: Account.find(9))
|
17
|
+
Article.create!(id: 3, title: "What is JSON:API?", account: Account.find(9))
|
18
|
+
Comment.create!(id: 5, body: "First!", article: Article.find(1), account: Account.find(2))
|
19
|
+
Comment.create!(id: 12, body: "I like XML better", article: Article.find(1), account: Account.find(9))
|
18
20
|
end
|
19
21
|
|
20
22
|
context("when the list has items") do
|
21
|
-
let(:object) {Kaminari.paginate_array(Article.all).page(1).per(1)}
|
23
|
+
let(:object) { Kaminari.paginate_array(Article.all).page(1).per(1) }
|
22
24
|
|
23
25
|
it("has a data key at root with the resources") do
|
24
26
|
expect(subject.fetch("data")).to(eq([{
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
27
|
+
"id" => "1",
|
28
|
+
"type" => "articles",
|
29
|
+
"attributes" => {
|
30
|
+
"title" => "JSON API paints my bikeshed!"
|
31
|
+
},
|
32
|
+
"relationships" => {
|
33
|
+
"author" => {
|
34
|
+
"data" => { "id" => "9", "type" => "people" },
|
35
|
+
"links" => {
|
36
|
+
"self" => "http://example.com/articles/1/relationships/author",
|
37
|
+
"related" => "http://example.com/articles/1/author"
|
38
|
+
}
|
39
|
+
},
|
40
|
+
"comments" => {
|
41
|
+
"data" => [
|
42
|
+
{ "id" => "5", "type" => "comments" },
|
43
|
+
{ "id" => "12", "type" => "comments" }
|
44
|
+
],
|
45
|
+
"links" => {
|
46
|
+
"self" => "http://example.com/articles/1/relationships/comments",
|
47
|
+
"related" => "http://example.com/articles/1/comments"
|
48
|
+
}
|
49
|
+
}
|
50
|
+
},
|
51
|
+
"links" => {
|
52
|
+
"self" => "http://example.com/articles/1"
|
53
|
+
}
|
54
|
+
}]))
|
53
55
|
end
|
54
56
|
|
55
57
|
it("has a links key at root with pagination") do
|
56
58
|
expect(subject.fetch("links")).to(eq(
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
59
|
+
"self" => "http://example.com/articles",
|
60
|
+
"next" => "http://example.com/articles?page[offset]=2&page[limit]=1",
|
61
|
+
"last" => "http://example.com/articles?page[offset]=3&page[limit]=1"
|
62
|
+
))
|
61
63
|
end
|
62
64
|
|
63
65
|
it("has a included key at root with included models") do
|
64
66
|
expect(subject.fetch("included")).to(include(
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
67
|
+
{
|
68
|
+
"id" => "5",
|
69
|
+
"type" => "comments",
|
70
|
+
"attributes" => { "body" => "First!" },
|
71
|
+
"relationships" => {
|
72
|
+
"author" => { "data" => { "id" => "2", "type" => "people" }, "links" => { "self" => "http://example.com/comments/5/relationships/author", "related" => "http://example.com/comments/5/author" } },
|
73
|
+
"article" => { "data" => { "id" => "1", "type" => "articles" }, "links" => { "self" => "http://example.com/comments/5/relationships/article", "related" => "http://example.com/comments/5/article" } }
|
74
|
+
},
|
75
|
+
"links" => { "self" => "http://example.com/comments/5" }
|
76
|
+
},
|
77
|
+
{
|
78
|
+
"id" => "12",
|
79
|
+
"type" => "comments",
|
80
|
+
"attributes" => { "body" => "I like XML better" },
|
81
|
+
"relationships" => {
|
82
|
+
"author" => { "data" => { "id" => "9", "type" => "people" }, "links" => { "self" => "http://example.com/comments/12/relationships/author", "related" => "http://example.com/comments/12/author" } },
|
83
|
+
"article" => { "data" => { "id" => "1", "type" => "articles" }, "links" => { "self" => "http://example.com/comments/12/relationships/article", "related" => "http://example.com/comments/12/article" } }
|
84
|
+
},
|
85
|
+
"links" => { "self" => "http://example.com/comments/12" }
|
86
|
+
},
|
87
|
+
{
|
88
|
+
"id" => "9",
|
89
|
+
"type" => "people",
|
90
|
+
"attributes" => { "name" => "Dan Gebhardt" },
|
91
|
+
"relationships" => {
|
92
|
+
"comments" => { "data" => [{ "id" => "12", "type" => "comments" }], "links" => { "self" => "http://example.com/people/9/relationships/comments", "related" => "http://example.com/people/9/comments" } },
|
93
|
+
"articles" => {
|
94
|
+
"data" => [{ "id" => "1", "type" => "articles" }, { "id" => "2", "type" => "articles" }, { "id" => "3", "type" => "articles" }],
|
95
|
+
"links" => { "self" => "http://example.com/people/9/relationships/articles", "related" => "http://example.com/people/9/articles" }
|
96
|
+
}
|
97
|
+
},
|
98
|
+
"links" => { "self" => "http://example.com/people/9" }
|
99
|
+
}
|
100
|
+
))
|
99
101
|
end
|
100
102
|
end
|
101
103
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module JSONAPI
|
2
4
|
module Materializer
|
3
5
|
module Resource
|
@@ -25,32 +27,28 @@ module JSONAPI
|
|
25
27
|
def for(subject)
|
26
28
|
@for ||= {}
|
27
29
|
@for[checksum(subject)] ||= case type
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
end
|
46
|
-
|
47
|
-
private def fetch_relation(subject)
|
48
|
-
@fetch_relationship ||= {}
|
49
|
-
@fetch_relationship[checksum(subject)] ||= subject.object.public_send(from)
|
30
|
+
when :many
|
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
|
40
|
+
if fetch_relation(subject).present?
|
41
|
+
materializer_class.new(
|
42
|
+
**subject.raw,
|
43
|
+
object: fetch_relation(subject)
|
44
|
+
)
|
45
|
+
end
|
46
|
+
end
|
50
47
|
end
|
48
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
51
49
|
|
52
50
|
def using(parent)
|
53
|
-
Resource::Relationship.new(:
|
51
|
+
Resource::Relationship.new(related: self, parent:)
|
54
52
|
end
|
55
53
|
|
56
54
|
def many?
|
@@ -61,15 +59,20 @@ module JSONAPI
|
|
61
59
|
type == :one
|
62
60
|
end
|
63
61
|
|
62
|
+
private def fetch_relation(subject)
|
63
|
+
@fetch_relationship ||= {}
|
64
|
+
@fetch_relationship[checksum(subject)] ||= subject.object.public_send(from)
|
65
|
+
end
|
66
|
+
|
64
67
|
private def materializer_class
|
65
68
|
class_name.constantize
|
66
69
|
end
|
67
70
|
|
68
71
|
private def unlessing(object, proc)
|
69
|
-
|
70
|
-
yield(object)
|
71
|
-
else
|
72
|
+
if proc.call
|
72
73
|
object
|
74
|
+
else
|
75
|
+
yield(object)
|
73
76
|
end
|
74
77
|
end
|
75
78
|
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module JSONAPI
|
2
4
|
module Materializer
|
3
5
|
module Resource
|
@@ -18,12 +20,12 @@ module JSONAPI
|
|
18
20
|
|
19
21
|
def as_json(*)
|
20
22
|
{
|
21
|
-
|
22
|
-
:
|
23
|
-
:
|
24
|
-
:
|
23
|
+
data:,
|
24
|
+
links: {
|
25
|
+
self: links_self,
|
26
|
+
related: links_related
|
25
27
|
},
|
26
|
-
:
|
28
|
+
meta: {}
|
27
29
|
}.transform_values(&:presence).compact
|
28
30
|
end
|
29
31
|
|
@@ -33,31 +35,32 @@ module JSONAPI
|
|
33
35
|
).pattern
|
34
36
|
end
|
35
37
|
|
36
|
-
|
38
|
+
def links_related
|
37
39
|
Addressable::Template.new(
|
38
40
|
"#{parent.links_self}/#{related.name}"
|
39
41
|
).pattern
|
40
42
|
end
|
41
43
|
|
42
|
-
|
44
|
+
def data
|
43
45
|
return if related_parent_materializer.blank?
|
44
46
|
|
45
47
|
@data ||= if related.many?
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
48
|
+
related_parent_materializer.map do |child|
|
49
|
+
{
|
50
|
+
id: child.attribute("id").for(child).to_s,
|
51
|
+
type: child.type.to_s
|
52
|
+
}
|
53
|
+
end
|
54
|
+
else
|
55
|
+
{
|
56
|
+
id: related_parent_materializer.attribute("id").for(related_parent_materializer).to_s,
|
57
|
+
type: related_parent_materializer.type.to_s
|
58
|
+
}
|
59
|
+
end
|
58
60
|
end
|
61
|
+
# rubocop:enable Metrics/AbcSize
|
59
62
|
|
60
|
-
|
63
|
+
def related_parent_materializer
|
61
64
|
related.for(parent)
|
62
65
|
end
|
63
66
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module JSONAPI
|
2
4
|
module Materializer
|
3
5
|
module Resource
|
@@ -9,11 +11,11 @@ module JSONAPI
|
|
9
11
|
extend(ActiveSupport::Concern)
|
10
12
|
include(ActiveModel::Model)
|
11
13
|
|
12
|
-
MIXIN_HOOK =
|
14
|
+
MIXIN_HOOK = lambda do |*|
|
13
15
|
@attributes = {}
|
14
16
|
@relations = {}
|
15
17
|
|
16
|
-
unless const_defined?(
|
18
|
+
unless const_defined?(:Collection)
|
17
19
|
self::Collection = Class.new do
|
18
20
|
include(JSONAPI::Materializer::Collection)
|
19
21
|
end
|
@@ -42,60 +44,33 @@ module JSONAPI
|
|
42
44
|
|
43
45
|
def as_data
|
44
46
|
{
|
45
|
-
|
46
|
-
|
47
|
-
:
|
48
|
-
transform_values {|attribute| object.public_send(attribute.from)},
|
49
|
-
:
|
50
|
-
transform_values {|relation| relation.using(self).as_json},
|
51
|
-
:
|
52
|
-
:
|
47
|
+
id:,
|
48
|
+
type:,
|
49
|
+
attributes: exposed(attributes.except(:id))
|
50
|
+
.transform_values { |attribute| object.public_send(attribute.from) },
|
51
|
+
relationships: exposed(relations)
|
52
|
+
.transform_values { |relation| relation.using(self).as_json },
|
53
|
+
links: {
|
54
|
+
self: links_self
|
53
55
|
}
|
54
56
|
}.transform_values(&:presence).compact
|
55
57
|
end
|
56
|
-
|
57
|
-
private def exposed(mapping)
|
58
|
-
if selects.any?
|
59
|
-
mapping.slice(*selects.dig(type))
|
60
|
-
else
|
61
|
-
mapping
|
62
|
-
end
|
63
|
-
end
|
58
|
+
# rubocop:enable Metrics/AbcSize
|
64
59
|
|
65
60
|
def as_json(*)
|
66
61
|
{
|
67
|
-
:
|
68
|
-
:
|
62
|
+
links: {
|
63
|
+
self: links_self
|
69
64
|
},
|
70
|
-
:
|
71
|
-
:
|
65
|
+
data: as_data,
|
66
|
+
included:
|
72
67
|
}.transform_values(&:presence).compact
|
73
68
|
end
|
74
69
|
|
75
|
-
private def id
|
76
|
-
object.public_send(identifier).to_s
|
77
|
-
end
|
78
|
-
|
79
70
|
def type
|
80
71
|
self.class.configuration.type.to_s
|
81
72
|
end
|
82
73
|
|
83
|
-
private def attributes
|
84
|
-
self.class.configuration.attributes
|
85
|
-
end
|
86
|
-
|
87
|
-
private def origin
|
88
|
-
self.class.configuration.origin
|
89
|
-
end
|
90
|
-
|
91
|
-
private def identifier
|
92
|
-
self.class.configuration.identifier
|
93
|
-
end
|
94
|
-
|
95
|
-
private def relations
|
96
|
-
self.class.configuration.relations
|
97
|
-
end
|
98
|
-
|
99
74
|
def attribute(name)
|
100
75
|
self.class.attribute(name)
|
101
76
|
end
|
@@ -111,31 +86,20 @@ module JSONAPI
|
|
111
86
|
end
|
112
87
|
|
113
88
|
def selects
|
114
|
-
(@selects || {}).transform_values {|list| list.map(&:to_sym)}
|
89
|
+
(@selects || {}).transform_values { |list| list.map(&:to_sym) }
|
115
90
|
end
|
116
91
|
|
117
92
|
def includes
|
118
93
|
@includes || []
|
119
94
|
end
|
120
95
|
|
121
|
-
private def included
|
122
|
-
@included ||= includes.flat_map do |path|
|
123
|
-
path.reduce(self) do |subject, key|
|
124
|
-
if subject.is_a?(Array)
|
125
|
-
subject.map {|related_subject| related_subject.relation(key).for(subject)}
|
126
|
-
else
|
127
|
-
subject.relation(key).for(subject)
|
128
|
-
end
|
129
|
-
end
|
130
|
-
end.map(&:as_data)
|
131
|
-
end
|
132
|
-
|
133
96
|
included do
|
134
97
|
class_eval(&MIXIN_HOOK) unless @abstract_class
|
135
98
|
end
|
136
99
|
|
137
100
|
class_methods do
|
138
101
|
def inherited(object)
|
102
|
+
super
|
139
103
|
object.class_eval(&MIXIN_HOOK) unless object.instance_variable_defined?(:@abstract_class)
|
140
104
|
end
|
141
105
|
|
@@ -153,51 +117,95 @@ module JSONAPI
|
|
153
117
|
|
154
118
|
def has(name, from: name)
|
155
119
|
@attributes[name] = Attribute.new(
|
156
|
-
:
|
157
|
-
|
158
|
-
:
|
120
|
+
owner: self,
|
121
|
+
name:,
|
122
|
+
from:
|
159
123
|
)
|
160
124
|
end
|
161
125
|
|
162
|
-
|
126
|
+
# rubocop:disable Naming/PredicateName
|
127
|
+
def has_one(name, class_name:, from: name)
|
163
128
|
@relations[name] = Relation.new(
|
164
|
-
:
|
165
|
-
:
|
166
|
-
|
167
|
-
|
168
|
-
:
|
129
|
+
owner: self,
|
130
|
+
type: :one,
|
131
|
+
name:,
|
132
|
+
from:,
|
133
|
+
class_name:
|
169
134
|
)
|
170
135
|
end
|
136
|
+
# rubocop:enable Naming/PredicateName
|
171
137
|
|
172
|
-
|
138
|
+
# rubocop:disable Naming/PredicateName
|
139
|
+
def has_many(name, class_name:, from: name)
|
173
140
|
@relations[name] = Relation.new(
|
174
|
-
:
|
175
|
-
:
|
176
|
-
|
177
|
-
|
178
|
-
:
|
141
|
+
owner: self,
|
142
|
+
type: :many,
|
143
|
+
name:,
|
144
|
+
from:,
|
145
|
+
class_name:
|
179
146
|
)
|
180
147
|
end
|
148
|
+
# rubocop:enable Naming/PredicateName
|
181
149
|
|
182
150
|
def configuration
|
183
151
|
@configuration ||= Configuration.new(
|
184
|
-
:
|
185
|
-
:
|
186
|
-
:
|
187
|
-
:
|
188
|
-
:
|
189
|
-
:
|
152
|
+
owner: self,
|
153
|
+
type: @type,
|
154
|
+
origin: @origin,
|
155
|
+
identifier: @identifier,
|
156
|
+
attributes: @attributes,
|
157
|
+
relations: @relations
|
190
158
|
)
|
191
159
|
end
|
192
160
|
|
193
161
|
def attribute(name)
|
194
|
-
configuration.attributes.fetch(name.to_sym) {raise(Error::ResourceAttributeNotFound,
|
162
|
+
configuration.attributes.fetch(name.to_sym) { raise(Error::ResourceAttributeNotFound, name:, materializer: self) }
|
195
163
|
end
|
196
164
|
|
197
165
|
def relation(name)
|
198
|
-
configuration.relations.fetch(name.to_sym) {raise(Error::ResourceRelationshipNotFound,
|
166
|
+
configuration.relations.fetch(name.to_sym) { raise(Error::ResourceRelationshipNotFound, name:, materializer: self) }
|
199
167
|
end
|
200
168
|
end
|
169
|
+
|
170
|
+
private def exposed(mapping)
|
171
|
+
if selects.any?
|
172
|
+
mapping.slice(*selects[type])
|
173
|
+
else
|
174
|
+
mapping
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
private def id
|
179
|
+
object.public_send(identifier).to_s
|
180
|
+
end
|
181
|
+
|
182
|
+
private def attributes
|
183
|
+
self.class.configuration.attributes
|
184
|
+
end
|
185
|
+
|
186
|
+
private def origin
|
187
|
+
self.class.configuration.origin
|
188
|
+
end
|
189
|
+
|
190
|
+
private def identifier
|
191
|
+
self.class.configuration.identifier
|
192
|
+
end
|
193
|
+
|
194
|
+
private def relations
|
195
|
+
self.class.configuration.relations
|
196
|
+
end
|
197
|
+
|
198
|
+
private def included
|
199
|
+
@included ||= includes.flat_map do |path|
|
200
|
+
path.reduce(self) do |subject, key|
|
201
|
+
if subject.is_a?(Array)
|
202
|
+
subject.map { |related_subject| related_subject.relation(key).for(subject) }
|
203
|
+
else
|
204
|
+
subject.relation(key).for(subject)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end.map(&:as_data)
|
208
|
+
end
|
201
209
|
end
|
202
210
|
end
|
203
211
|
end
|