├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── restpack_serializer.rb └── restpack_serializer │ ├── configuration.rb │ ├── factory.rb │ ├── options.rb │ ├── result.rb │ ├── serializable.rb │ ├── serializable │ ├── attributes.rb │ ├── filterable.rb │ ├── paging.rb │ ├── resource.rb │ ├── side_load_data_builder.rb │ ├── side_loading.rb │ ├── single.rb │ └── sortable.rb │ └── version.rb ├── performance ├── mem.rb └── perf.rb ├── restpack_serializer.gemspec └── spec ├── factory └── factory_spec.rb ├── fixtures ├── db.rb └── serializers.rb ├── restpack_serializer_spec.rb ├── result_spec.rb ├── serializable ├── attributes_spec.rb ├── filterable_spec.rb ├── options_spec.rb ├── paging_spec.rb ├── resource_spec.rb ├── serializer_spec.rb ├── side_loading │ ├── belongs_to_spec.rb │ ├── has_and_belongs_many_spec.rb │ ├── has_many_spec.rb │ └── side_loading_spec.rb ├── single_spec.rb └── sortable_spec.rb ├── spec_helper.rb └── support └── factory.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: false 11 | 12 | matrix: 13 | ruby-version: 14 | - 2.3 15 | - 2.4 16 | - 2.5 17 | - 2.6 18 | - 2.7 19 | - "3.0" 20 | - 3.1 21 | - 3.2 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: ${{ matrix.ruby-version }} 28 | bundler-cache: true 29 | - run: bundle exec rake test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.db 3 | tmp 4 | /coverage 5 | Gemfile.lock 6 | /.idea 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'coveralls', require: false 6 | gem 'memory_profiler', require: false 7 | 8 | if RUBY_VERSION < "2.2" 9 | gem "sqlite3", "~> 1.3.0" 10 | end 11 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', :cli => "-c -f doc" do 2 | watch(%r{^spec/.+_spec\.rb$}) { "spec" } 3 | watch(%r{^lib/(.+)\.rb$}) { "spec" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2014 Gavin Joyce 2 | Copyright (c) 2011-2012 José Valim & Yehuda Katz (https://github.com/rails-api/active_model_serializers/blob/master/MIT-LICENSE.txt) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # restpack_serializer 2 | [![Build Status](https://travis-ci.org/RestPack/restpack_serializer.png?branch=master)](https://travis-ci.org/RestPack/restpack_serializer) [![Code Climate](https://codeclimate.com/github/RestPack/restpack_serializer.png)](https://codeclimate.com/github/RestPack/restpack_serializer) [![Dependency Status](https://gemnasium.com/RestPack/restpack_serializer.png)](https://gemnasium.com/RestPack/restpack_serializer) [![Gem Version](https://badge.fury.io/rb/restpack_serializer.png)](http://badge.fury.io/rb/restpack_serializer) [![Coverage Status](https://coveralls.io/repos/RestPack/restpack_serializer/badge.png?branch=coveralls)](https://coveralls.io/r/RestPack/restpack_serializer?branch=coveralls) 3 | 4 | **Model serialization, paging, side-loading and filtering** 5 | 6 | restpack_serializer allows you to quickly provide a set of RESTful endpoints for your application. It is an implementation of the emerging [JSON API](http://jsonapi.org/) standard. 7 | 8 | > [Live Demo of RestPack Serializer](http://restpack-serializer-sample.herokuapp.com/) 9 | 10 | --- 11 | 12 | **NOTE: This gem needs maintainers**: https://github.com/RestPack/restpack_serializer/issues/128 13 | 14 | --- 15 | 16 | * [An overview of RestPack](http://www.slideshare.net/gavinjoyce/taming-monolithic-monsters) 17 | * [JSON API](http://jsonapi.org/) 18 | 19 | ## Getting Started 20 | 21 | ### For rails projects: 22 | After adding the gem `restpack_serializer` to your Gemfile, add this code to `config/initializers/restpack_serializer.rb`: 23 | 24 | ```ruby 25 | Dir[Rails.root.join('app/serializers/**/*.rb')].each do |path| 26 | require path 27 | end 28 | ``` 29 | 30 | ## Serialization 31 | 32 | Let's say we have an `Album` model: 33 | 34 | ```ruby 35 | class Album < ActiveRecord::Base 36 | attr_accessible :title, :year, :artist 37 | 38 | belongs_to :artist 39 | has_many :songs 40 | end 41 | ``` 42 | 43 | restpack_serializer allows us to define a corresponding serializer: 44 | 45 | ```ruby 46 | class AlbumSerializer 47 | include RestPack::Serializer 48 | attributes :id, :title, :year, :artist_id, :href 49 | end 50 | ``` 51 | 52 | `AlbumSerializer.as_json(album)` produces: 53 | 54 | ```javascript 55 | { 56 | "id": "1", 57 | "title": "Kid A", 58 | "year": 2000, 59 | "artist_id": 1, 60 | "href": "/albums/1" 61 | } 62 | ``` 63 | 64 | `as_json` accepts an optional `context` hash parameter which can be used by your Serializers to customize their output: 65 | 66 | ```ruby 67 | class AlbumSerializer 68 | include RestPack::Serializer 69 | attributes :id, :title, :year, :artist_id, :extras 70 | optional :score 71 | 72 | can_include :artists, :songs 73 | can_filter_by :year 74 | 75 | def extras 76 | if @context[:admin?] 77 | { markup_percent: 95 } 78 | end 79 | end 80 | end 81 | ``` 82 | 83 | ```ruby 84 | AlbumSerializer.as_json(album, { admin?: true }) 85 | ``` 86 | 87 | All `attributes` are serialized by default. If you'd like to skip an attribute, you can pass an option in the `@context` as follows: 88 | 89 | ```ruby 90 | AlbumSerializer.as_json(album, { include_title?: false }) 91 | ``` 92 | 93 | You can also define `optional` attributes which aren't included by default. To include: 94 | 95 | ```ruby 96 | AlbumSerializer.as_json(album, { include_score?: true }) 97 | ``` 98 | 99 | ## Exposing an API 100 | 101 | The `AlbumSerializer` provides `page` and `resource` methods which provide paged collection and singular resource GET endpoints. 102 | 103 | ```ruby 104 | class AlbumsController < ApplicationController 105 | def index 106 | render json: AlbumSerializer.page(params) 107 | end 108 | 109 | def show 110 | render json: AlbumSerializer.resource(params) 111 | end 112 | end 113 | ``` 114 | 115 | These endpoint will live at URLs such as `/albums` and `/albums/142857`: 116 | 117 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json 118 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums/4.json 119 | 120 | The `AlbumSerializer` also provides a `single` method which will return a serialized resource similar to `as_json` above. 121 | 122 | `page`, `resource` and `single` methods take an optional scope argument allowing us to enforce arbitrary constraints: 123 | 124 | ```ruby 125 | AlbumSerializer.page(params, Albums.where("year < 1950")) 126 | ``` 127 | 128 | In addition to `scope`, all three methods also accept an optional `context` hash: 129 | 130 | ```ruby 131 | AlbumSerializer.page(params, Albums.where("year < 1950"), { admin?: true }) 132 | ``` 133 | 134 | Other features: 135 | * [Custom Attributes Hash](https://github.com/RestPack/restpack_serializer/blob/master/spec/serializable/serializer_spec.rb#L55) 136 | 137 | ## Paging 138 | 139 | Collections are paged by default. `page` and `page_size` parameters are available: 140 | 141 | * http://restpack-serializer-sample.herokuapp.com/api/v1/songs.json?page=2 142 | * http://restpack-serializer-sample.herokuapp.com/api/v1/songs.json?page=2&page_size=3 143 | 144 | Paging details are included in a `meta` attribute: 145 | 146 | http://restpack-serializer-sample.herokuapp.com/api/v1/songs.json?page=2&page_size=3 yields: 147 | 148 | ```javascript 149 | { 150 | "songs": [ 151 | { 152 | "id": "4", 153 | "title": "How to Dissapear Completely", 154 | "href": "/songs/4", 155 | "links": { 156 | "artist": "1", 157 | "album": "1" 158 | } 159 | }, 160 | { 161 | "id": "5", 162 | "title": "Treefingers", 163 | "href": "/songs/5", 164 | "links": { 165 | "artist": "1", 166 | "album": "1" 167 | } 168 | }, 169 | { 170 | "id": "6", 171 | "title": "Optimistic", 172 | "href": "/songs/6", 173 | "links": { 174 | "artist": "1", 175 | "album": "1" 176 | } 177 | } 178 | ], 179 | "meta": { 180 | "songs": { 181 | "page": 2, 182 | "page_size": 3, 183 | "count": 42, 184 | "include": [], 185 | "page_count": 14, 186 | "previous_page": 1, 187 | "next_page": 3, 188 | "first_href": "/songs?page_size=3", 189 | "previous_href": "/songs?page_size=3", 190 | "next_href": "/songs?page=3&page_size=3", 191 | "last_href": "/songs?page=14&page_size=3" 192 | } 193 | }, 194 | "links": { 195 | "songs.artist": { 196 | "href": "/artists/{songs.artist}", 197 | "type": "artists" 198 | }, 199 | "songs.album": { 200 | "href": "/albums/{songs.album}", 201 | "type": "albums" 202 | } 203 | } 204 | } 205 | ``` 206 | 207 | URL Templates to related data are included in the `links` element. These can be used to construct URLs such as: 208 | 209 | * /artists/1 210 | * /albums/1 211 | 212 | ## Side-loading 213 | 214 | Side-loading allows related resources to be optionally included in a single API response. Valid side-loads can be defined in Serializers by using ```can_include``` as follows: 215 | 216 | ```ruby 217 | class AlbumSerializer 218 | include RestPack::Serializer 219 | attributes :id, :title, :year, :artist_id, :href 220 | 221 | can_include :songs, :artists 222 | end 223 | ``` 224 | 225 | In this example, we are allowing related `songs` and `artists` to be included in API responses. Side-loads can be specifed by using the `include` parameter: 226 | 227 | #### No side-loads 228 | 229 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json 230 | 231 | #### Side-load related Artists 232 | 233 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?include=artists 234 | 235 | which yields: 236 | 237 | ```javascript 238 | { 239 | "albums": [ 240 | { 241 | "id": "1", 242 | "title": "Kid A", 243 | "year": 2000, 244 | "href": "/albums/1", 245 | "links": { 246 | "artist": "1" 247 | } 248 | }, 249 | { 250 | "id": "2", 251 | "title": "Amnesiac", 252 | "year": 2001, 253 | "href": "/albums/2", 254 | "links": { 255 | "artist": "1" 256 | } 257 | }, 258 | { 259 | "id": "3", 260 | "title": "Murder Ballads", 261 | "year": 1996, 262 | "href": "/albums/3", 263 | "links": { 264 | "artist": "2" 265 | } 266 | }, 267 | { 268 | "id": "4", 269 | "title": "Curtains", 270 | "year": 2005, 271 | "href": "/albums/4", 272 | "links": { 273 | "artist": "3" 274 | } 275 | } 276 | ], 277 | "meta": { 278 | "albums": { 279 | "page": 1, 280 | "page_size": 10, 281 | "count": 4, 282 | "include": [ 283 | "artists" 284 | ], 285 | "page_count": 1, 286 | "previous_page": null, 287 | "next_page": null, 288 | "first_href": '/albums', 289 | "previous_href": null, 290 | "next_href": null, 291 | "last_href": '/albums' 292 | } 293 | }, 294 | "links": { 295 | "albums.songs": { 296 | "href": "/songs?album_id={albums.id}", 297 | "type": "songs" 298 | }, 299 | "albums.artist": { 300 | "href": "/artists/{albums.artist}", 301 | "type": "artists" 302 | }, 303 | "artists.albums": { 304 | "href": "/albums?artist_id={artists.id}", 305 | "type": "albums" 306 | }, 307 | "artists.songs": { 308 | "href": "/songs?artist_id={artists.id}", 309 | "type": "songs" 310 | } 311 | }, 312 | "linked": { 313 | "artists": [ 314 | { 315 | "id": "1", 316 | "name": "Radiohead", 317 | "website": "http://radiohead.com/", 318 | "href": "/artists/1" 319 | }, 320 | { 321 | "id": "2", 322 | "name": "Nick Cave & The Bad Seeds", 323 | "website": "http://www.nickcave.com/", 324 | "href": "/artists/2" 325 | }, 326 | { 327 | "id": "3", 328 | "name": "John Frusciante", 329 | "website": "http://johnfrusciante.com/", 330 | "href": "/artists/3" 331 | } 332 | ] 333 | } 334 | } 335 | ``` 336 | 337 | #### Side-load related Songs 338 | 339 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?include=songs 340 | 341 | An album `:has_many` songs, so the side-loaded songs are paged. The `meta.songs` includes `previous_href` and `next_href` which point to the previous and next page of this side-loaded data. These URLs take the form: 342 | 343 | * http://restpack-serializer-sample.herokuapp.com/api/v1/songs.json?album_ids=1,2,3,4&page=2 344 | 345 | #### Side-load related Artists and Songs 346 | 347 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?include=artists,songs 348 | 349 | ## Filtering 350 | 351 | Simple filtering based on primary and foreign keys is supported by default: 352 | 353 | #### By primary key: 354 | 355 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?id=1 356 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?ids=1,2,4 357 | 358 | #### By foreign key: 359 | 360 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?artist_id=1 361 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?artist_ids=2,3 362 | 363 | #### Custom filters: 364 | 365 | Custom filters can be defined with the `can_filter_by` option: 366 | 367 | ```ruby 368 | class Account 369 | include RestPack::Serializer 370 | attributes :id, :application_id, :created_by, :name, :href 371 | 372 | can_filter_by :application_id 373 | end 374 | ``` 375 | 376 | Side-loading is available when filtering: 377 | 378 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?artist_ids=2,3&include=artists,songs 379 | 380 | ## Sorting 381 | 382 | Sorting attributes can be defined with the `can_sort_by` option: 383 | 384 | ```ruby 385 | class Account 386 | include RestPack::Serializer 387 | attributes :id, :application_id, :created_by, :name, :href 388 | 389 | can_sort_by :id, :name 390 | end 391 | ``` 392 | 393 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?sort=id 394 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?sort=-name 395 | * http://restpack-serializer-sample.herokuapp.com/api/v1/albums.json?sort=name,-id 396 | 397 | ## Running Tests 398 | 399 | `bundle` 400 | `rake spec` 401 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "restpack_gem" 2 | RestPack::Gem::Tasks.load_tasks 3 | 4 | desc "Run some performance tests" 5 | task :perf do 6 | require_relative 'performance/perf.rb' 7 | require_relative 'performance/mem.rb' 8 | end 9 | -------------------------------------------------------------------------------- /lib/restpack_serializer.rb: -------------------------------------------------------------------------------- 1 | require 'kaminari' 2 | 3 | require_relative 'restpack_serializer/version' 4 | require_relative 'restpack_serializer/configuration' 5 | require_relative 'restpack_serializer/serializable' 6 | require_relative 'restpack_serializer/factory' 7 | require_relative 'restpack_serializer/result' 8 | 9 | Kaminari::Hooks.init if defined?(Kaminari::Hooks) 10 | 11 | module RestPack 12 | module Serializer 13 | mattr_accessor :config 14 | @@config = Configuration.new 15 | 16 | def self.setup 17 | yield @@config 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/restpack_serializer/configuration.rb: -------------------------------------------------------------------------------- 1 | module RestPack 2 | module Serializer 3 | class Configuration 4 | attr_accessor :href_prefix, :page_size 5 | 6 | def initialize 7 | @href_prefix = '' 8 | @page_size = 10 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/restpack_serializer/factory.rb: -------------------------------------------------------------------------------- 1 | class RestPack::Serializer::Factory 2 | def self.create(*identifiers) 3 | serializers = identifiers.map { |identifier| self.classify(identifier) } 4 | serializers.count == 1 ? serializers.first : serializers 5 | end 6 | 7 | private 8 | 9 | def self.classify(identifier) 10 | normalised_identifier = identifier.to_s.underscore 11 | [normalised_identifier, normalised_identifier.singularize].each do |format| 12 | klass = RestPack::Serializer.class_map[format] 13 | return klass.new if klass 14 | end 15 | 16 | raise "Invalid RestPack::Serializer : #{identifier}" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/restpack_serializer/options.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer 2 | class Options 3 | attr_accessor :page, :page_size, :include, :filters, :serializer, 4 | :model_class, :scope, :context, :include_links, 5 | :sorting 6 | 7 | def initialize(serializer, params = {}, scope = nil, context = {}) 8 | params.symbolize_keys! if params.respond_to?(:symbolize_keys!) 9 | 10 | @page = params[:page] ? params[:page].to_i : 1 11 | @page_size = params[:page_size] ? params[:page_size].to_i : RestPack::Serializer.config.page_size 12 | @include = params[:include] ? params[:include].split(',') : [] 13 | @filters = filters_from_params(params, serializer) 14 | @sorting = sorting_from_params(params, serializer) 15 | @serializer = serializer 16 | @model_class = serializer.model_class 17 | @scope = scope || model_class.send(:all) 18 | @context = context 19 | @include_links = true 20 | end 21 | 22 | def scope_with_filters 23 | scope_filter = {} 24 | 25 | @filters.keys.each do |filter| 26 | value = query_to_array(@filters[filter]) 27 | scope_filter[filter] = value 28 | end 29 | 30 | @scope.where(scope_filter) 31 | end 32 | 33 | def default_page_size? 34 | @page_size == RestPack::Serializer.config.page_size 35 | end 36 | 37 | def filters_as_url_params 38 | @filters.sort.map { |k,v| map_filter_ids(k,v) }.join('&') 39 | end 40 | 41 | def sorting_as_url_params 42 | sorting_values = sorting.map { |k, v| v == :asc ? k : "-#{k}" }.join(',') 43 | "sort=#{sorting_values}" 44 | end 45 | 46 | private 47 | 48 | def filters_from_params(params, serializer) 49 | filters = {} 50 | serializer.filterable_by.each do |filter| 51 | [filter, "#{filter}s".to_sym].each do |key| 52 | filters[filter] = params[key].to_s.split(',') if params[key] 53 | end 54 | end 55 | filters 56 | end 57 | 58 | def sorting_from_params(params, serializer) 59 | sort_values = params[:sort] && params[:sort].split(',') 60 | return {} if sort_values.blank? || serializer.serializable_sorting_attributes.blank? 61 | sorting_parameters = {} 62 | 63 | sort_values.each do |sort_value| 64 | sort_order = sort_value[0] == '-' ? :desc : :asc 65 | sort_value = sort_value.gsub(/\A\-/, '').downcase.to_sym 66 | sorting_parameters[sort_value] = sort_order if serializer.serializable_sorting_attributes.include?(sort_value) 67 | end 68 | sorting_parameters 69 | end 70 | 71 | def map_filter_ids(key,value) 72 | case value 73 | when Hash 74 | value.map { |k,v| map_filter_ids(k,v) } 75 | else 76 | "#{key}=#{value.join(',')}" 77 | end 78 | end 79 | 80 | def query_to_array(value) 81 | case value 82 | when String 83 | value.split(',') 84 | when Hash 85 | value.each { |k, v| value[k] = query_to_array(v) } 86 | else 87 | value 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/restpack_serializer/result.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer 2 | class Result 3 | attr_accessor :resources, :meta, :links 4 | 5 | def initialize 6 | @resources = {} 7 | @meta = {} 8 | @links = {} 9 | end 10 | 11 | def serialize 12 | result = {} 13 | 14 | unless @resources.empty? 15 | inject_has_many_links! 16 | result[@resources.keys.first] = @resources.values.first 17 | 18 | linked = @resources.except(@resources.keys.first) 19 | result[:linked] = linked unless linked.empty? 20 | end 21 | 22 | result[:links] = @links unless @links.empty? 23 | result[:meta] = @meta unless @meta.empty? 24 | 25 | result 26 | end 27 | 28 | private 29 | 30 | def inject_has_many_links! 31 | @resources.keys.each do |key| 32 | @resources[key].each do |item| 33 | if item[:links] 34 | item[:links].each do |link_key, link_value| 35 | unless link_value.is_a? Array 36 | plural_linked_key = "#{link_key}s".to_sym 37 | 38 | if @resources[plural_linked_key] 39 | linked_resource = @resources[plural_linked_key].find { |i| i[:id] == link_value } 40 | if linked_resource 41 | linked_resource[:links] ||= {} 42 | linked_resource[:links][key] ||= [] 43 | linked_resource[:links][key] << item[:id] 44 | linked_resource[:links][key].uniq! 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | require_relative "options" 3 | require_relative "serializable/attributes" 4 | require_relative "serializable/filterable" 5 | require_relative "serializable/paging" 6 | require_relative "serializable/resource" 7 | require_relative "serializable/single" 8 | require_relative "serializable/side_loading" 9 | require_relative "serializable/side_load_data_builder" 10 | require_relative "serializable/sortable" 11 | 12 | module RestPack 13 | module Serializer 14 | extend ActiveSupport::Concern 15 | mattr_accessor :class_map 16 | @@class_map ||= {} 17 | 18 | included do 19 | identifier = self.to_s.underscore.chomp('_serializer') 20 | @@class_map[identifier] = self 21 | @@class_map[identifier.split('/').last] = self 22 | end 23 | 24 | include RestPack::Serializer::Paging 25 | include RestPack::Serializer::Resource 26 | include RestPack::Serializer::Single 27 | include RestPack::Serializer::Attributes 28 | include RestPack::Serializer::Filterable 29 | include RestPack::Serializer::SideLoading 30 | include RestPack::Serializer::Sortable 31 | 32 | class InvalidInclude < Exception; end 33 | 34 | def as_json(model, context = {}) 35 | return if model.nil? 36 | if model.kind_of?(Array) 37 | return model.map { |item| self.class.new.as_json(item, context) } 38 | end 39 | 40 | apply_whitelist_and_blacklist(context) 41 | @model, @context = model, context 42 | 43 | data = {} 44 | if self.class.serializable_attributes.present? 45 | self.class.serializable_attributes.each do |key, attribute| 46 | method_name = attribute[:include_method_name] 47 | name = attribute[:name] 48 | if self.class.memoized_has_user_defined_method?(method_name) 49 | data[key] = self.send(name) if self.send(method_name) 50 | else 51 | #the default implementation of `include_abc?` 52 | if @context[method_name].nil? || @context[method_name] 53 | data[key] = self.send(name) 54 | end 55 | end 56 | end 57 | end 58 | 59 | add_custom_attributes(data) 60 | add_links(model, data) if self.class.has_associations? 61 | 62 | data 63 | end 64 | 65 | def to_json(model, context = {}) 66 | as_json(model, context).to_json 67 | end 68 | 69 | def custom_attributes 70 | nil 71 | end 72 | 73 | private 74 | 75 | def add_custom_attributes(data) 76 | custom = custom_attributes 77 | data.merge!(custom) if custom 78 | end 79 | 80 | def apply_whitelist_and_blacklist(context) 81 | blacklist = context[:attribute_blacklist] 82 | whitelist = context[:attribute_whitelist] 83 | 84 | if blacklist.present? && whitelist.present? 85 | raise ArgumentError.new "the context can't define both an `attribute_whitelist` and an `attribute_blacklist`" 86 | end 87 | 88 | if blacklist.present? 89 | blacklist = csv_to_symbol_array(blacklist) 90 | self.class.serializable_attributes.each do |key, value| 91 | if blacklist.include? key 92 | context[value[:include_method_name]] = false 93 | end 94 | end 95 | end 96 | 97 | if whitelist.present? 98 | whitelist = csv_to_symbol_array(whitelist) 99 | self.class.serializable_attributes.each do |key, value| 100 | unless whitelist.include? key 101 | context[value[:include_method_name]] = false 102 | end 103 | end 104 | end 105 | end 106 | 107 | def csv_to_symbol_array(maybe_csv) 108 | if maybe_csv.is_a? String 109 | maybe_csv.split(',').map {|a| a.strip.to_sym} 110 | else 111 | maybe_csv 112 | end 113 | end 114 | 115 | def add_links(model, data) 116 | self.class.associations.each do |association| 117 | data[:links] ||= {} 118 | links_value = case 119 | when association.macro == :belongs_to 120 | model.send(association.foreign_key).try(:to_s) 121 | when association.macro.to_s.match(/has_/) 122 | if model.send(association.name).loaded? 123 | model.send(association.name).collect { |associated| associated.id.to_s } 124 | else 125 | model.send(association.name).pluck(:id).map(&:to_s) 126 | end 127 | end 128 | unless links_value.blank? 129 | data[:links][association.name.to_sym] = links_value 130 | end 131 | end 132 | data 133 | end 134 | 135 | module ClassMethods 136 | attr_accessor :model_class, :href_prefix, :key, :user_defined_methods, :track_defined_methods 137 | 138 | def method_added(name) 139 | #we track used defined methods so that we can make quick decisions at runtime 140 | @user_defined_methods ||= [] 141 | if @track_defined_methods 142 | @user_defined_methods << name 143 | end 144 | end 145 | 146 | def has_user_defined_method?(method_name) 147 | if user_defined_methods && user_defined_methods.include?(method_name) 148 | true 149 | elsif superclass.respond_to?(:has_user_defined_method?) 150 | superclass.has_user_defined_method?(method_name) 151 | else 152 | false 153 | end 154 | end 155 | 156 | def memoized_has_user_defined_method?(method_name) 157 | @memoized_user_defined_methods ||= {} 158 | 159 | if @memoized_user_defined_methods.has_key? method_name 160 | return @memoized_user_defined_methods[method_name] 161 | else 162 | has_method = has_user_defined_method?(method_name) 163 | @memoized_user_defined_methods[method_name] = has_method 164 | return has_method 165 | end 166 | end 167 | 168 | def array_as_json(models, context = {}) 169 | new.as_json(models, context) 170 | end 171 | 172 | def as_json(model, context = {}) 173 | new.as_json(model, context) 174 | end 175 | 176 | def to_json(model, context = {}) 177 | new.as_json(model, context).to_json 178 | end 179 | 180 | def serialize(models, context = {}) 181 | models = [models] unless models.kind_of?(Array) 182 | 183 | { 184 | self.key() => models.map {|model| self.as_json(model, context)} 185 | } 186 | end 187 | 188 | def model_class 189 | @model_class || self.name.chomp('Serializer').constantize 190 | end 191 | 192 | def href_prefix 193 | @href_prefix || RestPack::Serializer.config.href_prefix 194 | end 195 | 196 | def key 197 | (@key || self.model_class.send(:table_name)).to_sym 198 | end 199 | 200 | def singular_key 201 | self.key.to_s.singularize.to_sym 202 | end 203 | 204 | def plural_key 205 | self.key 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/attributes.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::Attributes 2 | extend ActiveSupport::Concern 3 | 4 | def default_href 5 | "#{self.class.href_prefix}/#{self.class.key}/#{@model.to_param}" 6 | end 7 | 8 | module ClassMethods 9 | def serializable_attributes 10 | @serializable_attributes 11 | end 12 | 13 | def attributes(*attrs) 14 | attrs.each { |attr| attribute attr } 15 | end 16 | 17 | def optional(*attrs) 18 | attrs.each { |attr| optional_attribute attr } 19 | end 20 | 21 | def transform(attrs = [], transform_lambda) 22 | attrs.each { |attr| transform_attribute(attr, transform_lambda) } 23 | end 24 | 25 | def transform_attribute(name, transform_lambda, options = {}) 26 | add_to_serializable(name, options) 27 | 28 | self.track_defined_methods = false 29 | define_method name do 30 | transform_lambda.call(name, @model) 31 | end 32 | 33 | define_include_method name 34 | self.track_defined_methods = true 35 | end 36 | 37 | def attribute(name, options={}) 38 | add_to_serializable(name, options) 39 | define_attribute_method name 40 | define_include_method name 41 | end 42 | 43 | def optional_attribute(name, options={}) 44 | add_to_serializable(name, options) 45 | define_attribute_method name 46 | define_optional_include_method name 47 | end 48 | 49 | def define_attribute_method(name) 50 | unless method_defined?(name) 51 | self.track_defined_methods = false 52 | define_method name do 53 | value = self.default_href if name == :href 54 | if @model.is_a?(Hash) 55 | value = @model[name] 56 | value = @model[name.to_s] if value.nil? 57 | else 58 | value ||= @model.send(name) 59 | end 60 | value = value.to_s if name == :id 61 | value 62 | end 63 | self.track_defined_methods = true 64 | end 65 | end 66 | 67 | def define_optional_include_method(name) 68 | define_include_method(name, false) 69 | end 70 | 71 | def define_include_method(name, include_by_default=true) 72 | method = "include_#{name}?".to_sym 73 | 74 | unless method_defined?(method) 75 | unless include_by_default 76 | define_method method do 77 | @context[method].present? 78 | end 79 | end 80 | end 81 | end 82 | 83 | def add_to_serializable(name, options = {}) 84 | options[:key] ||= name.to_sym 85 | 86 | @serializable_attributes ||= {} 87 | @serializable_attributes[options[:key]] = { 88 | name: name, 89 | include_method_name: "include_#{options[:key]}?".to_sym 90 | } 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/filterable.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::Filterable 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def serializable_filters 6 | @serializable_filters 7 | end 8 | 9 | def can_filter_by(*attributes) 10 | attributes.each do |attribute| 11 | @serializable_filters ||= [] 12 | @serializable_filters << attribute.to_sym 13 | end 14 | end 15 | 16 | def filterable_by 17 | filters = [self.model_class.primary_key.to_sym] 18 | filters += self.model_class.reflect_on_all_associations(:belongs_to).map(&:foreign_key).map(&:to_sym) 19 | 20 | filters += @serializable_filters if @serializable_filters 21 | filters.uniq 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/paging.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::Paging 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def page(params = {}, scope = nil, context = {}) 6 | page_with_options RestPack::Serializer::Options.new(self, params, scope, context) 7 | end 8 | 9 | def page_with_options(options) 10 | page = options.scope_with_filters.page(options.page).per(options.page_size) 11 | page = page.reorder(options.sorting) if options.sorting.any? 12 | 13 | result = RestPack::Serializer::Result.new 14 | result.resources[self.key] = serialize_page(page, options) 15 | result.meta[self.key] = serialize_meta(page, options) 16 | 17 | if options.include_links 18 | result.links = self.links 19 | Array(RestPack::Serializer::Factory.create(*options.include)).each do |serializer| 20 | result.links.merge! serializer.class.links 21 | end 22 | end 23 | 24 | side_load_data = side_loads(page, options) 25 | result.meta.merge!(side_load_data[:meta] || {}) 26 | result.resources.merge! side_load_data.except(:meta) 27 | result.serialize 28 | end 29 | 30 | private 31 | 32 | def serialize_page(page, options) 33 | page.map { |model| self.as_json(model, options.context) } 34 | end 35 | 36 | def serialize_meta(page, options) 37 | meta = { 38 | page: page.current_page, 39 | page_size: page.limit_value, 40 | count: page.total_count, 41 | include: options.include, 42 | page_count: page.total_pages, 43 | previous_page: page.prev_page, 44 | next_page: page.next_page 45 | } 46 | 47 | meta[:first_href] = page_href(1, options) 48 | meta[:previous_href] = page_href(meta[:previous_page], options) 49 | meta[:next_href] = page_href(meta[:next_page], options) 50 | meta[:last_href] = page_href(meta[:page_count], options) 51 | meta 52 | end 53 | 54 | def page_href(page, options) 55 | return nil unless page 56 | 57 | url = "#{self.href_prefix}/#{self.key}" 58 | 59 | params = [] 60 | params << "page=#{page}" unless page == 1 61 | params << "page_size=#{options.page_size}" unless options.default_page_size? 62 | params << "include=#{options.include.join(',')}" if options.include.any? 63 | params << options.sorting_as_url_params if options.sorting.any? 64 | params << options.filters_as_url_params if options.filters.any? 65 | 66 | url += '?' + params.join('&') if params.any? 67 | url 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/resource.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::Resource 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def resource(params = {}, scope = nil, context = {}) 6 | page_with_options RestPack::Serializer::Options.new(self, params, scope, context) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/side_load_data_builder.rb: -------------------------------------------------------------------------------- 1 | module RestPack 2 | module Serializer 3 | class SideLoadDataBuilder 4 | 5 | def initialize(association, models, serializer) 6 | @association = association 7 | @models = models 8 | @serializer = serializer 9 | end 10 | 11 | def side_load_belongs_to 12 | foreign_keys = @models.map { |model| model.send(@association.foreign_key) }.uniq.compact 13 | side_load = foreign_keys.any? ? @association.klass.find(foreign_keys) : [] 14 | json_model_data = side_load.map { |model| @serializer.as_json(model) } 15 | { @association.plural_name.to_sym => json_model_data, meta: { } } 16 | end 17 | 18 | def side_load_has_many 19 | has_association_relation do |options| 20 | if join_table = @association.options[:through] 21 | options.scope = options.scope.joins(join_table).distinct 22 | association_fk = @association.through_reflection.foreign_key.to_sym 23 | options.filters = { join_table => { association_fk => model_ids } } 24 | else 25 | options.filters = { @association.foreign_key.to_sym => model_ids } 26 | end 27 | end 28 | end 29 | 30 | def side_load_has_and_belongs_to_many 31 | has_association_relation do |options| 32 | join_table_name = @association.join_table 33 | join_clause = "join #{join_table_name} on #{@association.plural_name}.id = #{join_table_name}.#{@association.class_name.foreign_key}" 34 | options.scope = options.scope.joins(join_clause) 35 | association_fk = @association.foreign_key.to_sym 36 | options.filters = { join_table_name.to_sym => { association_fk => model_ids } } 37 | end 38 | end 39 | 40 | private 41 | 42 | def model_ids 43 | @models.map(&:id) 44 | end 45 | 46 | def has_association_relation 47 | return {} if @models.empty? 48 | serializer_class = @serializer.class 49 | options = RestPack::Serializer::Options.new(serializer_class) 50 | yield options 51 | options.include_links = false 52 | serializer_class.page_with_options(options) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/side_loading.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::SideLoading 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def side_loads(models, options) 6 | { meta: { } }.tap do |side_loads| 7 | return side_loads if models.empty? || options.include.nil? 8 | 9 | options.include.each do |include| 10 | side_load_data = side_load(include, models, options) 11 | side_loads[:meta].merge!(side_load_data[:meta] || {}) 12 | side_loads.merge! side_load_data.except(:meta) 13 | end 14 | end 15 | end 16 | 17 | def can_includes 18 | @can_includes || [] 19 | end 20 | 21 | def can_include(*includes) 22 | @can_includes ||= [] 23 | @can_includes += includes 24 | end 25 | 26 | def links 27 | {}.tap do |links| 28 | associations.each do |association| 29 | if association.macro == :belongs_to 30 | link_key = "#{self.key}.#{association.name}" 31 | href = "/#{association.plural_name}/{#{link_key}}" 32 | elsif association.macro.to_s.match(/has_/) 33 | singular_key = self.key.to_s.singularize 34 | link_key = "#{self.key}.#{association.plural_name}" 35 | href = "/#{association.plural_name}?#{singular_key}_id={#{key}.id}" 36 | end 37 | 38 | links.merge!(link_key => { 39 | :href => href_prefix + href, 40 | :type => association.plural_name.to_sym 41 | } 42 | ) 43 | end 44 | end 45 | end 46 | 47 | def has_associations? 48 | @can_includes 49 | end 50 | 51 | def associations 52 | return [] unless has_associations? 53 | can_includes.map do |include| 54 | association = association_from_include(include) 55 | association if supported_association?(association.macro) 56 | end.compact 57 | end 58 | 59 | private 60 | 61 | def side_load(include, models, options) 62 | association = association_from_include(include) 63 | return {} unless supported_association?(association.macro) 64 | serializer = RestPack::Serializer::Factory.create(association.class_name) 65 | builder = RestPack::Serializer::SideLoadDataBuilder.new(association, 66 | models, 67 | serializer) 68 | builder.send("side_load_#{association.macro}") 69 | end 70 | 71 | def supported_association?(association_macro) 72 | [:belongs_to, :has_many, :has_and_belongs_to_many].include?(association_macro) 73 | end 74 | 75 | def association_from_include(include) 76 | raise_invalid_include(include) unless can_include?(include) 77 | possible_relations = [include.to_s.singularize.to_sym, include] 78 | select_association_from_possibles(possible_relations) 79 | end 80 | 81 | def select_association_from_possibles(possible_relations) 82 | possible_relations.each do |relation| 83 | if association = self.model_class.reflect_on_association(relation) 84 | return association 85 | end 86 | end 87 | raise_invalid_include(include) 88 | end 89 | 90 | def can_include?(include) 91 | !!self.can_includes.index do |can_include| 92 | can_include == include || can_include.to_s == include 93 | end 94 | end 95 | 96 | def raise_invalid_include(include) 97 | raise RestPack::Serializer::InvalidInclude.new, 98 | ":#{include} is not a valid include for #{self.model_class}" 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/single.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::Single 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def single(params = {}, scope = nil, context = {}) 6 | options = RestPack::Serializer::Options.new(self, params, scope, context) 7 | model = options.scope_with_filters.first 8 | 9 | return model ? self.as_json(model, context) : nil 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/restpack_serializer/serializable/sortable.rb: -------------------------------------------------------------------------------- 1 | module RestPack::Serializer::Sortable 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | attr_reader :serializable_sorting_attributes 6 | 7 | def can_sort_by(*attributes) 8 | @serializable_sorting_attributes = [] 9 | attributes.each do |attribute| 10 | @serializable_sorting_attributes << attribute.to_sym 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/restpack_serializer/version.rb: -------------------------------------------------------------------------------- 1 | module RestPack 2 | module Serializer 3 | VERSION = '0.6.15' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /performance/mem.rb: -------------------------------------------------------------------------------- 1 | require 'memory_profiler' 2 | require_relative '../lib/restpack_serializer' 3 | 4 | class SimpleSerializer 5 | include RestPack::Serializer 6 | attributes :id, :title 7 | end 8 | 9 | simple_model = { 10 | id: "123", 11 | title: 'This is the title', 12 | } 13 | 14 | # warmup 15 | SimpleSerializer.as_json(simple_model) 16 | 17 | report = MemoryProfiler.report do 18 | SimpleSerializer.as_json(simple_model) 19 | end 20 | 21 | puts "="*64 22 | puts "Simple Serializer:" 23 | puts "="*64 24 | 25 | report.pretty_print(detailed_report: false) 26 | 27 | class ComplexSerializer 28 | include RestPack::Serializer 29 | 30 | attributes :a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o, :p, :q, :r, :s, :t 31 | end 32 | 33 | complex_model = { 34 | a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 10, 35 | k: 11, l: 12, m: 13, n: 14, o: 15, p: 16, q: 17, r: 18, s: 19, t: 20, 36 | } 37 | 38 | # warmup 39 | ComplexSerializer.as_json(complex_model) 40 | 41 | report = MemoryProfiler.report do 42 | ComplexSerializer.as_json(complex_model) 43 | end 44 | 45 | puts "="*64 46 | puts "Complex Serializer:" 47 | puts "="*64 48 | 49 | report.pretty_print(detailed_report: false) 50 | -------------------------------------------------------------------------------- /performance/perf.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require_relative '../lib/restpack_serializer' 3 | 4 | class SimpleSerializer 5 | include RestPack::Serializer 6 | attributes :id, :title 7 | end 8 | 9 | class ComplexSerializer 10 | include RestPack::Serializer 11 | 12 | attributes :a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o, :p, :q, :r, :s, :t 13 | end 14 | 15 | iterations = 180_000 16 | 17 | Benchmark.bm(22) do |bm| 18 | bm.report('simple serializer') do 19 | 20 | model = { 21 | id: 123, 22 | title: 'This is the title' 23 | } 24 | 25 | iterations.times do 26 | SimpleSerializer.as_json(model) 27 | end 28 | end 29 | 30 | bm.report('complex serializer') do 31 | 32 | model = { 33 | a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 10, 34 | k: 11, l: 12, m: 13, n: 14, o: 15, p: 16, q: 17, r: 18, s: 19, t: 20, 35 | } 36 | 37 | iterations.times do 38 | ComplexSerializer.as_json(model) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /restpack_serializer.gemspec: -------------------------------------------------------------------------------- 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 'activesupport', ['>= 4.0.3', '< 7.2'] 21 | gem.add_dependency 'activerecord', ['>= 4.0.3', '< 7.2'] 22 | gem.add_dependency 'kaminari', ['>= 0.17.0', '< 2.0'] 23 | 24 | gem.add_development_dependency 'restpack_gem', '~> 0.0.9' 25 | gem.add_development_dependency 'rake', '~> 13' 26 | gem.add_development_dependency 'guard-rspec', '~> 4.7' 27 | gem.add_development_dependency 'factory_girl', '~> 4.7' 28 | gem.add_development_dependency 'sqlite3', '~> 1.3' 29 | gem.add_development_dependency 'database_cleaner' 30 | gem.add_development_dependency 'rspec' 31 | gem.add_development_dependency 'bump' 32 | end 33 | -------------------------------------------------------------------------------- /spec/factory/factory_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Factory do 4 | let(:factory) { RestPack::Serializer::Factory } 5 | 6 | describe "single-word" do 7 | it "creates by string" do 8 | expect(factory.create("Song")).to be_an_instance_of(MyApp::SongSerializer) 9 | end 10 | 11 | it "creates by lowercase string" do 12 | expect(factory.create("song")).to be_an_instance_of(MyApp::SongSerializer) 13 | end 14 | 15 | it "creates by lowercase plural string" do 16 | expect(factory.create("songs")).to be_an_instance_of(MyApp::SongSerializer) 17 | end 18 | 19 | it "creates by symbol" do 20 | expect(factory.create(:song)).to be_an_instance_of(MyApp::SongSerializer) 21 | end 22 | 23 | it "creates by class" do 24 | expect(factory.create(MyApp::Song)).to be_an_instance_of(MyApp::SongSerializer) 25 | end 26 | 27 | it "creates multiple with Array" do 28 | serializers = factory.create("Song", "artists", :album) 29 | expect(serializers[0]).to be_an_instance_of(MyApp::SongSerializer) 30 | expect(serializers[1]).to be_an_instance_of(MyApp::ArtistSerializer) 31 | expect(serializers[2]).to be_an_instance_of(MyApp::AlbumSerializer) 32 | end 33 | end 34 | 35 | describe "multi-word" do 36 | it "creates multi-word string" do 37 | expect(factory.create("AlbumReview")).to be_an_instance_of(MyApp::AlbumReviewSerializer) 38 | end 39 | 40 | it "creates multi-word lowercase string" do 41 | expect(factory.create("album_review")).to be_an_instance_of(MyApp::AlbumReviewSerializer) 42 | end 43 | 44 | it "creates multi-word lowercase plural string" do 45 | expect(factory.create("album_reviews")).to be_an_instance_of(MyApp::AlbumReviewSerializer) 46 | end 47 | 48 | it "creates multi-word symbol" do 49 | expect(factory.create(:album_review)).to be_an_instance_of(MyApp::AlbumReviewSerializer) 50 | end 51 | 52 | it "creates multi-word class" do 53 | expect(factory.create(MyApp::AlbumReview)).to be_an_instance_of(MyApp::AlbumReviewSerializer) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/fixtures/db.rb: -------------------------------------------------------------------------------- 1 | require 'sqlite3' 2 | require 'active_record' 3 | 4 | ActiveRecord::Base.establish_connection( 5 | :adapter => 'sqlite3', 6 | :database => 'test.db' 7 | ) 8 | 9 | ActiveRecord::Migration.verbose = false 10 | 11 | ActiveRecord::Schema.define(:version => 1) do 12 | create_table "artists", :force => true do |t| 13 | t.string "name" 14 | t.string "website" 15 | t.datetime "created_at" 16 | t.datetime "updated_at" 17 | end 18 | 19 | create_table "albums", :force => true do |t| 20 | t.string "title" 21 | t.integer "year" 22 | t.integer "artist_id" 23 | t.datetime "created_at" 24 | t.datetime "updated_at" 25 | end 26 | 27 | create_table "album_reviews", :force => true do |t| 28 | t.string "message" 29 | t.integer "album_id" 30 | t.datetime "created_at" 31 | t.datetime "updated_at" 32 | end 33 | 34 | create_table "songs", :force => true do |t| 35 | t.string "title" 36 | t.integer "album_id" 37 | t.integer "artist_id" 38 | t.datetime "created_at" 39 | t.datetime "updated_at" 40 | end 41 | 42 | create_table "payments", :force => true do |t| 43 | t.integer "amount" 44 | t.integer "artist_id" 45 | t.integer "fan_id" 46 | t.datetime "created_at" 47 | t.datetime "updated_at" 48 | end 49 | 50 | create_table "fans", :force => true do |t| 51 | t.string "name" 52 | t.datetime "created_at" 53 | t.datetime "updated_at" 54 | end 55 | 56 | create_table "stalkers", :force => true do |t| 57 | t.string "name" 58 | t.datetime "created_at" 59 | t.datetime "updated_at" 60 | end 61 | 62 | create_table "artists_stalkers", force: true, id: false do |t| 63 | t.integer :artist_id 64 | t.integer :stalker_id 65 | end 66 | end 67 | 68 | module MyApp 69 | class Artist < ActiveRecord::Base 70 | has_many :albums 71 | has_many :songs 72 | has_many :payments 73 | has_many :fans, :through => :payments 74 | has_and_belongs_to_many :stalkers 75 | end 76 | 77 | class Album < ActiveRecord::Base 78 | scope :classic, -> { where("year < 1950") } 79 | 80 | belongs_to :artist 81 | has_many :songs 82 | has_many :album_reviews 83 | end 84 | 85 | class AlbumReview < ActiveRecord::Base 86 | belongs_to :album 87 | end 88 | 89 | class Song < ActiveRecord::Base 90 | default_scope -> { order(id: :asc) } 91 | 92 | belongs_to :artist 93 | belongs_to :album 94 | end 95 | 96 | class Payment < ActiveRecord::Base 97 | belongs_to :artist 98 | belongs_to :fan 99 | end 100 | 101 | class Fan < ActiveRecord::Base 102 | has_many :payments 103 | has_many :artists, :through => :albums 104 | end 105 | 106 | class Stalker < ActiveRecord::Base 107 | has_and_belongs_to_many :artists 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/fixtures/serializers.rb: -------------------------------------------------------------------------------- 1 | module MyApp 2 | class SongSerializer 3 | include RestPack::Serializer 4 | attributes :id, :title, :album_id 5 | can_include :albums, :artists 6 | can_filter_by :title 7 | can_sort_by :id, :title 8 | 9 | def title 10 | @context[:reverse_title?] ? @model.title.reverse : @model.title 11 | end 12 | end 13 | 14 | class AlbumSerializer 15 | include RestPack::Serializer 16 | attributes :id, :title, :year, :artist_id 17 | can_include :artists, :songs 18 | can_filter_by :year 19 | end 20 | 21 | class AlbumReviewSerializer 22 | include RestPack::Serializer 23 | attributes :message 24 | can_filter_by :album 25 | end 26 | 27 | class ArtistSerializer 28 | include RestPack::Serializer 29 | attributes :id, :name, :website 30 | can_include :albums, :songs, :fans, :stalkers 31 | end 32 | 33 | class FanSerializer 34 | include RestPack::Serializer 35 | attributes :id, :name 36 | end 37 | 38 | class StalkerSerializer 39 | include RestPack::Serializer 40 | attributes :id, :name 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/restpack_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer do 4 | before { @original_config = subject.config.clone } 5 | after { subject.config = @original_config } 6 | 7 | context "#setup" do 8 | it "has defaults" do 9 | expect(subject.config.href_prefix).to eq('') 10 | expect(subject.config.page_size).to eq(10) 11 | end 12 | 13 | it "can be configured" do 14 | subject.setup do |config| 15 | config.href_prefix = '/api/v1' 16 | config.page_size = 50 17 | end 18 | 19 | expect(subject.config.href_prefix).to eq('/api/v1') 20 | expect(subject.config.page_size).to eq(50) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/result_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Result do 4 | context 'a new instance' do 5 | it 'has defaults' do 6 | expect(subject.resources).to eq({}) 7 | expect(subject.meta).to eq({}) 8 | expect(subject.links).to eq({}) 9 | end 10 | end 11 | 12 | context 'when serializing' do 13 | let(:result) { subject.serialize } 14 | 15 | context 'in jsonapi.org format' do 16 | context 'an empty result' do 17 | it 'returns an empty result' do 18 | expect(result).to eq({}) 19 | end 20 | end 21 | 22 | context 'a simple list of resources' do 23 | before do 24 | subject.resources[:albums] = [{ name: 'Album 1' }, { name: 'Album 2'}] 25 | subject.meta[:albums] = { count: 2 } 26 | subject.links['albums.songs'] = { href: 'songs.json', type: 'songs' } 27 | end 28 | 29 | it 'returns correct jsonapi.org format' do 30 | expect(result[:albums]).to eq(subject.resources[:albums]) 31 | expect(result[:meta]).to eq(subject.meta) 32 | expect(result[:links]).to eq(subject.links) 33 | end 34 | end 35 | 36 | context 'a list with side-loaded resources' do 37 | before do 38 | subject.resources[:albums] = [{ id: '1', name: 'AMOK'}] 39 | subject.resources[:songs] = [{ id: '91', name: 'Before Your Very Eyes...', links: { album: '1' }}] 40 | subject.meta[:albums] = { count: 1 } 41 | subject.meta[:songs] = { count: 1 } 42 | subject.links['albums.songs'] = { type: 'songs', href: '/api/v1/songs?album_id={albums.id}' } 43 | subject.links['songs.album'] = { type: 'albums', href: '/api/v1/albums/{songs.album}' } 44 | end 45 | 46 | it 'returns correct jsonapi.org format, including injected has_many links' do 47 | expect(result[:albums]).to eq([{ id: '1', name: 'AMOK', links: { songs: ['91'] } }]) 48 | expect(result[:links]).to eq(subject.links) 49 | expect(result[:linked][:songs]).to eq(subject.resources[:songs]) 50 | end 51 | 52 | it 'includes resources in correct order' do 53 | expect(result.keys[0]).to eq(:albums) 54 | expect(result.keys[1]).to eq(:linked) 55 | expect(result.keys[2]).to eq(:links) 56 | expect(result.keys[3]).to eq(:meta) 57 | end 58 | 59 | context 'with multiple calls to serialize' do 60 | let(:result) do 61 | subject.serialize 62 | subject.serialize 63 | end 64 | 65 | it 'does not create duplicate has_many links' do 66 | expect(result[:albums].first[:links][:songs].count).to eq(1) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/serializable/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Attributes do 4 | class CustomSerializer 5 | include RestPack::Serializer 6 | attributes :a, :b, :c 7 | attributes :d, :e, :f? 8 | optional :sometimes, :maybe 9 | attribute :old_attribute, :key => :new_key 10 | transform [:gonzaga], lambda { |name, model| model.send(name).downcase } 11 | end 12 | 13 | subject(:attributes) { CustomSerializer.serializable_attributes } 14 | 15 | it "correctly models specified attributes" do 16 | expect(attributes.length).to be(10) 17 | end 18 | 19 | it "correctly maps normal attributes" do 20 | [:a, :b, :c, :d, :e, :f?].each do |attr| 21 | expect(attributes[attr]).to eq({ 22 | name: attr, 23 | include_method_name: "include_#{attr}?".to_sym 24 | }) 25 | end 26 | end 27 | 28 | it "correctly maps attribute with :key options" do 29 | expect(attributes[:new_key]).to eq({ 30 | name: :old_attribute, 31 | include_method_name: :include_new_key? 32 | }) 33 | end 34 | 35 | describe "optional attributes" do 36 | let(:model) { OpenStruct.new(a: 'A', sometimes: 'SOMETIMES', gonzaga: 'GONZAGA') } 37 | let(:context) { {} } 38 | subject(:as_json) { CustomSerializer.as_json(model, context) } 39 | 40 | context 'with no includes context' do 41 | it "excludes by default" do 42 | expect(as_json[:sometimes]).to eq(nil) 43 | end 44 | end 45 | 46 | context 'with an includes context' do 47 | let(:context) { { include_sometimes?: true } } 48 | 49 | it "allows then to be included" do 50 | expect(as_json[:sometimes]).to eq('SOMETIMES') 51 | end 52 | end 53 | end 54 | 55 | describe '#transform_attributes' do 56 | let(:model) { OpenStruct.new(gonzaga: 'IS A SCHOOL') } 57 | 58 | subject(:as_json) { CustomSerializer.as_json(model) } 59 | 60 | it 'uses the transform method on the model attribute' do 61 | expect(as_json[:gonzaga]).to eq('is a school') 62 | end 63 | end 64 | 65 | describe "model as a hash" do 66 | let(:model) { { a: 'A', 'b' => 'B', c: false, :f? => 2 } } 67 | 68 | subject(:as_json) { CustomSerializer.as_json(model, include_gonzaga?: false) } 69 | 70 | it 'uses the transform method on the model attribute' do 71 | expect(as_json[:a]).to eq('A') 72 | expect(as_json[:b]).to eq('B') 73 | expect(as_json[:c]).to eq(false) 74 | expect(as_json[:f?]).to eq(2) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/serializable/filterable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Filterable do 4 | class CustomSerializer 5 | include RestPack::Serializer 6 | attributes :a, :b, :c 7 | 8 | can_filter_by :a, :c 9 | end 10 | 11 | it "captures the specified filters" do 12 | expect(CustomSerializer.serializable_filters).to eq([:a, :c]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/serializable/options_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Options do 4 | let(:subject) { RestPack::Serializer::Options.new(MyApp::SongSerializer, params, scope) } 5 | let(:params) { {} } 6 | let(:scope) { nil } 7 | 8 | describe 'default values' do 9 | it { expect(subject.model_class).to eq(MyApp::Song) } 10 | it { expect(subject.include).to eq([]) } 11 | it { expect(subject.page).to eq(1) } 12 | it { expect(subject.page_size).to eq(10) } 13 | it { expect(subject.filters).to eq({}) } 14 | it { expect(subject.scope).to eq(MyApp::Song.all) } 15 | it { expect(subject.default_page_size?).to eq(true) } 16 | it { expect(subject.filters_as_url_params).to eq('') } 17 | end 18 | 19 | describe 'with paging params' do 20 | let(:params) { { 'page' => '2', 'page_size' => '8' } } 21 | it { expect(subject.page).to eq(2) } 22 | it { expect(subject.page_size).to eq(8) } 23 | end 24 | 25 | describe 'with include' do 26 | let(:params) { { 'include' => 'model1,model2' } } 27 | it { expect(subject.include).to eq(%w(model1 model2)) } 28 | end 29 | 30 | context 'with filters' do 31 | describe 'with no filter params' do 32 | let(:params) { {} } 33 | it { expect(subject.filters).to eq({}) } 34 | end 35 | 36 | describe 'with a primary key with a single value' do 37 | let(:params) { { 'id' => '142857' } } 38 | it { expect(subject.filters).to eq(id: %w(142857)) } 39 | it { expect(subject.filters_as_url_params).to eq('id=142857') } 40 | end 41 | 42 | describe 'with a primary key with multiple values' do 43 | let(:params) { { 'ids' => '42,142857' } } 44 | it { expect(subject.filters).to eq(id: %w(42 142857)) } 45 | it { expect(subject.filters_as_url_params).to eq('id=42,142857') } 46 | end 47 | 48 | describe 'with a foreign key with a single value' do 49 | let(:params) { { 'album_id' => '789' } } 50 | it { expect(subject.filters).to eq(album_id: %w(789)) } 51 | it { expect(subject.filters_as_url_params).to eq('album_id=789') } 52 | end 53 | 54 | describe 'with a foreign key with multiple values' do 55 | let(:params) { { 'album_id' => '789,678,567' } } 56 | it { expect(subject.filters).to eq(album_id: %w(789 678 567)) } 57 | it { expect(subject.filters_as_url_params).to eq('album_id=789,678,567') } 58 | end 59 | 60 | describe 'with multiple foreign keys' do 61 | let(:params) { { 'album_id' => '111,222', 'artist_id' => '888,999' } } 62 | it { expect(subject.filters).to eq(album_id: %w(111 222), artist_id: %w(888 999)) } 63 | it { expect(subject.filters_as_url_params).to eq('album_id=111,222&artist_id=888,999') } 64 | end 65 | end 66 | 67 | context 'with sorting parameters' do 68 | describe 'with no params' do 69 | let(:params) { {} } 70 | it { expect(subject.sorting).to eq({}) } 71 | end 72 | describe 'with a sorting value' do 73 | let(:params) { { 'sort' => 'Title' } } 74 | it { expect(subject.sorting).to eq(title: :asc) } 75 | it { expect(subject.sorting_as_url_params).to eq('sort=title') } 76 | end 77 | describe 'with a descending sorting value' do 78 | let(:params) { { 'sort' => '-title' } } 79 | it { expect(subject.sorting).to eq(title: :desc) } 80 | it { expect(subject.sorting_as_url_params).to eq('sort=-title') } 81 | end 82 | describe 'with multiple sorting values' do 83 | let(:params) { { 'sort' => '-Title,ID' } } 84 | it { expect(subject.sorting).to eq(title: :desc, id: :asc) } 85 | it { expect(subject.sorting_as_url_params).to eq('sort=-title,id') } 86 | end 87 | describe 'with a not allowed sorting value' do 88 | let(:params) { { 'sort' => '-title,album_id,id' } } 89 | it { expect(subject.sorting).to eq(title: :desc, id: :asc) } 90 | it { expect(subject.sorting_as_url_params).to eq('sort=-title,id') } 91 | end 92 | end 93 | 94 | context 'scopes' do 95 | describe 'with default scope' do 96 | it { expect(subject.scope).to eq(MyApp::Song.all) } 97 | end 98 | 99 | describe 'with custom scope' do 100 | let(:scope) { MyApp::Song.where("id >= 100") } 101 | it { expect(subject.scope).to eq(scope) } 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/serializable/paging_spec.rb: -------------------------------------------------------------------------------- 1 | require '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) { MyApp::SongSerializer.page(params, scope, context) } 11 | let(:params) { {} } 12 | let(:scope) { nil } 13 | let(:context) { {} } 14 | 15 | context "with defaults" do 16 | it "page defaults to 1" do 17 | expect(page[:meta][:songs][:page]).to eq(1) 18 | end 19 | 20 | it "page_size defaults to 10" do 21 | expect(page[:meta][:songs][:page_size]).to eq(10) 22 | end 23 | 24 | it "includes valid paging meta data" do 25 | expect(page[:meta][:songs][:count]).to eq(18) 26 | expect(page[:meta][:songs][:page_count]).to eq(2) 27 | expect(page[:meta][:songs][:first_href]).to eq('/songs') 28 | expect(page[:meta][:songs][:previous_page]).to eq(nil) 29 | expect(page[:meta][:songs][:previous_href]).to eq(nil) 30 | expect(page[:meta][:songs][:next_page]).to eq(2) 31 | expect(page[:meta][:songs][:next_href]).to eq('/songs?page=2') 32 | expect(page[:meta][:songs][:last_href]).to eq('/songs?page=2') 33 | end 34 | 35 | it "includes links" do 36 | expect(page[:links]).to eq( 37 | 'songs.album' => { href: "/albums/{songs.album}", type: :albums }, 38 | 'songs.artist' => { href: "/artists/{songs.artist}", type: :artists } 39 | ) 40 | end 41 | end 42 | 43 | context 'when href prefix is set' do 44 | before do 45 | @original_prefix = MyApp::SongSerializer.href_prefix 46 | MyApp::SongSerializer.href_prefix = '/api/v3' 47 | end 48 | after { MyApp::SongSerializer.href_prefix = @original_prefix } 49 | 50 | let(:page) { MyApp::SongSerializer.page(params, scope, context) } 51 | 52 | it 'should use prefixed links' do 53 | expect(page[:meta][:songs][:next_href]).to eq('/api/v3/songs?page=2') 54 | end 55 | end 56 | 57 | context "with custom page size" do 58 | let(:params) { { page_size: '3' } } 59 | 60 | it "returns custom page sizes" do 61 | expect(page[:meta][:songs][:page_size]).to eq(3) 62 | expect(page[:meta][:songs][:page_count]).to eq(6) 63 | end 64 | 65 | it "includes the custom page size in the page hrefs" do 66 | expect(page[:meta][:songs][:next_page]).to eq(2) 67 | expect(page[:meta][:songs][:next_href]).to eq('/songs?page=2&page_size=3') 68 | expect(page[:meta][:songs][:last_href]).to eq('/songs?page=6&page_size=3') 69 | end 70 | end 71 | 72 | context "with custom filter" do 73 | context "valid :title" do 74 | let(:params) { { title: @album1.songs[0].title } } 75 | 76 | it "returns the album" do 77 | expect(page[:meta][:songs][:count]).to eq(1) 78 | end 79 | end 80 | 81 | context "invalid :title" do 82 | let(:params) { { title: "this doesn't exist" } } 83 | 84 | it "returns the album" do 85 | expect(page[:meta][:songs][:count]).to eq(0) 86 | end 87 | end 88 | end 89 | 90 | context "with context" do 91 | let(:context) { { reverse_title?: true } } 92 | 93 | it "returns reversed titles" do 94 | first = MyApp::Song.first 95 | expect(page[:songs].first[:title]).to eq(first.title.reverse) 96 | end 97 | end 98 | 99 | it "serializes results" do 100 | first = MyApp::Song.first 101 | expect(page[:songs].first).to eq( 102 | id: first.id.to_s, 103 | title: first.title, 104 | album_id: first.album_id, 105 | links: { 106 | album: first.album_id.to_s, 107 | artist: first.artist_id.to_s 108 | } 109 | ) 110 | end 111 | 112 | context "first page" do 113 | let(:params) { { page: '1' } } 114 | 115 | it "returns first page" do 116 | expect(page[:meta][:songs][:page]).to eq(1) 117 | expect(page[:meta][:songs][:page_size]).to eq(10) 118 | expect(page[:meta][:songs][:previous_page]).to eq(nil) 119 | expect(page[:meta][:songs][:next_page]).to eq(2) 120 | end 121 | end 122 | 123 | context "second page" do 124 | let(:params) { { page: '2' } } 125 | 126 | it "returns second page" do 127 | expect(page[:songs].length).to eq(8) 128 | expect(page[:meta][:songs][:page]).to eq(2) 129 | expect(page[:meta][:songs][:previous_page]).to eq(1) 130 | expect(page[:meta][:songs][:next_page]).to eq(nil) 131 | expect(page[:meta][:songs][:previous_href]).to eq('/songs') 132 | end 133 | end 134 | 135 | context "when sideloading" do 136 | let(:params) { { include: 'albums' } } 137 | 138 | it "includes side-loaded models" do 139 | expect(page[:linked][:albums]).not_to eq(nil) 140 | end 141 | 142 | it "includes the side-loads in the main meta data" do 143 | expect(page[:meta][:songs][:include]).to eq(%w(albums)) 144 | end 145 | 146 | it "includes the side-loads in page hrefs" do 147 | expect(page[:meta][:songs][:next_href]).to eq('/songs?page=2&include=albums') 148 | end 149 | 150 | it "includes links between documents" do 151 | song = page[:songs].first 152 | song_model = MyApp::Song.find(song[:id]) 153 | expect(song[:links][:album]).to eq(song_model.album_id.to_s) 154 | expect(song[:links][:artist]).to eq(song_model.artist_id.to_s) 155 | 156 | album = page[:linked][:albums].first 157 | album_model = MyApp::Album.find(album[:id]) 158 | 159 | expect(album[:links][:artist]).to eq(album_model.artist_id.to_s) 160 | expect((page[:songs].map { |song| song[:id] } - album[:links][:songs]).empty?).to eq(true) 161 | end 162 | 163 | context "with includes as comma delimited string" do 164 | let(:params) { { include: "albums,artists" } } 165 | 166 | it "includes side-loaded models" do 167 | expect(page[:linked][:albums]).not_to eq(nil) 168 | expect(page[:linked][:artists]).not_to eq(nil) 169 | end 170 | 171 | it "includes the side-loads in page hrefs" do 172 | expect(page[:meta][:songs][:next_href]).to eq('/songs?page=2&include=albums,artists') 173 | end 174 | 175 | it "includes links" do 176 | expect(page[:links]['songs.album']).not_to eq(nil) 177 | expect(page[:links]['songs.artist']).not_to eq(nil) 178 | expect(page[:links]['albums.songs']).not_to eq(nil) 179 | expect(page[:links]['albums.artist']).not_to eq(nil) 180 | expect(page[:links]['artists.songs']).not_to eq(nil) 181 | expect(page[:links]['artists.albums']).not_to eq(nil) 182 | end 183 | end 184 | end 185 | 186 | context "when filtering" do 187 | context "with no filters" do 188 | let(:params) { {} } 189 | 190 | it "returns a page of all data" do 191 | expect(page[:meta][:songs][:count]).to eq(18) 192 | end 193 | end 194 | 195 | context "with :album_id filter" do 196 | let(:params) { { album_id: @album1.id.to_s } } 197 | 198 | it "returns a page with songs from album1" do 199 | expect(page[:meta][:songs][:count]).to eq(@album1.songs.length) 200 | end 201 | 202 | it "includes the filter in page hrefs" do 203 | expect(page[:meta][:songs][:next_href]).to eq("/songs?page=2&album_id=#{@album1.id}") 204 | end 205 | end 206 | end 207 | 208 | context 'when sorting' do 209 | context 'with no sorting' do 210 | let(:params) { {} } 211 | 212 | it "uses the model's sorting" do 213 | expect(page[:songs].first[:id].to_i < page[:songs].last[:id].to_i).to eq(true) 214 | end 215 | end 216 | 217 | context 'with descending title sorting' do 218 | let(:params) { { sort: '-title' } } 219 | 220 | it 'returns a page with sorted songs' do 221 | expect(page[:songs].first[:title] > page[:songs].last[:title]).to eq(true) 222 | end 223 | 224 | it 'includes the sorting in page hrefs' do 225 | expect(page[:meta][:songs][:next_href]).to eq('/songs?page=2&sort=-title') 226 | end 227 | end 228 | end 229 | 230 | context "with custom scope" do 231 | before do 232 | FactoryGirl.create(:album, year: 1930) 233 | FactoryGirl.create(:album, year: 1948) 234 | end 235 | let(:page) { MyApp::AlbumSerializer.page(params, scope) } 236 | let(:scope) { MyApp::Album.classic } 237 | 238 | it "returns a page of scoped data" do 239 | expect(page[:meta][:albums][:count]).to eq(2) 240 | end 241 | end 242 | end 243 | 244 | context "#page_with_options" do 245 | let(:page) { MyApp::SongSerializer.page_with_options(options) } 246 | let(:params) { {} } 247 | let(:options) { RestPack::Serializer::Options.new(MyApp::SongSerializer, params) } 248 | 249 | context "with defaults" do 250 | it "includes valid paging meta data" do 251 | expect(page[:meta][:songs][:count]).to eq(18) 252 | expect(page[:meta][:songs][:page_count]).to eq(2) 253 | expect(page[:meta][:songs][:previous_page]).to eq(nil) 254 | expect(page[:meta][:songs][:next_page]).to eq(2) 255 | end 256 | end 257 | 258 | context "with custom page size" do 259 | let(:params) { { page_size: '3' } } 260 | 261 | it "returns custom page sizes" do 262 | expect(page[:meta][:songs][:page_size]).to eq(3) 263 | expect(page[:meta][:songs][:page_count]).to eq(6) 264 | end 265 | end 266 | end 267 | 268 | context "paging with paged side-load" do 269 | let(:page) { MyApp::AlbumSerializer.page_with_options(options) } 270 | let(:options) { RestPack::Serializer::Options.new(MyApp::AlbumSerializer, include: 'songs') } 271 | 272 | it "includes side-loaded paging data in meta data" do 273 | expect(page[:meta][:albums]).not_to eq(nil) 274 | expect(page[:meta][:albums][:page]).to eq(1) 275 | expect(page[:meta][:songs]).not_to eq(nil) 276 | expect(page[:meta][:songs][:page]).to eq(1) 277 | end 278 | end 279 | 280 | context "paging with two paged side-loads" do 281 | let(:page) { MyApp::ArtistSerializer.page_with_options(options) } 282 | let(:options) { RestPack::Serializer::Options.new(MyApp::ArtistSerializer, include: 'albums,songs') } 283 | 284 | it "includes side-loaded paging data in meta data" do 285 | expect(page[:meta][:albums]).not_to eq(nil) 286 | expect(page[:meta][:albums][:page]).to eq(1) 287 | expect(page[:meta][:songs]).not_to eq(nil) 288 | expect(page[:meta][:songs][:page]).to eq(1) 289 | end 290 | end 291 | end 292 | -------------------------------------------------------------------------------- /spec/serializable/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require '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) { MyApp::SongSerializer.resource(params, scope, context) } 10 | let(:params) { { id: @song.id } } 11 | let(:scope) { nil } 12 | let(:context) { {} } 13 | 14 | it "returns a resource by id" do 15 | expect(resource[:songs].count).to eq(1) 16 | expect(resource[:songs][0][:id]).to eq(@song.id.to_s) 17 | end 18 | 19 | context "with context" do 20 | let(:context) { { reverse_title?: true } } 21 | 22 | it "returns reversed titles" do 23 | expect(resource[:songs][0][:title]).to eq(@song.title.reverse) 24 | end 25 | end 26 | 27 | describe "side-loading" do 28 | let(:params) { { id: @song.id, include: 'albums' } } 29 | 30 | it "includes side-loaded models" do 31 | expect(resource[:linked][:albums].count).to eq(1) 32 | expect(resource[:linked][:albums].first[:id]).to eq(@song.album.id.to_s) 33 | end 34 | 35 | it "includes the side-loads in the main meta data" do 36 | expect(resource[:meta][:songs][:include]).to eq(%w(albums)) 37 | end 38 | end 39 | 40 | describe "missing resource" do 41 | let(:params) { { id: "-99" } } 42 | 43 | it "returns no resource" do 44 | expect(resource[:songs].length).to eq(0) 45 | end 46 | 47 | #TODO: add specs for jsonapi error format when it has been standardised 48 | # https://github.com/RestPack/restpack_serializer/issues/27 49 | # https://github.com/json-api/json-api/issues/7 50 | end 51 | 52 | describe "song with no artist" do 53 | let(:song) { FactoryGirl.create(:song, artist: nil) } 54 | let(:resource) { MyApp::SongSerializer.resource(id: song.id.to_s) } 55 | 56 | it "should not have an artist link" do 57 | expect(resource[:songs][0][:links].keys).not_to include(:artist) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/serializable/serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer do 4 | let(:serializer) { PersonSerializer.new } 5 | let(:person) { Person.new(id: 123, name: 'Gavin', age: 36) } 6 | class Person 7 | attr_accessor :id, :name, :age 8 | 9 | def initialize(attributes = {}) 10 | @id = attributes[:id] 11 | @name = attributes[:name] 12 | @age = attributes[:age] 13 | end 14 | 15 | def self.table_name 16 | "people" 17 | end 18 | 19 | def to_param 20 | id.to_s 21 | end 22 | end 23 | 24 | context "bare bones serializer" do 25 | class EmptySerializer 26 | include RestPack::Serializer 27 | end 28 | 29 | it ".as_json serializes to an empty hash" do 30 | expect(EmptySerializer.as_json(person)).to eq({}) 31 | end 32 | end 33 | 34 | context "serializer inheritance" do 35 | class BaseSerializer 36 | include RestPack::Serializer 37 | attributes :name, :colour 38 | optional :count 39 | 40 | def name 41 | @context[:name] 42 | end 43 | 44 | def count 45 | 99 46 | end 47 | 48 | def age 49 | -2 50 | end 51 | 52 | def colour 53 | 'purple' 54 | end 55 | 56 | def include_colour? 57 | false 58 | end 59 | end 60 | 61 | class DerivedSerializer < BaseSerializer 62 | attributes :name, :age, :food, :colour, :count 63 | 64 | def age 65 | @context[:age] 66 | end 67 | 68 | def food 69 | 'crackers' 70 | end 71 | end 72 | 73 | it ".as_json serializes" do 74 | serialized = DerivedSerializer.as_json({}, include_food?: false, name: 'Ben', age: 1) 75 | expect(serialized).to eq({ #NOTE: I think this should include colour as DerivedSerializer defines it, but this would be a big breaking change 76 | name: "Ben", 77 | age: 1 78 | }) 79 | end 80 | end 81 | 82 | context "serializer instance variables" do 83 | class MemoizingSerializer 84 | include RestPack::Serializer 85 | 86 | attributes :id, :memoized_id 87 | 88 | def memoized_id 89 | @memoized_id ||= id 90 | end 91 | end 92 | 93 | it "does not reuse instance variable values" do 94 | people = [Person.new(id: 123), Person.new(id: 456)] 95 | serialized = MemoizingSerializer.as_json(people) 96 | expect(serialized).to eq([ 97 | { id: "123", memoized_id: "123" }, 98 | { id: "456", memoized_id: "456" } 99 | ]) 100 | end 101 | end 102 | 103 | class PersonSerializer 104 | include RestPack::Serializer 105 | attributes :id, :name, :description, :href, :admin_info, :string_keys 106 | 107 | def description 108 | "This is person ##{id}" 109 | end 110 | 111 | def admin_info 112 | { 113 | key: "super_secret_sauce", 114 | array: [ 115 | { name: "Alex" } 116 | ] 117 | } 118 | end 119 | 120 | def include_admin_info? 121 | @context[:is_admin?] 122 | end 123 | 124 | def string_keys 125 | { 126 | "kid_b" => "Ben", 127 | "likes" => { 128 | "foods" => ["crackers", "stawberries"], 129 | "books" => ["Dumpy", "That's Not My Tiger"] 130 | } 131 | } 132 | end 133 | 134 | def include_string_keys? 135 | @context[:is_ben?] 136 | end 137 | 138 | def custom_attributes 139 | { 140 | custom_key: "custom value for model id #{@model.id}" 141 | } 142 | end 143 | end 144 | 145 | describe ".serialize" do 146 | it "serializes to an array" do 147 | expect(serializer.class.serialize(person)).to eq( 148 | people: [{ 149 | id: '123', name: 'Gavin', description: 'This is person #123', 150 | href: '/people/123', custom_key: 'custom value for model id 123' 151 | }] 152 | ) 153 | end 154 | end 155 | 156 | describe ".as_json" do 157 | it "serializes specified attributes" do 158 | expect(serializer.as_json(person)).to eq( 159 | id: '123', name: 'Gavin', description: 'This is person #123', 160 | href: '/people/123', custom_key: 'custom value for model id 123' 161 | ) 162 | end 163 | 164 | context "an array" do 165 | let(:people) { [person, person] } 166 | 167 | it "results in a serialized array" do 168 | expect(serializer.as_json(people)).to eq([ 169 | { 170 | id: '123', name: 'Gavin', description: 'This is person #123', 171 | href: '/people/123', custom_key: 'custom value for model id 123' 172 | }, 173 | { 174 | id: '123', name: 'Gavin', description: 'This is person #123', 175 | href: '/people/123', custom_key: 'custom value for model id 123' 176 | } 177 | ]) 178 | end 179 | 180 | context "#array_as_json" do 181 | it "results in a serialized array" do 182 | expect(serializer.class.array_as_json(people)).to eq([ 183 | { 184 | id: '123', name: 'Gavin', description: 'This is person #123', 185 | href: '/people/123', custom_key: 'custom value for model id 123' 186 | }, 187 | { 188 | id: '123', name: 'Gavin', description: 'This is person #123', 189 | href: '/people/123', custom_key: 'custom value for model id 123' 190 | } 191 | ]) 192 | end 193 | end 194 | end 195 | 196 | context "nil" do 197 | it "results in nil" do 198 | expect(serializer.as_json(nil)).to eq(nil) 199 | end 200 | end 201 | 202 | context "with options" do 203 | it "excludes specified attributes" do 204 | expect(serializer.as_json(person, include_description?: false)).to eq( 205 | id: '123', name: 'Gavin', href: '/people/123', 206 | custom_key: 'custom value for model id 123' 207 | ) 208 | end 209 | 210 | it "excludes custom attributes if specified" do 211 | hash = serializer.as_json(person, is_admin?: false) 212 | expect(hash[:admin_info]).to eq(nil) 213 | end 214 | 215 | it "includes custom attributes if specified" do 216 | hash = serializer.as_json(person, is_admin?: true) 217 | expect(hash[:admin_info]).to eq( 218 | key: "super_secret_sauce", 219 | array: [ 220 | name: 'Alex' 221 | ] 222 | ) 223 | end 224 | 225 | it "excludes a blacklist of attributes if specified as an array" do 226 | expect(serializer.as_json(person, attribute_blacklist: [:name, :description])).to eq( 227 | id: '123', 228 | href: '/people/123', 229 | custom_key: 'custom value for model id 123' 230 | ) 231 | end 232 | 233 | it "excludes a blacklist of attributes if specified as a string" do 234 | expect(serializer.as_json(person, attribute_blacklist: 'name, description')).to eq( 235 | id: '123', 236 | href: '/people/123', 237 | custom_key: 'custom value for model id 123' 238 | ) 239 | end 240 | 241 | it "includes a whitelist of attributes if specified as an array" do 242 | expect(serializer.as_json(person, attribute_whitelist: [:name, :description])).to eq( 243 | name: 'Gavin', 244 | description: 'This is person #123', 245 | custom_key: 'custom value for model id 123' 246 | ) 247 | end 248 | 249 | it "includes a whitelist of attributes if specified as a string" do 250 | expect(serializer.as_json(person, attribute_whitelist: 'name, description')).to eq( 251 | name: 'Gavin', 252 | description: 'This is person #123', 253 | custom_key: 'custom value for model id 123' 254 | ) 255 | end 256 | 257 | it "raises an exception if both the whitelist and blacklist are provided" do 258 | expect do 259 | serializer.as_json(person, attribute_whitelist: [:name], attribute_blacklist: [:id]) 260 | end.to raise_error(ArgumentError, "the context can't define both an `attribute_whitelist` and an `attribute_blacklist`") 261 | end 262 | end 263 | 264 | context "links" do 265 | context "'belongs to' associations" do 266 | let(:serializer) { MyApp::SongSerializer.new } 267 | 268 | it "includes 'links' data for :belongs_to associations" do 269 | @album1 = FactoryGirl.create(:album_with_songs, song_count: 11) 270 | json = serializer.as_json(@album1.songs.first) 271 | expect(json[:links]).to eq( 272 | artist: @album1.artist_id.to_s, 273 | album: @album1.id.to_s 274 | ) 275 | end 276 | end 277 | 278 | context "with a serializer with has_* associations" do 279 | let(:artist_factory) { FactoryGirl.create :artist_with_fans } 280 | let(:artist_serializer) { MyApp::ArtistSerializer.new } 281 | let(:json) { artist_serializer.as_json(artist_factory) } 282 | let(:side_load_ids) { artist_has_association.map { |obj| obj.id.to_s } } 283 | 284 | context "when the association has been eager loaded" do 285 | before do 286 | allow(artist_factory.fans).to receive(:loaded?).and_return(true) 287 | end 288 | 289 | it "does not make a query to retrieve id values" do 290 | expect(artist_factory.fans).not_to receive(:pluck) 291 | json 292 | end 293 | end 294 | 295 | 296 | describe "'has_many, through' associations" do 297 | let(:artist_has_association) { artist_factory.fans } 298 | 299 | it "includes 'links' data when there are associated records" do 300 | expect(json[:links][:fans]).to match_array(side_load_ids) 301 | end 302 | end 303 | 304 | describe "'has_and_belongs_to_many' associations" do 305 | let(:artist_factory) { FactoryGirl.create :artist_with_stalkers } 306 | let(:artist_has_association) { artist_factory.stalkers } 307 | 308 | it "includes 'links' data when there are associated records" do 309 | expect(json[:links][:stalkers]).to match_array(side_load_ids) 310 | end 311 | end 312 | end 313 | end 314 | end 315 | 316 | describe "to_json" do 317 | context "class method" do 318 | it "delegates to as_json" do 319 | expect(PersonSerializer.as_json(person).to_json).to eq(PersonSerializer.to_json(person)) 320 | end 321 | end 322 | 323 | context "instance method" do 324 | it "delegates to as_json" do 325 | expect(serializer.as_json(person).to_json).to eq(serializer.to_json(person)) 326 | end 327 | end 328 | end 329 | 330 | describe "#model_class" do 331 | it "extracts the Model name from the Serializer name" do 332 | expect(PersonSerializer.model_class).to eq(Person) 333 | end 334 | 335 | context "with namespaced model class" do 336 | module SomeNamespace 337 | class Model 338 | end 339 | end 340 | 341 | class NamespacedSerializer 342 | include RestPack::Serializer 343 | self.model_class = SomeNamespace::Model 344 | end 345 | 346 | it "returns the correct class" do 347 | expect(NamespacedSerializer.model_class).to eq(SomeNamespace::Model) 348 | end 349 | end 350 | end 351 | 352 | describe "#key" do 353 | context "with default key" do 354 | it "returns the correct key" do 355 | expect(PersonSerializer.key).to eq(:people) 356 | end 357 | 358 | it "has correct #singular_key" do 359 | expect(PersonSerializer.singular_key).to eq(:person) 360 | end 361 | 362 | it "has correct #plural_key" do 363 | expect(PersonSerializer.plural_key).to eq(:people) 364 | end 365 | end 366 | 367 | context "with custom key" do 368 | class SerializerWithCustomKey 369 | include RestPack::Serializer 370 | self.key = :customers 371 | end 372 | 373 | it "returns the correct key" do 374 | expect(SerializerWithCustomKey.key).to eq(:customers) 375 | end 376 | 377 | it "has correct #singular_key" do 378 | expect(SerializerWithCustomKey.singular_key).to eq(:customer) 379 | end 380 | end 381 | end 382 | end 383 | -------------------------------------------------------------------------------- /spec/serializable/side_loading/belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::SideLoading do 4 | context "when side-loading" do 5 | describe ".belongs_to" do 6 | 7 | before(:each) do 8 | FactoryGirl.create(:artist_with_albums, album_count: 2) 9 | FactoryGirl.create(:artist_with_albums, album_count: 1) 10 | end 11 | let(:side_loads) { MyApp::SongSerializer.side_loads(models, options) } 12 | 13 | context "with no models" do 14 | let(:models) { [] } 15 | 16 | context "no side-loads" do 17 | let(:options) { RestPack::Serializer::Options.new(MyApp::SongSerializer) } 18 | 19 | it "returns a hash with no data" do 20 | expect(side_loads).to eq(meta: {}) 21 | end 22 | end 23 | 24 | context "when including :albums" do 25 | let(:options) { RestPack::Serializer::Options.new(MyApp::SongSerializer, "include" => "albums") } 26 | 27 | it "returns a hash with no data" do 28 | expect(side_loads).to eq(meta: {}) 29 | end 30 | end 31 | end 32 | 33 | context "with a single model" do 34 | let(:models) { [MyApp::Song.first] } 35 | 36 | context "when including :albums" do 37 | let(:options) { RestPack::Serializer::Options.new(MyApp::SongSerializer, "include" => "albums") } 38 | 39 | it "returns side-loaded albums" do 40 | expect(side_loads).to eq( 41 | albums: [MyApp::AlbumSerializer.as_json(MyApp::Song.first.album)], 42 | meta: {} 43 | ) 44 | end 45 | end 46 | end 47 | 48 | context "with multiple models" do 49 | let(:artist1) { MyApp::Artist.find(1) } 50 | let(:artist2) { MyApp::Artist.find(2) } 51 | let(:song1) { artist1.songs.first } 52 | let(:song2) { artist2.songs.first } 53 | let(:models) { [song1, song2] } 54 | 55 | context "when including :albums" do 56 | let(:options) { RestPack::Serializer::Options.new(MyApp::SongSerializer, "include" => "albums") } 57 | 58 | it "returns side-loaded albums" do 59 | expect(side_loads).to eq( 60 | albums: [ 61 | MyApp::AlbumSerializer.as_json(song1.album), 62 | MyApp::AlbumSerializer.as_json(song2.album) 63 | ], 64 | meta: {} 65 | ) 66 | end 67 | end 68 | end 69 | 70 | context 'without an associated model' do 71 | let!(:b_side) { FactoryGirl.create(:song, album: nil) } 72 | let(:models) { [b_side] } 73 | 74 | context 'when including :albums' do 75 | let(:options) { RestPack::Serializer::Options.new(MyApp::SongSerializer, "include" => "albums") } 76 | 77 | it 'return a hash with no data' do 78 | expect(side_loads).to eq( 79 | albums: [], 80 | meta: {} 81 | ) 82 | end 83 | end 84 | end 85 | 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/serializable/side_loading/has_and_belongs_many_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::SideLoading do 4 | context "when side-loading" do 5 | let(:side_loads) { MyApp::ArtistSerializer.side_loads(models, options) } 6 | 7 | describe ".has_and_belongs_to_many" do 8 | 9 | before(:each) do 10 | @artist1 = FactoryGirl.create(:artist_with_stalkers, stalker_count: 2) 11 | @artist2 = FactoryGirl.create(:artist_with_stalkers, stalker_count: 3) 12 | end 13 | 14 | context "with a single model" do 15 | let(:models) { [@artist1] } 16 | 17 | context "when including :albums" do 18 | let(:options) { RestPack::Serializer::Options.new(MyApp::ArtistSerializer, "include" => "stalkers") } 19 | let(:stalker_count) { @artist1.stalkers.count } 20 | 21 | it "returns side-loaded albums" do 22 | expect(side_loads[:stalkers].count).to eq(stalker_count) 23 | expect(side_loads[:meta][:stalkers][:page]).to eq(1) 24 | expect(side_loads[:meta][:stalkers][:count]).to eq(stalker_count) 25 | end 26 | end 27 | end 28 | 29 | context "with two models" do 30 | let(:models) { [@artist1, @artist2] } 31 | 32 | context "when including :albums" do 33 | let(:options) { RestPack::Serializer::Options.new(MyApp::ArtistSerializer, "include" => "stalkers") } 34 | let(:stalker_count) { @artist1.stalkers.count + @artist2.stalkers.count } 35 | 36 | it "returns side-loaded albums" do 37 | expect(side_loads[:stalkers].count).to eq(stalker_count) 38 | expect(side_loads[:meta][:stalkers][:count]).to eq(stalker_count) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/serializable/side_loading/has_many_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::SideLoading do 4 | context "when side-loading" do 5 | let(:side_loads) { MyApp::ArtistSerializer.side_loads(models, options) } 6 | 7 | describe ".has_many" do 8 | 9 | before(:each) do 10 | @artist1 = FactoryGirl.create(:artist_with_albums, album_count: 2) 11 | @artist2 = FactoryGirl.create(:artist_with_albums, album_count: 1) 12 | end 13 | 14 | context "with a single model" do 15 | let(:models) { [@artist1] } 16 | 17 | context "when including :albums" do 18 | let(:options) { RestPack::Serializer::Options.new(MyApp::ArtistSerializer, "include" => "albums") } 19 | 20 | it "returns side-loaded albums" do 21 | expect(side_loads[:albums].count).to eq(@artist1.albums.count) 22 | expect(side_loads[:meta][:albums][:page]).to eq(1) 23 | expect(side_loads[:meta][:albums][:count]).to eq(@artist1.albums.count) 24 | end 25 | end 26 | end 27 | 28 | context "with two models" do 29 | let(:models) { [@artist1, @artist2] } 30 | 31 | context "when including :albums" do 32 | let(:options) { RestPack::Serializer::Options.new(MyApp::ArtistSerializer, "include" => "albums") } 33 | 34 | it "returns side-loaded albums" do 35 | expected_count = @artist1.albums.count + @artist2.albums.count 36 | expect(side_loads[:albums].count).to eq(expected_count) 37 | expect(side_loads[:meta][:albums][:count]).to eq(expected_count) 38 | end 39 | end 40 | end 41 | end 42 | 43 | describe '.has_many through' do 44 | context 'when including :fans' do 45 | let(:options) { RestPack::Serializer::Options.new(MyApp::ArtistSerializer, "include" => "fans") } 46 | let(:artist_1) { FactoryGirl.create :artist_with_fans } 47 | let(:artist_2) { FactoryGirl.create :artist_with_fans } 48 | 49 | context "with a single model" do 50 | let(:models) { [artist_1] } 51 | 52 | it 'returns side-loaded fans' do 53 | expect(side_loads[:fans].count).to eq(artist_1.fans.count) 54 | expect(side_loads[:meta][:fans][:page]).to eq(1) 55 | expect(side_loads[:meta][:fans][:count]).to eq(artist_1.fans.count) 56 | end 57 | end 58 | context "with a multiple models" do 59 | let(:models) { [artist_1, artist_2] } 60 | 61 | it 'returns side-loaded fans' do 62 | expected_count = artist_1.fans.count + artist_2.fans.count 63 | expect(side_loads[:fans].count).to eq(expected_count) 64 | expect(side_loads[:meta][:fans][:page]).to eq(1) 65 | expect(side_loads[:meta][:fans][:count]).to eq(expected_count) 66 | end 67 | 68 | context "when there are shared fans" do 69 | before do 70 | artist_1.fans << artist_2.fans.first 71 | end 72 | it "should not include duplicates in the linked resource collection" do 73 | expected_count = (artist_1.fans + artist_2.fans).uniq.count 74 | expect(side_loads[:fans].count).to eq(expected_count) 75 | expect(side_loads[:meta][:fans][:count]).to eq(expected_count) 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/serializable/side_loading/side_loading_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::SideLoading do 4 | context "invalid :include" do 5 | before(:each) do 6 | FactoryGirl.create(:song) 7 | end 8 | 9 | context "an include to an inexistent model" do 10 | it "raises an exception" do 11 | exception = RestPack::Serializer::InvalidInclude 12 | message = ":wrong is not a valid include for MyApp::Song" 13 | 14 | expect do 15 | MyApp::SongSerializer.side_loads([MyApp::Song.first], RestPack::Serializer::Options.new(MyApp::SongSerializer, "include" => "wrong")) 16 | end.to raise_error(exception, message) 17 | end 18 | end 19 | 20 | context "an include to a model which has not been whitelisted with 'can_include'" do 21 | it "raises an exception" do 22 | payment = FactoryGirl.create(:payment) 23 | exception = RestPack::Serializer::InvalidInclude 24 | message = ":payments is not a valid include for MyApp::Artist" 25 | 26 | expect do 27 | MyApp::ArtistSerializer.side_loads([payment.artist], RestPack::Serializer::Options.new(MyApp::ArtistSerializer, "include" => "payments")) 28 | end.to raise_error(exception, message) 29 | end 30 | end 31 | end 32 | 33 | describe "#can_include" do 34 | class CustomSerializer 35 | include RestPack::Serializer 36 | attributes :a, :b, :c 37 | end 38 | it "defaults to empty array" do 39 | expect(CustomSerializer.can_includes).to eq([]) 40 | end 41 | 42 | it "allows includes to be specified" do 43 | class CustomSerializer 44 | can_include :model1 45 | can_include :model2, :model3 46 | end 47 | 48 | expect(CustomSerializer.can_includes).to eq([:model1, :model2, :model3]) 49 | end 50 | end 51 | 52 | describe "#links" do 53 | it do 54 | expect(MyApp::AlbumSerializer.links).to eq( 55 | "albums.artist" => { 56 | href: "/artists/{albums.artist}", 57 | type: :artists 58 | }, 59 | "albums.songs" => { 60 | href: "/songs?album_id={albums.id}", 61 | type: :songs 62 | } 63 | ) 64 | end 65 | 66 | it "applies custom RestPack::Serializer.config.href_prefix" do 67 | original = RestPack::Serializer.config.href_prefix 68 | RestPack::Serializer.config.href_prefix = "/api/v1" 69 | expect(MyApp::AlbumSerializer.links["albums.artist"][:href]).to eq("/api/v1/artists/{albums.artist}") 70 | RestPack::Serializer.config.href_prefix = original 71 | end 72 | 73 | it "applies custom serializer href_prefix" do 74 | original = RestPack::Serializer.config.href_prefix 75 | MyApp::AlbumSerializer.href_prefix = '/api/v2' 76 | expect(MyApp::AlbumSerializer.links["albums.artist"][:href]).to eq("/api/v2/artists/{albums.artist}") 77 | MyApp::AlbumSerializer.href_prefix = original 78 | end 79 | end 80 | 81 | describe "#filterable_by" do 82 | context "a model with no :belongs_to relations" do 83 | it "is filterable by :id only" do 84 | expect(MyApp::ArtistSerializer.filterable_by).to eq([:id]) 85 | end 86 | end 87 | context "a model with a single :belongs_to relations" do 88 | it "is filterable by primary key and foreign keys" do 89 | expect(MyApp::AlbumSerializer.filterable_by).to eq([:id, :artist_id, :year]) 90 | end 91 | end 92 | context "a model with multiple :belongs_to relations" do 93 | it "is filterable by primary key and foreign keys" do 94 | expect(MyApp::SongSerializer.filterable_by).to eq([:id, :artist_id, :album_id, :title]) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/serializable/single_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Single 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) { MyApp::SongSerializer.single(params, scope, context) } 10 | let(:params) { { id: @song.id } } 11 | let(:scope) { nil } 12 | let(:context) { { } } 13 | 14 | it "returns a resource by id" do 15 | expect(resource[:id]).to eq(@song.id.to_s) 16 | expect(resource[:title]).to eq(@song.title) 17 | end 18 | 19 | context "with context" do 20 | let(:context) { { reverse_title?: true } } 21 | 22 | it "returns reversed titles" do 23 | expect(resource[:title]).to eq(@song.title.reverse) 24 | end 25 | end 26 | 27 | context "invalid id" do 28 | let(:params) { { id: @song.id + 100 } } 29 | 30 | it "returns nil" do 31 | expect(resource).to eq(nil) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/serializable/sortable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RestPack::Serializer::Sortable do 4 | class CustomSerializer 5 | include RestPack::Serializer 6 | attributes :a, :b, :c 7 | 8 | can_sort_by :a, :c 9 | end 10 | 11 | it 'captures the specified sorting attributes' do 12 | expect(CustomSerializer.serializable_sorting_attributes).to eq([:a, :c]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require './lib/restpack_serializer' 3 | require './spec/fixtures/db' 4 | require './spec/fixtures/serializers' 5 | require './spec/support/factory' 6 | require 'database_cleaner' 7 | require 'coveralls' 8 | 9 | Coveralls::Output.silent = true unless ENV["CI"] 10 | Coveralls.wear! 11 | FactoryGirl.find_definitions 12 | 13 | RSpec.configure do |config| 14 | config.include FactoryGirl::Syntax::Methods 15 | config.raise_errors_for_deprecations! 16 | 17 | config.before(:suite) do 18 | DatabaseCleaner.clean_with(:truncation) 19 | end 20 | 21 | config.before(:each) do 22 | DatabaseCleaner.strategy = :transaction 23 | end 24 | 25 | config.before(:each) do 26 | DatabaseCleaner.start 27 | end 28 | 29 | config.after(:each) do 30 | DatabaseCleaner.clean 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/factory.rb: -------------------------------------------------------------------------------- 1 | require 'factory_girl' 2 | 3 | FactoryGirl.define do 4 | factory :artist, class: MyApp::Artist do 5 | sequence(:name) { |n| "Artist ##{n}" } 6 | sequence(:website) { |n| "http://website#{n}.com/" } 7 | 8 | factory :artist_with_albums do 9 | transient { album_count 3 } 10 | 11 | after(:create) do |artist, evaluator| 12 | create_list(:album_with_songs, evaluator.album_count, artist: artist) 13 | end 14 | end 15 | 16 | factory :artist_with_fans do 17 | transient { fans_count 3 } 18 | 19 | after(:create) do |artist, evaluator| 20 | create_list(:payment, evaluator.fans_count, artist: artist) 21 | end 22 | end 23 | 24 | factory :artist_with_stalkers do 25 | transient { stalker_count 2 } 26 | 27 | after(:create) do |artist, evaluator| 28 | create_list(:stalker, evaluator.stalker_count, artists: [artist]) 29 | end 30 | end 31 | end 32 | 33 | factory :album, class: MyApp::Album do 34 | sequence(:title) { |n| "Album ##{n}" } 35 | sequence(:year) { |n| 1960 + n } 36 | artist 37 | 38 | factory :album_with_songs do 39 | transient { song_count 10 } 40 | 41 | after(:create) do |album, evaluator| 42 | create_list(:song, evaluator.song_count, album: album, artist: album.artist) 43 | end 44 | end 45 | end 46 | 47 | factory :song, class: MyApp::Song do 48 | sequence(:title) { |n| "Song ##{n}" } 49 | artist 50 | album 51 | end 52 | 53 | factory :payment, class: MyApp::Payment do 54 | amount 999 55 | artist 56 | fan 57 | end 58 | 59 | factory :fan, class: MyApp::Fan do 60 | sequence(:name) { |n| "Fan ##{n}" } 61 | end 62 | 63 | factory :stalker, class: MyApp::Stalker do 64 | sequence(:name) { |n| "Stalker ##{n}" } 65 | end 66 | end 67 | --------------------------------------------------------------------------------