jsonapi-materializer 1.0.0.rc2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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,3 @@
1
+ module JSONAPI
2
+ require_relative("jsonapi/materializer")
3
+ end
@@ -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