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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 839a143859ef0a7e0a4cdbb14df6f244379dcaffcdee698e1f5b84db81f1e17d
|
4
|
+
data.tar.gz: 8053caead68127f9819da92c4e7adf02ab84ba0bd39b5da9ebefb47c2b3cfc1e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 329f5f40bc229719d95bfa85cf4969d0cbac81eed8315703602d7518db2b5d114db13b4253945248ba99689f37b5a968c918b0c6065e3a90e7f262c997584dde
|
7
|
+
data.tar.gz: 1d08b4783f94b7b55cb38e5fb664ed1dc36f2ddc41f67f119f6338aac3d6d6d2d6de6caa0093ba7488169b834ad0119d60527b60ee6c62a810c9f72b4bc5b56a
|
data/README.md
ADDED
@@ -0,0 +1,211 @@
|
|
1
|
+
# jsonapi-materializer
|
2
|
+
|
3
|
+
- [](https://travis-ci.org/krainboltgreene/jsonapi-materializer.rb)
|
4
|
+
- [](https://rubygems.org/gems/jsonapi-materializer)
|
5
|
+
- [](https://rubygems.org/gems/jsonapi-materializer)
|
6
|
+
|
7
|
+
jsonapi-materializer is a way to turn data objects (for example, active record models) into json:api responses. Largely the library doesn't care *what* it's given, as long as it responds to certain calls.
|
8
|
+
|
9
|
+
|
10
|
+
## Using
|
11
|
+
|
12
|
+
Lets say we have a simple rails application setup for our project. We'll define the model first:
|
13
|
+
|
14
|
+
``` ruby
|
15
|
+
class Account < ApplicationRecord
|
16
|
+
has_many(:articles)
|
17
|
+
has_many(:comments)
|
18
|
+
|
19
|
+
def self.schema
|
20
|
+
ActiveRecord::Migration.create_table(:accounts, :force => true) do |table|
|
21
|
+
table.text(:name, :null => false)
|
22
|
+
table.timestamps(:null => false)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
```
|
27
|
+
|
28
|
+
And a controller:
|
29
|
+
|
30
|
+
``` ruby
|
31
|
+
class AccountsController < ApplicationController
|
32
|
+
def index
|
33
|
+
render(
|
34
|
+
:json => AccountMaterializer::Collection.new(:object => object)
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
def show
|
39
|
+
render(
|
40
|
+
:json => AccountMaterializer::Resource.new(:object => object)
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
```
|
45
|
+
|
46
|
+
Finally, lets setup `JSONAPI::Materializer`:
|
47
|
+
|
48
|
+
``` ruby
|
49
|
+
JSONAPI::Materializer.configuration do |let|
|
50
|
+
let.default_origin = "http://localhost:3001"
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
Now you need to define a materializer, which is a class that determines how and what to return as a json:api response:
|
55
|
+
|
56
|
+
``` ruby
|
57
|
+
class AccountMaterializer
|
58
|
+
include(JSONAPI::Materializer::Resource)
|
59
|
+
|
60
|
+
type(:accounts)
|
61
|
+
|
62
|
+
has_many(:reviews, :class_name => "ReviewMaterializer")
|
63
|
+
|
64
|
+
has(:name)
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
That's it! Your endpoint should correctly return:
|
69
|
+
|
70
|
+
``` json
|
71
|
+
{
|
72
|
+
"links": {
|
73
|
+
"self": "http://localhost:3001/accounts/cf305673-ce1f-4605-8aa4-cba33a6a5a17"
|
74
|
+
},
|
75
|
+
"data": {
|
76
|
+
"id": "cf305673-ce1f-4605-8aa4-cba33a6a5a17",
|
77
|
+
"type": "accounts",
|
78
|
+
"attributes": {
|
79
|
+
"name": "Sally Stuthers"
|
80
|
+
},
|
81
|
+
"relationships": {
|
82
|
+
"reviews": {
|
83
|
+
"data": [
|
84
|
+
{
|
85
|
+
"id": "91a8ca48-df58-423c-bf36-344cd07e1a51",
|
86
|
+
"type": "reviews"
|
87
|
+
}
|
88
|
+
],
|
89
|
+
"links": {
|
90
|
+
"self": "http://localhost:3001/accounts/cf305673-ce1f-4605-8aa4-cba33a6a5a17/relationships/reviews",
|
91
|
+
"related": "http://localhost:3001/accounts/cf305673-ce1f-4605-8aa4-cba33a6a5a17/reviews"
|
92
|
+
}
|
93
|
+
}
|
94
|
+
},
|
95
|
+
"links": {
|
96
|
+
"self": "http://localhost:3001/accounts/cf305673-ce1f-4605-8aa4-cba33a6a5a17"
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}
|
100
|
+
```
|
101
|
+
|
102
|
+
You're going to want to handle both sparse fieldset and includes, but materializer doesn't do any of that work for you:
|
103
|
+
|
104
|
+
``` ruby
|
105
|
+
class AccountsController < ApplicationController
|
106
|
+
def index
|
107
|
+
render(
|
108
|
+
:json => AccountMaterializer::Collection.new(
|
109
|
+
:object => object,
|
110
|
+
:selects => {"accounts" => ["name"]},
|
111
|
+
:includes => [["reviews"]]
|
112
|
+
)
|
113
|
+
)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
We suggest [jsonapi-realizer](https://github.com/krainboltgreene/jsonapi-realizer.rb) to handle this for you.
|
119
|
+
|
120
|
+
|
121
|
+
### rails
|
122
|
+
|
123
|
+
There is *nothing* specific about rails for this library, it can be used in any framework. You just need:
|
124
|
+
|
125
|
+
0. A place to turn models into json (rails controller)
|
126
|
+
0. A place to store the configuration at boot (rails initializers)
|
127
|
+
|
128
|
+
|
129
|
+
### policies (aka pundit)
|
130
|
+
|
131
|
+
If you're using some sort of policy logic like pundit you'll have the ability to pass it as a context to the materializer:
|
132
|
+
|
133
|
+
``` ruby
|
134
|
+
class AccountsController < ApplicationController
|
135
|
+
def show
|
136
|
+
context = {
|
137
|
+
:policy => policy
|
138
|
+
}
|
139
|
+
render(
|
140
|
+
:json => AccountMaterializer::Resource.new(:object => object, :context => context)
|
141
|
+
)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
```
|
145
|
+
|
146
|
+
And now the use of that context object:
|
147
|
+
|
148
|
+
``` ruby
|
149
|
+
class AccountMaterializer
|
150
|
+
include(JSONAPI::Materializer::Resource)
|
151
|
+
|
152
|
+
type(:accounts)
|
153
|
+
|
154
|
+
has_many(:reviews, :class_name => "ReviewMaterializer")
|
155
|
+
|
156
|
+
has(:name, :visible => :readable_attribute?)
|
157
|
+
|
158
|
+
private def readable_attribute?(attribute)
|
159
|
+
context.policy.read_attribute?(attribute.from)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
```
|
163
|
+
|
164
|
+
You'll notice that context is an object, not a hash, when referenced on the materializer. That's because we give you the ability to enforce the context for saftey:
|
165
|
+
|
166
|
+
``` ruby
|
167
|
+
|
168
|
+
class AccountMaterializer
|
169
|
+
include(JSONAPI::Materializer::Resource)
|
170
|
+
|
171
|
+
type(:accounts)
|
172
|
+
|
173
|
+
has_many(:reviews, :class_name => "ReviewMaterializer")
|
174
|
+
|
175
|
+
has(:name, :visible => :readable_attribute?)
|
176
|
+
|
177
|
+
context.validates_presence_of(:policy)
|
178
|
+
|
179
|
+
private def readable_attribute?(attribute)
|
180
|
+
context.policy.read_attribute?(attribute.from)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
```
|
184
|
+
|
185
|
+
These are just aliases for ActiveModel::Validations.
|
186
|
+
|
187
|
+
|
188
|
+
### Sister Projects
|
189
|
+
|
190
|
+
I'm already using jsonapi-materializer. and it's sister project [jsonapi-realizer](https://github.com/krainboltgreene/jsonapi-realizer.rb) in a new gem of mine that allows services to be discoverable: [jsonapi-home](https://github.com/krainboltgreene/jsonapi-home.rb).
|
191
|
+
|
192
|
+
|
193
|
+
## Installing
|
194
|
+
|
195
|
+
Add this line to your application's Gemfile:
|
196
|
+
|
197
|
+
$ bundle add jsonapi-materializer
|
198
|
+
|
199
|
+
Or install it yourself with:
|
200
|
+
|
201
|
+
$ gem install jsonapi-materializer
|
202
|
+
|
203
|
+
|
204
|
+
## Contributing
|
205
|
+
|
206
|
+
1. Read the [Code of Conduct](/CONDUCT.md)
|
207
|
+
2. Fork it
|
208
|
+
3. Create your feature branch (`git checkout -b my-new-feature`)
|
209
|
+
4. Commit your changes (`git commit -am 'Add some feature'`)
|
210
|
+
5. Push to the branch (`git push origin my-new-feature`)
|
211
|
+
6. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
|
3
|
+
require("bundler/gem_tasks")
|
4
|
+
require("rspec/core/rake_task")
|
5
|
+
|
6
|
+
desc("Run all the tests in spec")
|
7
|
+
RSpec::Core::RakeTask.new(:spec) do |let|
|
8
|
+
let.pattern = "lib/**{,/*/**}/*_spec.rb"
|
9
|
+
end
|
10
|
+
|
11
|
+
desc("Default: run tests")
|
12
|
+
task(:default => :spec)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require("ostruct")
|
2
|
+
require("addressable")
|
3
|
+
require("active_model")
|
4
|
+
require("kaminari")
|
5
|
+
require("active_support/concern")
|
6
|
+
require("active_support/core_ext/enumerable")
|
7
|
+
require("active_support/core_ext/string")
|
8
|
+
require("active_support/core_ext/module")
|
9
|
+
|
10
|
+
module JSONAPI
|
11
|
+
MEDIA_TYPE = "application/vnd.api+json".freeze unless const_defined?("MEDIA_TYPE")
|
12
|
+
|
13
|
+
module Materializer
|
14
|
+
require_relative("materializer/version")
|
15
|
+
require_relative("materializer/error")
|
16
|
+
require_relative("materializer/configuration")
|
17
|
+
require_relative("materializer/controller")
|
18
|
+
|
19
|
+
@configuration ||= Configuration.new(
|
20
|
+
:default_invalid_accept_exception => JSONAPI::Materializer::Error::InvalidAcceptHeader,
|
21
|
+
:default_missing_accept_exception => JSONAPI::Materializer::Error::MissingAcceptHeader,
|
22
|
+
:default_identifier => :id
|
23
|
+
)
|
24
|
+
require_relative("materializer/collection")
|
25
|
+
require_relative("materializer/context")
|
26
|
+
require_relative("materializer/resource")
|
27
|
+
|
28
|
+
def self.configuration
|
29
|
+
if block_given?
|
30
|
+
yield(@configuration)
|
31
|
+
else
|
32
|
+
@configuration
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
module Materializer
|
3
|
+
module Collection
|
4
|
+
SELF_TEMPLATE = "{origin}/{type}".freeze
|
5
|
+
|
6
|
+
extend(ActiveSupport::Concern)
|
7
|
+
include(ActiveModel::Model)
|
8
|
+
|
9
|
+
attr_accessor(:object)
|
10
|
+
attr_writer(:selects)
|
11
|
+
attr_writer(:includes)
|
12
|
+
attr_writer(:pagination)
|
13
|
+
attr_accessor(:context)
|
14
|
+
|
15
|
+
delegate(:first_page?, :to => :object)
|
16
|
+
delegate(:prev_page, :to => :object)
|
17
|
+
delegate(:total_pages, :to => :object)
|
18
|
+
delegate(:next_page, :to => :object)
|
19
|
+
delegate(:last_page?, :to => :object)
|
20
|
+
delegate(:limit_value, :to => :object)
|
21
|
+
|
22
|
+
def as_json(*)
|
23
|
+
{
|
24
|
+
:links => {
|
25
|
+
:first => unless total_pages.zero? || first_page? then links_pagination.expand(:offset => 1, :limit => limit_value).to_s end,
|
26
|
+
:prev => unless total_pages.zero? || first_page? then links_pagination.expand(:offset => prev_page, :limit => limit_value).to_s end,
|
27
|
+
:self => unless total_pages.zero? then links_self end,
|
28
|
+
:next => unless total_pages.zero? || last_page? then links_pagination.expand(:offset => next_page, :limit => limit_value).to_s end,
|
29
|
+
:last => unless total_pages.zero? || last_page? then links_pagination.expand(:offset => total_pages, :limit => limit_value).to_s end
|
30
|
+
}.compact,
|
31
|
+
:data => resources,
|
32
|
+
:included => included
|
33
|
+
}.transform_values(&:presence).compact
|
34
|
+
end
|
35
|
+
|
36
|
+
private def materializers
|
37
|
+
@materializers ||= object.map {|subobject| self.class.parent.new(:object => subobject, :selects => selects, :includes => includes, :context => context)}
|
38
|
+
end
|
39
|
+
|
40
|
+
private def links_pagination
|
41
|
+
Addressable::Template.new(
|
42
|
+
"#{origin}/#{type}?page[offset]={offset}&page[limit]={limit}"
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
private def links_self
|
47
|
+
Addressable::Template.new(
|
48
|
+
"#{origin}/#{type}"
|
49
|
+
).pattern
|
50
|
+
end
|
51
|
+
|
52
|
+
private def origin
|
53
|
+
self.class.parent.instance_variable_get(:@origin)
|
54
|
+
end
|
55
|
+
|
56
|
+
private def type
|
57
|
+
self.class.parent.instance_variable_get(:@type)
|
58
|
+
end
|
59
|
+
|
60
|
+
private def resources
|
61
|
+
@resources ||= materializers.map(&:as_data)
|
62
|
+
end
|
63
|
+
|
64
|
+
private def selects
|
65
|
+
@selects
|
66
|
+
end
|
67
|
+
|
68
|
+
private def includes
|
69
|
+
@includes || []
|
70
|
+
end
|
71
|
+
|
72
|
+
private def included
|
73
|
+
@included ||= materializers.flat_map do |materializer|
|
74
|
+
includes.flat_map do |path|
|
75
|
+
path.reduce(materializer) do |subject, key|
|
76
|
+
if subject.is_a?(Array)
|
77
|
+
subject.map {|related_subject| related_subject.relation(key).for(related_subject)}
|
78
|
+
else
|
79
|
+
subject.relation(key).for(subject)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end.uniq.map(&:as_data)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require("spec_helper")
|
2
|
+
|
3
|
+
RSpec.describe(JSONAPI::Materializer::Collection) do
|
4
|
+
let(:described_class) {ArticleMaterializer::Collection}
|
5
|
+
let(:policy) {Class.new { def read_attribute?(name); true end}}
|
6
|
+
let(:collection) {described_class.new(:object => object, :includes => [["comments"], ["author"]], :context => {:policy => policy.new})}
|
7
|
+
|
8
|
+
describe("#as_json") do
|
9
|
+
subject {collection.as_json.deep_stringify_keys}
|
10
|
+
|
11
|
+
before do
|
12
|
+
Account.create!(:id => 9, :name => "Dan Gebhardt", :twitter => "dgeb")
|
13
|
+
Account.create!(:id => 2, :name => "DHH", :twitter => "DHH")
|
14
|
+
Article.create!(:id => 1, :title => "JSON API paints my bikeshed!", :account => Account.find(9))
|
15
|
+
Article.create!(:id => 2, :title => "Rails is Omakase", :account => Account.find(9))
|
16
|
+
Article.create!(:id => 3, :title => "What is JSON:API?", :account => Account.find(9))
|
17
|
+
Comment.create!(:id => 5, :body => "First!", :article => Article.find(1), :account => Account.find(2))
|
18
|
+
Comment.create!(:id => 12, :body => "I like XML better", :article => Article.find(1), :account => Account.find(9))
|
19
|
+
end
|
20
|
+
|
21
|
+
context("when the list has items") do
|
22
|
+
let(:object) {Kaminari.paginate_array(Article.all).page(1).per(1)}
|
23
|
+
|
24
|
+
it("has a data key at root with the resources") do
|
25
|
+
expect(subject.fetch("data")).to(eq([{
|
26
|
+
"id" => "1",
|
27
|
+
"type" => "articles",
|
28
|
+
"attributes" => {
|
29
|
+
"title" => "JSON API paints my bikeshed!"
|
30
|
+
},
|
31
|
+
"relationships" => {
|
32
|
+
"author" => {
|
33
|
+
"data" => {"id" => "9", "type" => "people"},
|
34
|
+
"links" => {
|
35
|
+
"self" => "http://example.com/articles/1/relationships/author",
|
36
|
+
"related" => "http://example.com/articles/1/author"
|
37
|
+
}
|
38
|
+
},
|
39
|
+
"comments" => {
|
40
|
+
"data" => [
|
41
|
+
{"id" => "5", "type" => "comments"},
|
42
|
+
{"id" => "12", "type" => "comments"}
|
43
|
+
],
|
44
|
+
"links" => {
|
45
|
+
"self" => "http://example.com/articles/1/relationships/comments",
|
46
|
+
"related" => "http://example.com/articles/1/comments"
|
47
|
+
}
|
48
|
+
}
|
49
|
+
},
|
50
|
+
"links" => {
|
51
|
+
"self" => "http://example.com/articles/1"
|
52
|
+
}
|
53
|
+
}]))
|
54
|
+
end
|
55
|
+
|
56
|
+
it("has a links key at root with pagination") do
|
57
|
+
expect(subject.fetch("links")).to(eq(
|
58
|
+
"self" => "http://example.com/articles",
|
59
|
+
"next" => "http://example.com/articles?page[offset]=2&page[limit]=1",
|
60
|
+
"last" => "http://example.com/articles?page[offset]=3&page[limit]=1"
|
61
|
+
))
|
62
|
+
end
|
63
|
+
|
64
|
+
it("has a included key at root with included models") do
|
65
|
+
expect(subject.fetch("included")).to(include(
|
66
|
+
{
|
67
|
+
"id" => "5",
|
68
|
+
"type" => "comments",
|
69
|
+
"attributes"=>{"body"=>"First!"},
|
70
|
+
"relationships" => {
|
71
|
+
"author" => {"data" => {"id" => "2", "type" => "people"}, "links" => {"self" => "http://example.com/comments/5/relationships/author", "related" => "http://example.com/comments/5/author"}},
|
72
|
+
"article" => {"data" => {"id" => "1", "type" => "articles"}, "links" => {"self" => "http://example.com/comments/5/relationships/article", "related" => "http://example.com/comments/5/article"}}
|
73
|
+
},
|
74
|
+
"links" => {"self" => "http://example.com/comments/5"}
|
75
|
+
},
|
76
|
+
{
|
77
|
+
"id" => "12",
|
78
|
+
"type" => "comments",
|
79
|
+
"attributes"=>{"body"=>"I like XML better"},
|
80
|
+
"relationships" => {
|
81
|
+
"author" => {"data" => {"id" => "9", "type" => "people"}, "links" => {"self" => "http://example.com/comments/12/relationships/author", "related" => "http://example.com/comments/12/author"}},
|
82
|
+
"article" => {"data" => {"id" => "1", "type" => "articles"}, "links" => {"self" => "http://example.com/comments/12/relationships/article", "related" => "http://example.com/comments/12/article"}}
|
83
|
+
},
|
84
|
+
"links" => {"self" => "http://example.com/comments/12"}
|
85
|
+
},
|
86
|
+
{
|
87
|
+
"id" => "9",
|
88
|
+
"type" => "people",
|
89
|
+
"attributes"=>{"name"=>"Dan Gebhardt"},
|
90
|
+
"relationships" => {
|
91
|
+
"comments" => {"data" => [{"id" => "12", "type" => "comments"}], "links" => {"self" => "http://example.com/people/9/relationships/comments", "related" => "http://example.com/people/9/comments"}},
|
92
|
+
"articles" => {
|
93
|
+
"data" => [{"id" => "1", "type" => "articles"}, {"id" => "2", "type" => "articles"}, {"id" => "3", "type" => "articles"}],
|
94
|
+
"links" => {"self" => "http://example.com/people/9/relationships/articles", "related" => "http://example.com/people/9/articles"}
|
95
|
+
}
|
96
|
+
},
|
97
|
+
"links" => {"self" => "http://example.com/people/9"}
|
98
|
+
}
|
99
|
+
))
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|