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
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
|
+
- [![Build](http://img.shields.io/travis-ci/krainboltgreene/jsonapi-materializer.rb.svg?style=flat-square)](https://travis-ci.org/krainboltgreene/jsonapi-materializer.rb)
|
4
|
+
- [![Downloads](http://img.shields.io/gem/dtv/jsonapi-materializer.svg?style=flat-square)](https://rubygems.org/gems/jsonapi-materializer)
|
5
|
+
- [![Version](http://img.shields.io/gem/v/jsonapi-materializer.svg?style=flat-square)](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
|