restpack_serializer 0.2.3

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,115 @@
1
+ module RestPack::Serializer::SideLoading
2
+ extend ActiveSupport::Concern
3
+
4
+ module ClassMethods
5
+ def side_loads(models, options)
6
+ side_loads = {
7
+ :meta => { }
8
+ }
9
+ return side_loads if models.empty? || options.includes.nil?
10
+
11
+ options.includes.each do |include|
12
+ side_load_data = side_load(include, models, options)
13
+ side_loads[:meta].merge!(side_load_data[:meta] || {})
14
+ side_loads.merge! side_load_data.except(:meta)
15
+ end
16
+ side_loads
17
+ end
18
+
19
+ def filterable_by
20
+ filters = [self.model_class.primary_key.to_sym]
21
+ filters += self.model_class.reflect_on_all_associations(:belongs_to).map(&:foreign_key).map(&:to_sym)
22
+ filters.uniq
23
+ end
24
+
25
+ def can_includes
26
+ @can_includes || []
27
+ end
28
+
29
+ def can_include(*includes)
30
+ @can_includes ||= []
31
+ @can_includes += includes
32
+ end
33
+
34
+ def links
35
+ links = {}
36
+
37
+ associations.each do |association|
38
+ if association.macro == :belongs_to
39
+ href = "/#{association.plural_name}/{#{self.key}.#{association.name}}.json"
40
+ elsif association.macro == :has_many
41
+ singular_key = self.key.to_s.singularize
42
+ href = "/#{association.plural_name}.json?#{singular_key}_id={#{key}.id}"
43
+ end
44
+
45
+ links["#{self.key}.#{association.plural_name}"] = {
46
+ :href => href,
47
+ :type => association.plural_name.to_sym
48
+ }
49
+ end
50
+
51
+ links
52
+ end
53
+
54
+ def associations
55
+ associations = []
56
+ can_includes.each do |include|
57
+ association = association_from_include(include)
58
+ associations << association if supported_association?(association)
59
+ end
60
+ associations
61
+ end
62
+
63
+ private
64
+
65
+ def side_load(include, models, options)
66
+ association = association_from_include(include)
67
+
68
+ if supported_association?(association)
69
+ serializer = RestPack::Serializer::Factory.create(association.class_name)
70
+ return send("side_load_#{association.macro}", association, models, serializer)
71
+ else
72
+ return {}
73
+ end
74
+ end
75
+
76
+ def supported_association?(association)
77
+ [:belongs_to, :has_many].include?(association.macro)
78
+ end
79
+
80
+ def side_load_belongs_to(association, models, serializer)
81
+ foreign_keys = models.map { |model| model.send(association.foreign_key) }.uniq
82
+ side_load = association.klass.find(foreign_keys)
83
+
84
+ return {
85
+ association.plural_name.to_sym => side_load.map { |model| serializer.as_json(model) },
86
+ :meta => { }
87
+ }
88
+ end
89
+
90
+ def side_load_has_many(association, models, serializer)
91
+ return {} if models.empty?
92
+ options = RestPack::Serializer::Options.new(serializer.class.model_class)
93
+ options.filters = { association.foreign_key.to_sym => models.map(&:id) }
94
+ options.include_links = false
95
+ return serializer.class.page_with_options(options)
96
+ end
97
+
98
+ def association_from_include(include)
99
+ raise_invalid_include(include) unless self.can_includes.include?(include)
100
+
101
+ possible_relations = [include.to_s.singularize.to_sym, include]
102
+ possible_relations.each do |relation|
103
+ association = self.model_class.reflect_on_association(relation)
104
+ return association unless association.nil?
105
+ end
106
+
107
+ raise_invalid_include(include)
108
+ end
109
+
110
+ def raise_invalid_include(include)
111
+ raise RestPack::Serializer::InvalidInclude.new,
112
+ ":#{include} is not a valid include for #{self.model_class}"
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,69 @@
1
+ require 'active_support/concern'
2
+ require_relative "options"
3
+ require_relative "serializable/attributes"
4
+ require_relative "serializable/paging"
5
+ require_relative "serializable/resource"
6
+ require_relative "serializable/side_loading"
7
+
8
+ module RestPack
9
+ module Serializer
10
+ extend ActiveSupport::Concern
11
+
12
+ include RestPack::Serializer::Paging
13
+ include RestPack::Serializer::Resource
14
+ include RestPack::Serializer::Attributes
15
+ include RestPack::Serializer::SideLoading
16
+
17
+ class InvalidInclude < Exception; end
18
+
19
+ def as_json(model, options = {})
20
+ @model, @options = model, options
21
+
22
+ data = {}
23
+ if self.class.serializable_attributes.present?
24
+ self.class.serializable_attributes.each do |key, name|
25
+ data[key] = self.send(name) if include_attribute?(name)
26
+ end
27
+ end
28
+
29
+ add_links(model, data)
30
+
31
+ data
32
+ end
33
+
34
+ private
35
+
36
+ def add_links(model, data)
37
+ self.class.associations.each do |association|
38
+ if association.macro == :belongs_to
39
+ data[:links] ||= {}
40
+ data[:links][association.name.to_sym] = model.send(association.foreign_key).to_s
41
+ end
42
+ end
43
+ data
44
+ end
45
+
46
+ def include_attribute?(name)
47
+ self.send("include_#{name}?".to_sym)
48
+ end
49
+
50
+ module ClassMethods
51
+ def as_json(model, options = {})
52
+ new.as_json(model, options)
53
+ end
54
+
55
+ def model_name
56
+ self.name.chomp('Serializer')
57
+ end
58
+
59
+ def model_class
60
+ model_name.constantize
61
+ end
62
+
63
+ def key
64
+ self.model_class.send(:table_name).to_sym
65
+ end
66
+
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ module RestPack
2
+ module Serializer
3
+ VERSION = '0.2.3'
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ require 'will_paginate'
2
+ require 'will_paginate/active_record'
3
+
4
+ require 'restpack_serializer/version'
5
+ require 'restpack_serializer/serializable'
6
+ require 'restpack_serializer/factory'
@@ -0,0 +1,32 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'restpack_serializer/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "restpack_serializer"
8
+ gem.version = RestPack::Serializer::VERSION
9
+ gem.authors = ["Gavin Joyce"]
10
+ gem.email = ["gavinjoyce@gmail.com"]
11
+ gem.description = %q{Model serialization, paging, side-loading and filtering}
12
+ gem.summary = %q{Model serialization, paging, side-loading and filtering}
13
+ gem.homepage = "https://github.com/RestPack"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ gem.add_dependency 'activerecord', '>= 3.0'
21
+ gem.add_dependency 'activesupport', '>= 3.0'
22
+ gem.add_dependency 'will_paginate', '~> 3.0'
23
+
24
+ gem.add_development_dependency 'rake', '~> 10.0.3'
25
+ gem.add_development_dependency 'rspec', '~> 2.12'
26
+ gem.add_development_dependency 'guard-rspec', '~> 2.5.4'
27
+ gem.add_development_dependency 'growl', '~> 1.0.3'
28
+ gem.add_development_dependency 'factory_girl', '~> 4.2.0'
29
+ gem.add_development_dependency 'sqlite3', '~> 1.3.7'
30
+ gem.add_development_dependency 'database_cleaner', '~> 0.9.1'
31
+ gem.add_development_dependency 'bump'
32
+ end
@@ -0,0 +1,28 @@
1
+ require './spec/spec_helper'
2
+
3
+ describe RestPack::Serializer::Factory do
4
+ let(:factory) { RestPack::Serializer::Factory }
5
+
6
+ it "creates by string" do
7
+ factory.create("Song").should be_an_instance_of(SongSerializer)
8
+ end
9
+ it "creates by lowercase string" do
10
+ factory.create("song").should be_an_instance_of(SongSerializer)
11
+ end
12
+ it "creates by lowercase plural string" do
13
+ factory.create("songs").should be_an_instance_of(SongSerializer)
14
+ end
15
+ it "creates by symbol" do
16
+ factory.create(:song).should be_an_instance_of(SongSerializer)
17
+ end
18
+ it "creates by class" do
19
+ factory.create(Song).should be_an_instance_of(SongSerializer)
20
+ end
21
+
22
+ it "creates multiple with Array" do
23
+ serializers = factory.create("Song", "artists", :album)
24
+ serializers[0].should be_an_instance_of(SongSerializer)
25
+ serializers[1].should be_an_instance_of(ArtistSerializer)
26
+ serializers[2].should be_an_instance_of(AlbumSerializer)
27
+ end
28
+ end
@@ -0,0 +1,68 @@
1
+ require 'sqlite3'
2
+ require 'active_record'
3
+
4
+ ActiveRecord::Base.establish_connection(
5
+ :adapter => 'sqlite3',
6
+ :database => 'test.db'
7
+ )
8
+
9
+ ActiveRecord::Schema.define(:version => 1) do
10
+ create_table "artists", :force => true do |t|
11
+ t.string "name"
12
+ t.string "website"
13
+ t.datetime "created_at"
14
+ t.datetime "updated_at"
15
+ end
16
+
17
+ create_table "albums", :force => true do |t|
18
+ t.string "title"
19
+ t.integer "year"
20
+ t.integer "artist_id"
21
+ t.datetime "created_at"
22
+ t.datetime "updated_at"
23
+ end
24
+
25
+ create_table "songs", :force => true do |t|
26
+ t.string "title"
27
+ t.integer "album_id"
28
+ t.integer "artist_id"
29
+ t.datetime "created_at"
30
+ t.datetime "updated_at"
31
+ end
32
+
33
+ create_table "payments", :force => true do |t|
34
+ t.integer "amount"
35
+ t.integer "artist_id"
36
+ t.datetime "created_at"
37
+ t.datetime "updated_at"
38
+ end
39
+ end
40
+
41
+ class Artist < ActiveRecord::Base
42
+ attr_accessible :name, :website
43
+
44
+ has_many :albums
45
+ has_many :songs
46
+ has_many :payments
47
+ end
48
+
49
+ class Album < ActiveRecord::Base
50
+ attr_accessible :title, :year, :artist
51
+ scope :classic, where("year < 1950")
52
+
53
+ belongs_to :artist
54
+ has_many :songs
55
+ end
56
+
57
+ class Song < ActiveRecord::Base
58
+ attr_accessible :title, :artist, :album
59
+
60
+ belongs_to :artist
61
+ belongs_to :album
62
+ end
63
+
64
+ class Payment < ActiveRecord::Base
65
+ attr_accessible :amount, :artist
66
+
67
+ belongs_to :artist
68
+ end
@@ -0,0 +1,17 @@
1
+ class SongSerializer
2
+ include RestPack::Serializer
3
+ attributes :id, :title, :album_id
4
+ can_include :albums, :artists
5
+ end
6
+
7
+ class AlbumSerializer
8
+ include RestPack::Serializer
9
+ attributes :id, :title, :year, :artist_id
10
+ can_include :artists, :songs
11
+ end
12
+
13
+ class ArtistSerializer
14
+ include RestPack::Serializer
15
+ attributes :id, :name, :website
16
+ can_include :albums, :songs
17
+ end
@@ -0,0 +1,27 @@
1
+ require './spec/spec_helper'
2
+
3
+ describe RestPack::Serializer::Attributes do
4
+ class CustomSerializer
5
+ include RestPack::Serializer
6
+ attributes :a, :b, :c
7
+ attribute :old_attribute, :key => :new_key
8
+ end
9
+
10
+ before do
11
+ @attributes = CustomSerializer.serializable_attributes
12
+ end
13
+
14
+ it "correctly models specified attributes" do
15
+ @attributes.length.should == 4
16
+ end
17
+
18
+ it "correctly maps normal attributes" do
19
+ [:a, :b, :c].each do |attr|
20
+ @attributes[attr].should == attr
21
+ end
22
+ end
23
+
24
+ it "correctly maps attribute with :key options" do
25
+ @attributes[:new_key].should == :old_attribute
26
+ end
27
+ end
@@ -0,0 +1,65 @@
1
+ require './spec/spec_helper'
2
+
3
+ describe RestPack::Serializer::Options do
4
+ let(:subject) { RestPack::Serializer::Options.new(Song, params, scope) }
5
+ let(:params) { {} }
6
+ let(:scope) { nil }
7
+
8
+ describe 'default values' do
9
+ it { subject.model_class.should == Song }
10
+ it { subject.includes.should == [] }
11
+ it { subject.page.should == 1 }
12
+ it { subject.page_size.should == 10 }
13
+ it { subject.filters.should == {} }
14
+ it { subject.scope.should == Song.scoped }
15
+ end
16
+
17
+ describe 'with paging params' do
18
+ let(:params) { { 'page' => '2', 'page_size' => '8' } }
19
+ it { subject.page.should == 2 }
20
+ it { subject.page_size.should == 8 }
21
+ end
22
+
23
+ describe 'with includes' do
24
+ let(:params) { { 'includes' => 'model1,model2' } }
25
+ it { subject.includes.should == [:model1, :model2] }
26
+ end
27
+
28
+ context 'with filters' do
29
+ describe 'with no filter params' do
30
+ let(:params) { { } }
31
+ it { subject.filters.should == {} }
32
+ end
33
+ describe 'with a primary key with a single value' do
34
+ let(:params) { { 'id' => '142857' } }
35
+ it { subject.filters.should == { id: ['142857'] } }
36
+ end
37
+ describe 'with a primary key with multiple values' do
38
+ let(:params) { { 'ids' => '42,142857' } }
39
+ it { subject.filters.should == { id: ['42', '142857'] } }
40
+ end
41
+ describe 'with a foreign key with a single value' do
42
+ let(:params) { { 'album_id' => '789' } }
43
+ it { subject.filters.should == { album_id: ['789'] } }
44
+ end
45
+ describe 'with a foreign key with multiple values' do
46
+ let(:params) { { 'album_id' => '789,678,567' } }
47
+ it { subject.filters.should == { album_id: ['789', '678', '567'] } }
48
+ end
49
+ describe 'with multiple foreign keys' do
50
+ let(:params) { { 'album_id' => '111,222', 'artist_id' => '888,999' } }
51
+ it { subject.filters.should == { album_id: ['111', '222'], artist_id: ['888', '999'] } }
52
+ end
53
+ end
54
+
55
+ context 'scopes' do
56
+ describe 'with default scope' do
57
+ it { subject.scope.should == Song.scoped }
58
+ end
59
+
60
+ describe 'with custom scope' do
61
+ let(:scope) { Song.where("id >= 100") }
62
+ it { subject.scope.should == scope }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,184 @@
1
+ require './spec/spec_helper'
2
+
3
+ describe RestPack::Serializer::Paging do
4
+ before(:each) do
5
+ @album1 = FactoryGirl.create(:album_with_songs, song_count: 11)
6
+ @album2 = FactoryGirl.create(:album_with_songs, song_count: 7)
7
+ end
8
+
9
+ context "#page" do
10
+ let(:page) { SongSerializer.page(params) }
11
+ let(:params) { { } }
12
+
13
+ context "with defaults" do
14
+ it "page defaults to 1" do
15
+ page[:meta][:songs][:page].should == 1
16
+ end
17
+ it "page_size defaults to 10" do
18
+ page[:meta][:songs][:page_size].should == 10
19
+ end
20
+ it "includes valid paging meta data" do
21
+ page[:meta][:songs][:count].should == 18
22
+ page[:meta][:songs][:page_count].should == 2
23
+ page[:meta][:songs][:previous_page].should == nil
24
+ page[:meta][:songs][:next_page].should == 2
25
+ end
26
+ it "includes links" do
27
+ page[:links].should == {
28
+ 'songs.albums' => { :href => "/albums/{songs.album}.json", :type => :albums },
29
+ 'songs.artists' => { :href => "/artists/{songs.artist}.json", :type => :artists }
30
+ }
31
+ end
32
+ end
33
+
34
+ context "with custom page size" do
35
+ let(:params) { { page_size: '3' } }
36
+ it "returns custom page sizes" do
37
+ page[:meta][:songs][:page_size].should == 3
38
+ page[:meta][:songs][:page_count].should == 6
39
+ end
40
+ end
41
+
42
+ it "serializes results" do
43
+ first = Song.first
44
+ page[:songs].first.should == {
45
+ id: first.id.to_s,
46
+ title: first.title,
47
+ album_id: first.album_id,
48
+ links: {
49
+ album: first.album_id.to_s,
50
+ artist: first.artist_id.to_s
51
+ }
52
+ }
53
+ end
54
+
55
+ context "first page" do
56
+ let(:params) { { page: '1' } }
57
+
58
+ it "returns first page" do
59
+ page[:meta][:songs][:page].should == 1
60
+ page[:meta][:songs][:page_size].should == 10
61
+ page[:meta][:songs][:previous_page].should == nil
62
+ page[:meta][:songs][:next_page].should == 2
63
+ end
64
+ end
65
+
66
+ context "second page" do
67
+ let(:params) { { page: '2' } }
68
+
69
+ it "returns second page" do
70
+ page[:songs].length.should == 8
71
+ page[:meta][:songs][:page].should == 2
72
+ page[:meta][:songs][:previous_page].should == 1
73
+ page[:meta][:songs][:next_page].should == nil
74
+ end
75
+ end
76
+
77
+ context "when sideloading" do
78
+ let(:params) { { includes: 'albums' } }
79
+
80
+ it "includes side-loaded models" do
81
+ page[:albums].should_not == nil
82
+ end
83
+
84
+ it "includes the side-loads in the main meta data" do
85
+ page[:meta][:songs][:includes].should == [:albums]
86
+ end
87
+
88
+ context "with includes as comma delimited string" do
89
+ let(:params) { { includes: "albums,artists" } }
90
+ it "includes side-loaded models" do
91
+ page[:albums].should_not == nil
92
+ page[:artists].should_not == nil
93
+ end
94
+
95
+ it "includes links" do
96
+ page[:links]['songs.albums'].should_not == nil
97
+ page[:links]['songs.artists'].should_not == nil
98
+ page[:links]['albums.songs'].should_not == nil
99
+ page[:links]['albums.artists'].should_not == nil
100
+ page[:links]['artists.songs'].should_not == nil
101
+ page[:links]['artists.albums'].should_not == nil
102
+ end
103
+ end
104
+ end
105
+
106
+ context "when filtering" do
107
+ context "with no filters" do
108
+ let(:params) { {} }
109
+
110
+ it "returns a page of all data" do
111
+ page[:meta][:songs][:count].should == 18
112
+ end
113
+ end
114
+
115
+ context "with :album_id filter" do
116
+ let(:params) { { album_id: @album1.id.to_s } }
117
+
118
+ it "returns a page with songs from album1" do
119
+ page[:meta][:songs][:count].should == @album1.songs.length
120
+ end
121
+ end
122
+ end
123
+
124
+ context "with custom scope" do
125
+ before do
126
+ FactoryGirl.create(:album, year: 1930)
127
+ FactoryGirl.create(:album, year: 1948)
128
+ end
129
+ let(:page) { AlbumSerializer.page(params, scope) }
130
+ let(:scope) { Album.classic }
131
+
132
+ it "returns a page of scoped data" do
133
+ page[:meta][:albums][:count].should == 2
134
+ end
135
+ end
136
+ end
137
+
138
+ context "#page_with_options" do
139
+ let(:page) { SongSerializer.page_with_options(options) }
140
+ let(:params) { {} }
141
+ let(:options) { RestPack::Serializer::Options.new(Song, params) }
142
+
143
+ context "with defaults" do
144
+ it "includes valid paging meta data" do
145
+ page[:meta][:songs][:count].should == 18
146
+ page[:meta][:songs][:page_count].should == 2
147
+ page[:meta][:songs][:previous_page].should == nil
148
+ page[:meta][:songs][:next_page].should == 2
149
+ end
150
+ end
151
+
152
+ context "with custom page size" do
153
+ let(:params) { { page_size: '3' } }
154
+ it "returns custom page sizes" do
155
+ page[:meta][:songs][:page_size].should == 3
156
+ page[:meta][:songs][:page_count].should == 6
157
+ end
158
+ end
159
+ end
160
+
161
+ context "paging with paged side-load" do
162
+ let(:page) { AlbumSerializer.page_with_options(options) }
163
+ let(:options) { RestPack::Serializer::Options.new(Album, { includes: 'songs' }) }
164
+
165
+ it "includes side-loaded paging data in meta data" do
166
+ page[:meta][:albums].should_not == nil
167
+ page[:meta][:albums][:page].should == 1
168
+ page[:meta][:songs].should_not == nil
169
+ page[:meta][:songs][:page].should == 1
170
+ end
171
+ end
172
+
173
+ context "paging with two paged side-loads" do
174
+ let(:page) { ArtistSerializer.page_with_options(options) }
175
+ let(:options) { RestPack::Serializer::Options.new(Artist, { includes: 'albums,songs' }) }
176
+
177
+ it "includes side-loaded paging data in meta data" do
178
+ page[:meta][:albums].should_not == nil
179
+ page[:meta][:albums][:page].should == 1
180
+ page[:meta][:songs].should_not == nil
181
+ page[:meta][:songs][:page].should == 1
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,40 @@
1
+ require './spec/spec_helper'
2
+
3
+ describe RestPack::Serializer::Resource do
4
+ before(:each) do
5
+ @album = FactoryGirl.create(:album_with_songs, song_count: 11)
6
+ @song = @album.songs.first
7
+ end
8
+
9
+ let(:resource) { SongSerializer.resource(params) }
10
+ let(:params) { { id: @song.id.to_s } }
11
+
12
+ it "returns a resource by id" do
13
+ resource[:songs].count.should == 1
14
+ resource[:songs][0][:id].should == @song.id.to_s
15
+ end
16
+
17
+ describe "side-loading" do
18
+ let(:params) { { id: @song.id.to_s, includes: 'albums' } }
19
+
20
+ it "includes side-loaded models" do
21
+ resource[:albums].count.should == 1
22
+ resource[:albums].first[:id].should == @song.album.id.to_s
23
+ end
24
+
25
+ it "includes the side-loads in the main meta data" do
26
+ resource[:meta][:songs][:includes].should == [:albums]
27
+ end
28
+ end
29
+
30
+ describe "missing resource" do
31
+ let(:params) { { id: "-99" } }
32
+ it "returns no resource" do
33
+ resource[:songs].length.should == 0
34
+ end
35
+
36
+ #TODO: add specs for jsonapi error format when it has been standardised
37
+ # https://github.com/RestPack/restpack_serializer/issues/27
38
+ # https://github.com/json-api/json-api/issues/7
39
+ end
40
+ end