restpack_serializer 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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