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.
@@ -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