├── Rakefile ├── .rspec ├── lib ├── jsonapi-serializers │ ├── version.rb │ ├── attributes.rb │ └── serializer.rb └── jsonapi-serializers.rb ├── Gemfile ├── .travis.yml ├── .gitignore ├── spec ├── spec_helper.rb ├── support │ ├── factory.rb │ └── serializers.rb └── serializer_spec.rb ├── jsonapi-serializers.gemspec ├── LICENSE └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format d 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/jsonapi-serializers/version.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Serializer 3 | VERSION = '0.1.2' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in jsonapi-serializers.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.1.1 5 | - 2.2.2 6 | - ruby-head 7 | script: bundle exec rspec 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /lib/jsonapi-serializers.rb: -------------------------------------------------------------------------------- 1 | require "jsonapi-serializers/version" 2 | require "jsonapi-serializers/attributes" 3 | require "jsonapi-serializers/serializer" 4 | 5 | module JSONAPI 6 | module Serializer 7 | class Error < Exception; end 8 | class AmbiguousCollectionError < Error; end 9 | class InvalidIncludeError < Error; end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'factory_girl' 2 | require './lib/jsonapi-serializers' 3 | require './spec/support/serializers' 4 | 5 | RSpec.configure do |config| 6 | config.include FactoryGirl::Syntax::Methods 7 | 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | 12 | config.before(:each) do 13 | # Force FactoryGirl sequences to be fully reset before each test run to simplify ID testing 14 | # since we are not using a database or real fixtures. Inside of each test case, IDs will 15 | # increment per type starting at 1. 16 | FactoryGirl.reload 17 | load './spec/support/factory.rb' 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/factory.rb: -------------------------------------------------------------------------------- 1 | require 'factory_girl' 2 | 3 | FactoryGirl.define do 4 | factory :post, class: MyApp::Post do 5 | skip_create 6 | sequence(:id) {|n| n } 7 | sequence(:title) {|n| "Title for Post #{n}" } 8 | sequence(:body) {|n| "Body for Post #{n}" } 9 | 10 | trait :with_author do 11 | association :author, factory: :user 12 | end 13 | end 14 | 15 | factory :long_comment, class: MyApp::LongComment do 16 | skip_create 17 | sequence(:id) {|n| n } 18 | sequence(:body) {|n| "Body for LongComment #{n}" } 19 | end 20 | 21 | factory :user, class: MyApp::User do 22 | skip_create 23 | sequence(:id) {|n| n } 24 | sequence(:name) {|n| "User ##{n}"} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /jsonapi-serializers.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'jsonapi-serializers/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "jsonapi-serializers" 8 | spec.version = JSONAPI::Serializer::VERSION 9 | spec.authors = ["Mike Fotinakis"] 10 | spec.email = ["mike@fotinakis.com"] 11 | spec.summary = %q{Pure Ruby readonly serializers for the JSON:API spec.} 12 | spec.description = %q{} 13 | spec.homepage = "" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "activesupport" 22 | spec.add_development_dependency "bundler", "~> 1.7" 23 | spec.add_development_dependency "rake", "~> 10.0" 24 | spec.add_development_dependency "rspec", "~> 3.2" 25 | spec.add_development_dependency "factory_girl", "~> 4.5" 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Mike Fotinakis 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/jsonapi-serializers/attributes.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Attributes 3 | def self.included(target) 4 | target.send(:include, InstanceMethods) 5 | target.extend ClassMethods 6 | end 7 | 8 | module InstanceMethods 9 | end 10 | 11 | module ClassMethods 12 | attr_accessor :attributes_map 13 | attr_accessor :to_one_associations 14 | attr_accessor :to_many_associations 15 | 16 | def attribute(name, options = {}, &block) 17 | add_attribute(name, options, &block) 18 | end 19 | 20 | def has_one(name, options = {}, &block) 21 | add_to_one_association(name, options, &block) 22 | end 23 | 24 | def has_many(name, options = {}, &block) 25 | add_to_many_association(name, options, &block) 26 | end 27 | 28 | def add_attribute(name, options = {}, &block) 29 | # Blocks are optional and can override the default attribute discovery. They are just 30 | # stored here, but evaluated by the Serializer within the instance context. 31 | @attributes_map ||= {} 32 | @attributes_map[name] = { 33 | attr_or_block: block_given? ? block : name, 34 | options: options, 35 | } 36 | end 37 | private :add_attribute 38 | 39 | def add_to_one_association(name, options = {}, &block) 40 | @to_one_associations ||= {} 41 | @to_one_associations[name] = { 42 | attr_or_block: block_given? ? block : name, 43 | options: options, 44 | } 45 | end 46 | private :add_to_one_association 47 | 48 | def add_to_many_association(name, options = {}, &block) 49 | @to_many_associations ||= {} 50 | @to_many_associations[name] = { 51 | attr_or_block: block_given? ? block : name, 52 | options: options, 53 | } 54 | end 55 | private :add_to_many_association 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/support/serializers.rb: -------------------------------------------------------------------------------- 1 | module MyApp 2 | class Post 3 | attr_accessor :id 4 | attr_accessor :title 5 | attr_accessor :body 6 | attr_accessor :author 7 | attr_accessor :long_comments 8 | end 9 | 10 | class LongComment 11 | attr_accessor :id 12 | attr_accessor :body 13 | attr_accessor :user 14 | attr_accessor :post 15 | end 16 | 17 | class User 18 | attr_accessor :id 19 | attr_accessor :name 20 | end 21 | 22 | class PostSerializer 23 | include JSONAPI::Serializer 24 | 25 | attribute :title 26 | attribute :long_content do 27 | object.body 28 | end 29 | 30 | has_one :author 31 | has_many :long_comments 32 | end 33 | 34 | class LongCommentSerializer 35 | include JSONAPI::Serializer 36 | 37 | attribute :body 38 | has_one :user 39 | 40 | # Circular-reference back to post. 41 | has_one :post 42 | end 43 | 44 | class UserSerializer 45 | include JSONAPI::Serializer 46 | 47 | attribute :name 48 | end 49 | 50 | # More customized, one-off serializers to test particular behaviors: 51 | 52 | class SimplestPostSerializer 53 | include JSONAPI::Serializer 54 | 55 | attribute :title 56 | attribute :long_content do 57 | object.body 58 | end 59 | 60 | def type 61 | :posts 62 | end 63 | end 64 | 65 | class PostSerializerWithMetadata 66 | include JSONAPI::Serializer 67 | 68 | attribute :title 69 | attribute :long_content do 70 | object.body 71 | end 72 | 73 | def type 74 | 'posts' # Intentionally test string type. 75 | end 76 | 77 | def meta 78 | { 79 | 'copyright' => 'Copyright 2015 Example Corp.', 80 | 'authors' => ['Aliens'], 81 | } 82 | end 83 | end 84 | 85 | class PostSerializerWithContextHandling < SimplestPostSerializer 86 | include JSONAPI::Serializer 87 | 88 | attribute :body, if: :show_body?, unless: :hide_body? 89 | 90 | def show_body? 91 | context.fetch(:show_body, true) 92 | end 93 | 94 | def hide_body? 95 | context.fetch(:hide_body, false) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONAPI::Serializers 2 | 3 | [![Build Status](https://travis-ci.org/fotinakis/jsonapi-serializers.svg?branch=master)](https://travis-ci.org/fotinakis/jsonapi-serializers) 4 | [![Gem Version](https://badge.fury.io/rb/jsonapi-serializers.svg)](http://badge.fury.io/rb/jsonapi-serializers) 5 | 6 | 7 | JSONAPI::Serializers is a simple library for serializing Ruby objects and their relationships into the [JSON:API format](http://jsonapi.org/format/). 8 | 9 | As of writing, the JSON:API spec is approaching v1 and still undergoing changes. This library supports RC3+ and aims to keep up with the continuing development changes. 10 | 11 | * [Features](#features) 12 | * [Installation](#installation) 13 | * [Usage](#usage) 14 | * [Define a serializer](#define-a-serializer) 15 | * [Serialize an object](#serialize-an-object) 16 | * [Serialize a collection](#serialize-a-collection) 17 | * [Null handling](#null-handling) 18 | * [Custom attributes](#custom-attributes) 19 | * [More customizations](#more-customizations) 20 | * [Relationships](#relationships) 21 | * [Compound documents and includes](#compound-documents-and-includes) 22 | * [Relationship path handling](#relationship-path-handling) 23 | * [Rails example](#rails-example) 24 | * [Unfinished business](#unfinished-business) 25 | * [Contributing](#contributing) 26 | 27 | ## Features 28 | 29 | * Works with **any Ruby web framework**, including Rails, Sinatra, etc. This is a pure Ruby library. 30 | * Supports the readonly features of the JSON:API spec. 31 | * **Full support for compound documents** ("side-loading") and the `include` parameter. 32 | * Similar interface to ActiveModel::Serializers, should provide an easy migration path. 33 | * Intentionally unopinionated and simple, allows you to structure your app however you would like and then serialize the objects at the end. 34 | 35 | JSONAPI::Serializers was built as an intentionally simple serialization interface. It makes no assumptions about your database structure or routes and it does not provide controllers or any create/update interface to the objects. It is a library, not a framework. You will probably still need to do work to make your API fully compliant with the nuances of the [JSON:API spec](http://jsonapi.org/format/), for things like supporting `/links` routes and for supporting write actions like creating or updating objects. If you are looking for a more complete and opinionated framework, see the [jsonapi-resources](https://github.com/cerebris/jsonapi-resources) project. 36 | 37 | ## Installation 38 | 39 | Add this line to your application's Gemfile: 40 | 41 | ```ruby 42 | gem 'jsonapi-serializers' 43 | ``` 44 | 45 | Or install directly with `gem install jsonapi-serializers`. 46 | 47 | ## Usage 48 | 49 | ### Define a serializer 50 | 51 | ```ruby 52 | require 'jsonapi-serializers' 53 | 54 | class PostSerializer 55 | include JSONAPI::Serializer 56 | 57 | attribute :title 58 | attribute :content 59 | end 60 | ``` 61 | 62 | ### Serialize an object 63 | 64 | ```ruby 65 | JSONAPI::Serializer.serialize(post) 66 | ``` 67 | 68 | Returns a hash: 69 | ```json 70 | { 71 | "data": { 72 | "id": "1", 73 | "type": "posts", 74 | "attributes": { 75 | "title": "Hello World", 76 | "content": "Your first post" 77 | }, 78 | "links": { 79 | "self": "/posts/1" 80 | }, 81 | "relationships": {} 82 | } 83 | } 84 | ``` 85 | 86 | ### Serialize a collection 87 | 88 | ```ruby 89 | JSONAPI::Serializer.serialize(posts, is_collection: true) 90 | ``` 91 | 92 | Returns: 93 | 94 | ```json 95 | { 96 | "data": [ 97 | { 98 | "id": "1", 99 | "type": "posts", 100 | "attributes": { 101 | "title": "Hello World", 102 | "content": "Your first post" 103 | }, 104 | "links": { 105 | "self": "/posts/1" 106 | }, 107 | "relationships": {} 108 | }, 109 | { 110 | "id": "2", 111 | "type": "posts", 112 | "attributes": { 113 | "title": "Hello World again", 114 | "content": "Your second post" 115 | }, 116 | "links": { 117 | "self": "/posts/2" 118 | }, 119 | "relationships": {} 120 | } 121 | ] 122 | } 123 | ``` 124 | 125 | You must always pass `is_collection: true` when serializing a collection, see [Null handling](#null-handling). 126 | 127 | ### Null handling 128 | 129 | ```ruby 130 | JSONAPI::Serializer.serialize(nil) 131 | ``` 132 | 133 | Returns: 134 | ```json 135 | { 136 | "data": null 137 | } 138 | ``` 139 | 140 | And serializing an empty collection: 141 | ```ruby 142 | JSONAPI::Serializer.serialize([], is_collection: true) 143 | ``` 144 | 145 | Returns: 146 | ```json 147 | { 148 | "data": [] 149 | } 150 | ``` 151 | 152 | Note that the JSON:API spec distinguishes in how null/empty is handled for single objects vs. collections, so you must always provide `is_collection: true` when serializing multiple objects. If you attempt to serialize multiple objects without this flag (or a single object with it on) a `JSONAPI::Serializer::AmbiguousCollectionError` will be raised. 153 | 154 | ### Custom attributes 155 | 156 | By default the serializer looks for the same name of the attribute on the object it is given. You can customize this behavior by providing a block to the attribute: 157 | 158 | ```ruby 159 | attribute :content do 160 | object.body 161 | end 162 | ``` 163 | 164 | The block is evaluated within the serializer instance, so it has access to the `object` and `context` instance variables. 165 | 166 | ### More customizations 167 | 168 | Many other formatting and customizations are possible by overriding any of the following instance methods on your serializers. 169 | 170 | ```ruby 171 | # Override this to customize the JSON:API "id" for this object. 172 | # Always return a string from this method to conform with the JSON:API spec. 173 | def id 174 | object.id.to_s 175 | end 176 | ``` 177 | ```ruby 178 | # Override this to customize the JSON:API "type" for this object. 179 | # By default, the type is the object's class name lowercased, pluralized, and dasherized, 180 | # per the spec naming recommendations: http://jsonapi.org/recommendations/#naming 181 | # For example, 'MyApp::LongCommment' will become the 'long-comments' type. 182 | def type 183 | object.class.name.demodulize.tableize.dasherize 184 | end 185 | ``` 186 | ```ruby 187 | # Override this to customize how attribute names are formatted. 188 | # By default, attribute names are dasherized per the spec naming recommendations: 189 | # http://jsonapi.org/recommendations/#naming 190 | def format_name(attribute_name) 191 | attribute_name.to_s.dasherize 192 | end 193 | ``` 194 | ```ruby 195 | # The opposite of format_name. Override this if you override format_name. 196 | def unformat_name(attribute_name) 197 | attribute_name.to_s.underscore 198 | end 199 | ``` 200 | ```ruby 201 | # Override this to provide resource-object metadata. 202 | # http://jsonapi.org/format/#document-structure-resource-objects 203 | def meta 204 | end 205 | ``` 206 | ```ruby 207 | def self_link 208 | "/#{type}/#{id}" 209 | end 210 | ``` 211 | ```ruby 212 | def relationship_self_link(attribute_name) 213 | "#{self_link}/links/#{format_name(attribute_name)}" 214 | end 215 | ``` 216 | ```ruby 217 | def relationship_related_link(attribute_name) 218 | "#{self_link}/#{format_name(attribute_name)}" 219 | end 220 | ``` 221 | 222 | ## Relationships 223 | 224 | You can easily specify relationships with the `has_one` and `has_many` directives. 225 | 226 | ```ruby 227 | class BaseSerializer 228 | include JSONAPI::Serializer 229 | end 230 | 231 | class PostSerializer < BaseSerializer 232 | attribute :title 233 | attribute :content 234 | 235 | has_one :author 236 | has_many :comments 237 | end 238 | 239 | class UserSerializer < BaseSerializer 240 | attribute :name 241 | end 242 | 243 | class CommentSerializer < BaseSerializer 244 | attribute :content 245 | 246 | has_one :user 247 | end 248 | ``` 249 | 250 | Note that when serializing a post, the `author` association will come from the `author` attribute on the `Post` instance, no matter what type it is (in this case it is a `User`). This will work just fine, because JSONAPI::Serializers automatically finds serializer classes by appending `Serializer` to the object's class name. This behavior can be customized. 251 | 252 | Because the full class name is used when discovering serializers, JSONAPI::Serializers works with any custom namespaces you might have, like a Rails Engine or standard Ruby module namespace. 253 | 254 | ### Compound documents and includes 255 | 256 | > To reduce the number of HTTP requests, servers MAY allow responses that include related resources along with the requested primary resources. Such responses are called "compound documents". 257 | > [JSON:API Compound Documents](http://jsonapi.org/format/#document-structure-compound-documents) 258 | 259 | JSONAPI::Serializers supports compound documents with a simple `include` parameter. 260 | 261 | For example: 262 | 263 | ```ruby 264 | JSONAPI::Serializer.serialize(post, include: ['author', 'comments', 'comments.user']) 265 | ``` 266 | 267 | Returns: 268 | 269 | ```json 270 | { 271 | "data": { 272 | "id": "1", 273 | "type": "posts", 274 | "attributes": { 275 | "title": "Hello World", 276 | "content": "Your first post" 277 | }, 278 | "links": { 279 | "self": "/posts/1" 280 | }, 281 | "relationships": { 282 | "author": { 283 | "self": "/posts/1/links/author", 284 | "related": "/posts/1/author", 285 | "linkage": { 286 | "type": "users", 287 | "id": "1" 288 | } 289 | }, 290 | "comments": { 291 | "self": "/posts/1/links/comments", 292 | "related": "/posts/1/comments", 293 | "linkage": [ 294 | { 295 | "type": "comments", 296 | "id": "1" 297 | } 298 | ] 299 | } 300 | } 301 | }, 302 | "included": [ 303 | { 304 | "id": "1", 305 | "type": "users", 306 | "attributes": { 307 | "name": "Post Author" 308 | }, 309 | "links": { 310 | "self": "/users/1" 311 | }, 312 | "relationships": {} 313 | }, 314 | { 315 | "id": "1", 316 | "type": "comments", 317 | "attributes": { 318 | "content": "Have no fear, sers, your king is safe." 319 | }, 320 | "links": { 321 | "self": "/comments/1" 322 | }, 323 | "relationships": { 324 | "user": { 325 | "self": "/comments/1/links/user", 326 | "related": "/comments/1/user", 327 | "linkage": { 328 | "type": "users", 329 | "id": "2" 330 | } 331 | }, 332 | "post": { 333 | "self": "/comments/1/links/post", 334 | "related": "/comments/1/post" 335 | } 336 | } 337 | }, 338 | { 339 | "id": "2", 340 | "type": "users", 341 | "attributes": { 342 | "name": "Barristan Selmy" 343 | }, 344 | "links": { 345 | "self": "/users/2" 346 | }, 347 | "relationships": {} 348 | } 349 | ] 350 | } 351 | ``` 352 | 353 | Notice a few things: 354 | * The [primary data](http://jsonapi.org/format/#document-structure-top-level) relationships now include "linkage" information for each relationship that was included. 355 | * The related objects themselves are loaded in the top-level `included` member. 356 | * The related objects _also_ include "linkage" information when a deeper relationship is also present in the compound document. This is a very powerful feature of the JSON:API spec, and allows you to deeply link complicated relationships all in the same document and in a single HTTP response. JSONAPI::Serializers automatically includes the correct linkage information for whatever `include` paths you specify. This conforms to this part of the spec: 357 | 358 | > Note: Full linkage ensures that included resources are related to either the primary data (which could be resource objects or resource identifier objects) or to each other. 359 | > [JSON:API Compound Documents](http://jsonapi.org/format/#document-structure-compound-documents) 360 | 361 | #### Relationship path handling 362 | 363 | The `include` param also accepts a string of [relationship paths](http://jsonapi.org/format/#fetching-includes), ie. `include: 'author,comments,comments.user'` so you can pass an `?include` query param directly through to the serialize method. Be aware that letting users pass arbitrary relationship paths might introduce security issues depending on your authorization setup, where a user could `include` a relationship they might not be authorized to see directly. Be aware of what you allow API users to include. 364 | 365 | ## Rails example 366 | 367 | ```ruby 368 | # app/serializers/base_serializer.rb 369 | class BaseSerializer 370 | include JSONAPI::Serializer 371 | 372 | def self_link 373 | "/api/v1#{super}" 374 | end 375 | end 376 | 377 | # app/serializers/post_serializer.rb 378 | class PostSerializer < BaseSerializer 379 | attribute :title 380 | attribute :content 381 | end 382 | 383 | # app/controllers/api/v1/base_controller.rb 384 | class Api::V1::BaseController < ActionController::Base 385 | # Convenience methods for serializing models: 386 | def serialize_model(model, options = {}) 387 | options[:is_collection] = false 388 | JSONAPI::Serializer.serialize(model, options) 389 | end 390 | 391 | def serialize_models(models, options = {}) 392 | options[:is_collection] = true 393 | JSONAPI::Serializer.serialize(models, options) 394 | end 395 | end 396 | 397 | # app/controllers/api/v1/posts_controller.rb 398 | class Api::V1::ReposController < Api::V1::BaseController 399 | def index 400 | posts = Post.all 401 | render json: serialize_models(posts) 402 | end 403 | 404 | def show 405 | post = Post.find(params[:id]) 406 | render json: serialize_model(post) 407 | end 408 | end 409 | 410 | # lib/jsonapi_mimetypes.rb 411 | # Without this mimetype registration, controllers will not automatically parse JSON API params. 412 | module JSONAPI 413 | MIMETYPE = "application/vnd.api+json" 414 | end 415 | Mime::Type.register(JSONAPI::MIMETYPE, :api_json) 416 | ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime::Type.lookup(JSONAPI::MIMETYPE)] = lambda do |body| 417 | JSON.parse(body) 418 | end 419 | ``` 420 | 421 | ## Unfinished business 422 | 423 | * Support for passing `context` through to serializers is partially complete, but needs more work. 424 | * Support for a `serializer_class` attribute on objects that overrides serializer discovery, would love a PR contribution for this. 425 | * Support for the `fields` spec is planned, would love a PR contribution for this. 426 | * Support for pagination/sorting is unlikely to be supported because it would likely involve coupling to ActiveRecord, but please open an issue if you have ideas of how to support this generically. 427 | 428 | ## Contributing 429 | 430 | 1. Fork it ( https://github.com/fotinakis/jsonapi-serializers/fork ) 431 | 2. Create your feature branch (`git checkout -b my-new-feature`) 432 | 3. Commit your changes (`git commit -am 'Add some feature'`) 433 | 4. Push to the branch (`git push origin my-new-feature`) 434 | 5. Create a new Pull Request 435 | 436 | Throw a ★ on it! :) 437 | -------------------------------------------------------------------------------- /lib/jsonapi-serializers/serializer.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | require 'active_support/inflector' 3 | 4 | module JSONAPI 5 | module Serializer 6 | def self.included(target) 7 | target.send(:include, InstanceMethods) 8 | target.extend ClassMethods 9 | target.class_eval do 10 | include JSONAPI::Attributes 11 | end 12 | end 13 | 14 | module ClassMethods 15 | def serialize(object, options = {}) 16 | # Since this is being called on the class directly and not the module, override the 17 | # serializer option to be the current class. 18 | options[:serializer] = self 19 | 20 | JSONAPI::Serializer.serialize(object, options) 21 | end 22 | end 23 | 24 | module InstanceMethods 25 | attr_accessor :object 26 | attr_accessor :context 27 | 28 | def initialize(object, options = {}) 29 | @object = object 30 | @context = options[:context] || {} 31 | 32 | # Internal serializer options, not exposed through attr_accessor. No touchie. 33 | @_include_linkages = options[:include_linkages] || [] 34 | end 35 | 36 | # Override this to customize the JSON:API "id" for this object. 37 | # Always return a string from this method to conform with the JSON:API spec. 38 | def id 39 | object.id.to_s 40 | end 41 | 42 | # Override this to customize the JSON:API "type" for this object. 43 | # By default, the type is the object's class name lowercased, pluralized, and dasherized, 44 | # per the spec naming recommendations: http://jsonapi.org/recommendations/#naming 45 | # For example, 'MyApp::LongCommment' will become the 'long-comments' type. 46 | def type 47 | object.class.name.demodulize.tableize.dasherize 48 | end 49 | 50 | # Override this to customize how attribute names are formatted. 51 | # By default, attribute names are dasherized per the spec naming recommendations: 52 | # http://jsonapi.org/recommendations/#naming 53 | def format_name(attribute_name) 54 | attribute_name.to_s.dasherize 55 | end 56 | 57 | # The opposite of format_name. Override this if you override format_name. 58 | def unformat_name(attribute_name) 59 | attribute_name.to_s.underscore 60 | end 61 | 62 | # Override this to provide resource-object metadata. 63 | # http://jsonapi.org/format/#document-structure-resource-objects 64 | def meta 65 | end 66 | 67 | def self_link 68 | "/#{type}/#{id}" 69 | end 70 | 71 | def relationship_self_link(attribute_name) 72 | "#{self_link}/links/#{format_name(attribute_name)}" 73 | end 74 | 75 | def relationship_related_link(attribute_name) 76 | "#{self_link}/#{format_name(attribute_name)}" 77 | end 78 | 79 | def links 80 | data = {} 81 | data.merge!({'self' => self_link}) if self_link 82 | end 83 | 84 | def relationships 85 | data = {} 86 | # Merge in data for has_one relationships. 87 | has_one_relationships.each do |attribute_name, object| 88 | formatted_attribute_name = format_name(attribute_name) 89 | data[formatted_attribute_name] = { 90 | 'self' => relationship_self_link(attribute_name), 91 | 'related' => relationship_related_link(attribute_name), 92 | } 93 | if @_include_linkages.include?(formatted_attribute_name) 94 | if object.nil? 95 | # Spec: Resource linkage MUST be represented as one of the following: 96 | # - null for empty to-one relationships. 97 | # http://jsonapi.org/format/#document-structure-resource-relationships 98 | data[formatted_attribute_name].merge!({'linkage' => nil}) 99 | else 100 | related_object_serializer = JSONAPI::Serializer.find_serializer(object) 101 | data[formatted_attribute_name].merge!({ 102 | 'linkage' => { 103 | 'type' => related_object_serializer.type.to_s, 104 | 'id' => related_object_serializer.id.to_s, 105 | }, 106 | }) 107 | end 108 | end 109 | end 110 | 111 | # Merge in data for has_many relationships. 112 | has_many_relationships.each do |attribute_name, objects| 113 | formatted_attribute_name = format_name(attribute_name) 114 | data[formatted_attribute_name] = { 115 | 'self' => relationship_self_link(attribute_name), 116 | 'related' => relationship_related_link(attribute_name), 117 | } 118 | # Spec: Resource linkage MUST be represented as one of the following: 119 | # - an empty array ([]) for empty to-many relationships. 120 | # - an array of linkage objects for non-empty to-many relationships. 121 | # http://jsonapi.org/format/#document-structure-resource-relationships 122 | if @_include_linkages.include?(formatted_attribute_name) 123 | data[formatted_attribute_name].merge!({'linkage' => []}) 124 | objects = objects || [] 125 | objects.each do |obj| 126 | related_object_serializer = JSONAPI::Serializer.find_serializer(obj) 127 | data[formatted_attribute_name]['linkage'] << { 128 | 'type' => related_object_serializer.type.to_s, 129 | 'id' => related_object_serializer.id.to_s, 130 | } 131 | end 132 | end 133 | end 134 | data 135 | end 136 | 137 | def attributes 138 | attributes = {} 139 | self.class.attributes_map.each do |attribute_name, attr_data| 140 | next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless]) 141 | value = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block]) 142 | attributes[format_name(attribute_name)] = value 143 | end 144 | attributes 145 | end 146 | 147 | def has_one_relationships 148 | return {} if self.class.to_one_associations.nil? 149 | data = {} 150 | self.class.to_one_associations.each do |attribute_name, attr_data| 151 | next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless]) 152 | data[attribute_name] = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block]) 153 | end 154 | data 155 | end 156 | 157 | def has_many_relationships 158 | return {} if self.class.to_many_associations.nil? 159 | data = {} 160 | self.class.to_many_associations.each do |attribute_name, attr_data| 161 | next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless]) 162 | data[attribute_name] = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block]) 163 | end 164 | data 165 | end 166 | 167 | def should_include_attr?(if_method_name, unless_method_name) 168 | # Allow "if: :show_title?" and "unless: :hide_title?" attribute options. 169 | show_attr = true 170 | show_attr &&= send(if_method_name) if if_method_name 171 | show_attr &&= !send(unless_method_name) if unless_method_name 172 | show_attr 173 | end 174 | protected :should_include_attr? 175 | 176 | def evaluate_attr_or_block(attribute_name, attr_or_block) 177 | if attr_or_block.is_a?(Proc) 178 | # A custom block was given, call it to get the value. 179 | instance_eval(&attr_or_block) 180 | else 181 | # Default behavior, call a method by the name of the attribute. 182 | object.send(attr_or_block) 183 | end 184 | end 185 | protected :evaluate_attr_or_block 186 | end 187 | 188 | def self.find_serializer_class_name(object) 189 | "#{object.class.name}Serializer" 190 | end 191 | 192 | def self.find_serializer_class(object) 193 | class_name = find_serializer_class_name(object) 194 | class_name.constantize 195 | end 196 | 197 | def self.find_serializer(object) 198 | find_serializer_class(object).new(object) 199 | end 200 | 201 | def self.serialize(objects, options = {}) 202 | # Normalize option strings to symbols. 203 | options[:is_collection] = options.delete('is_collection') || options[:is_collection] || false 204 | options[:include] = options.delete('include') || options[:include] 205 | options[:serializer] = options.delete('serializer') || options[:serializer] 206 | options[:context] = options.delete('context') || options[:context] || {} 207 | 208 | # Normalize includes. 209 | includes = options[:include] 210 | includes = (includes.is_a?(String) ? includes.split(',') : includes).uniq if includes 211 | 212 | # An internal-only structure that is passed through serializers as they are created. 213 | passthrough_options = { 214 | context: options[:context], 215 | serializer: options[:serializer], 216 | include: includes 217 | } 218 | 219 | if options[:is_collection] && !objects.respond_to?(:each) 220 | raise JSONAPI::Serializer::AmbiguousCollectionError.new( 221 | 'Attempted to serialize a single object as a collection.') 222 | end 223 | 224 | # Automatically include linkage data for any relation that is also included. 225 | if includes 226 | direct_children_includes = includes.reject { |key| key.include?('.') } 227 | passthrough_options[:include_linkages] = direct_children_includes 228 | end 229 | 230 | # Spec: Primary data MUST be either: 231 | # - a single resource object or null, for requests that target single resources. 232 | # - an array of resource objects or an empty array ([]), for resource collections. 233 | # http://jsonapi.org/format/#document-structure-top-level 234 | if options[:is_collection] && !objects.any? 235 | primary_data = [] 236 | elsif !options[:is_collection] && objects.nil? 237 | primary_data = nil 238 | elsif options[:is_collection] 239 | # Have object collection. 240 | passthrough_options[:serializer] ||= find_serializer_class(objects.first) 241 | primary_data = serialize_primary_multi(objects, passthrough_options) 242 | else 243 | # Duck-typing check for a collection being passed without is_collection true. 244 | # We always must be told if serializing a collection because the JSON:API spec distinguishes 245 | # how to serialize null single resources vs. empty collections. 246 | if objects.respond_to?(:each) 247 | raise JSONAPI::Serializer::AmbiguousCollectionError.new( 248 | 'Must provide `is_collection: true` to `serialize` when serializing collections.') 249 | end 250 | # Have single object. 251 | passthrough_options[:serializer] ||= find_serializer_class(objects) 252 | primary_data = serialize_primary(objects, passthrough_options) 253 | end 254 | result = { 255 | 'data' => primary_data, 256 | } 257 | 258 | # If 'include' relationships are given, recursively find and include each object. 259 | if includes 260 | relationship_data = {} 261 | inclusion_tree = parse_relationship_paths(includes) 262 | 263 | # Given all the primary objects (either the single root object or collection of objects), 264 | # recursively search and find related associations that were specified as includes. 265 | objects = options[:is_collection] ? objects.to_a : [objects] 266 | objects.compact.each do |obj| 267 | # Use the mutability of relationship_data as the return datastructure to take advantage 268 | # of the internal special merging logic. 269 | find_recursive_relationships(obj, inclusion_tree, relationship_data) 270 | end 271 | 272 | result['included'] = relationship_data.map do |_, data| 273 | passthrough_options = {} 274 | passthrough_options[:serializer] = find_serializer_class(data[:object]) 275 | passthrough_options[:include_linkages] = data[:include_linkages] 276 | serialize_primary(data[:object], passthrough_options) 277 | end 278 | end 279 | result 280 | end 281 | 282 | def self.serialize_primary(object, options = {}) 283 | serializer_class = options.fetch(:serializer) 284 | 285 | # Spec: Primary data MUST be either: 286 | # - a single resource object or null, for requests that target single resources. 287 | # http://jsonapi.org/format/#document-structure-top-level 288 | return if object.nil? 289 | 290 | serializer = serializer_class.new(object, options) 291 | data = { 292 | 'id' => serializer.id.to_s, 293 | 'type' => serializer.type.to_s, 294 | 'attributes' => serializer.attributes, 295 | } 296 | 297 | # Merge in optional top-level members if they are non-nil. 298 | # http://jsonapi.org/format/#document-structure-resource-objects 299 | data.merge!({'attributes' => serializer.attributes}) if !serializer.attributes.nil? 300 | data.merge!({'links' => serializer.links}) if !serializer.links.nil? 301 | data.merge!({'relationships' => serializer.relationships}) if !serializer.relationships.nil? 302 | data.merge!({'meta' => serializer.meta}) if !serializer.meta.nil? 303 | data 304 | end 305 | class << self; protected :serialize_primary; end 306 | 307 | def self.serialize_primary_multi(objects, options = {}) 308 | # Spec: Primary data MUST be either: 309 | # - an array of resource objects or an empty array ([]), for resource collections. 310 | # http://jsonapi.org/format/#document-structure-top-level 311 | return [] if !objects.any? 312 | 313 | objects.map { |obj| serialize_primary(obj, options) } 314 | end 315 | class << self; protected :serialize_primary_multi; end 316 | 317 | # Recursively find object relationships and returns a tree of related objects. 318 | # Example return: 319 | # { 320 | # ['comments', '1'] => {object: , include_linkages: ['author']}, 321 | # ['users', '1'] => {object: , include_linkages: []}, 322 | # ['users', '2'] => {object: , include_linkages: []}, 323 | # } 324 | def self.find_recursive_relationships(root_object, root_inclusion_tree, results) 325 | root_inclusion_tree.each do |attribute_name, child_inclusion_tree| 326 | # Skip the sentinal value, but we need to preserve it for siblings. 327 | next if attribute_name == :_include 328 | 329 | serializer = JSONAPI::Serializer.find_serializer(root_object) 330 | unformatted_attr_name = serializer.unformat_name(attribute_name).to_sym 331 | 332 | # We know the name of this relationship, but we don't know where it is stored internally. 333 | # Check if it is a has_one or has_many relationship. 334 | object = nil 335 | is_collection = false 336 | is_valid_attr = false 337 | if serializer.has_one_relationships.has_key?(unformatted_attr_name) 338 | is_valid_attr = true 339 | object = serializer.has_one_relationships[unformatted_attr_name] 340 | elsif serializer.has_many_relationships.has_key?(unformatted_attr_name) 341 | is_valid_attr = true 342 | is_collection = true 343 | object = serializer.has_many_relationships[unformatted_attr_name] 344 | end 345 | if !is_valid_attr 346 | raise JSONAPI::Serializer::InvalidIncludeError.new( 347 | "'#{attribute_name}' is not a valid include.") 348 | end 349 | 350 | # We're finding relationships for compound documents, so skip anything that doesn't exist. 351 | next if object.nil? 352 | 353 | # We only include parent values if the sential value _include is set. This satifies the 354 | # spec note: A request for comments.author should not automatically also include comments 355 | # in the response. This can happen if the client already has the comments locally, and now 356 | # wants to fetch the associated authors without fetching the comments again. 357 | # http://jsonapi.org/format/#fetching-includes 358 | objects = is_collection ? object : [object] 359 | if child_inclusion_tree[:_include] == true 360 | # Include the current level objects if the _include attribute exists. 361 | # If it is not set, that indicates that this is an inner path and not a leaf and will 362 | # be followed by the recursion below. 363 | objects.each do |obj| 364 | obj_serializer = JSONAPI::Serializer.find_serializer(obj) 365 | # Use keys of ['posts', '1'] for the results to enforce uniqueness. 366 | # Spec: A compound document MUST NOT include more than one resource object for each 367 | # type and id pair. 368 | # http://jsonapi.org/format/#document-structure-compound-documents 369 | key = [obj_serializer.type, obj_serializer.id] 370 | 371 | # This is special: we know at this level if a child of this parent will also been 372 | # included in the compound document, so we can compute exactly what linkages should 373 | # be included by the object at this level. This satisfies this part of the spec: 374 | # 375 | # Spec: Resource linkage in a compound document allows a client to link together 376 | # all of the included resource objects without having to GET any relationship URLs. 377 | # http://jsonapi.org/format/#document-structure-resource-relationships 378 | current_child_includes = [] 379 | inclusion_names = child_inclusion_tree.keys.reject { |k| k == :_include } 380 | inclusion_names.each do |inclusion_name| 381 | if child_inclusion_tree[inclusion_name][:_include] 382 | current_child_includes << inclusion_name 383 | end 384 | end 385 | 386 | # Special merge: we might see this object multiple times in the course of recursion, 387 | # so merge the include_linkages each time we see it to load all the relevant linkages. 388 | current_child_includes += results[key] && results[key][:include_linkages] || [] 389 | current_child_includes.uniq! 390 | results[key] = {object: obj, include_linkages: current_child_includes} 391 | end 392 | end 393 | 394 | # Recurse deeper! 395 | if !child_inclusion_tree.empty? 396 | # For each object we just loaded, find all deeper recursive relationships. 397 | objects.each do |obj| 398 | find_recursive_relationships(obj, child_inclusion_tree, results) 399 | end 400 | end 401 | end 402 | nil 403 | end 404 | class << self; protected :find_recursive_relationships; end 405 | 406 | # Takes a list of relationship paths and returns a hash as deep as the given paths. 407 | # The _include: true is a sentinal value that specifies whether the parent level should 408 | # be included. 409 | # 410 | # Example: 411 | # Given: ['author', 'comments', 'comments.user'] 412 | # Returns: { 413 | # 'author' => {_include: true}, 414 | # 'comments' => {_include: true, 'user' => {_include: true}}, 415 | # } 416 | def self.parse_relationship_paths(paths) 417 | relationships = {} 418 | paths.each { |path| merge_relationship_path(path, relationships) } 419 | relationships 420 | end 421 | class << self; protected :parse_relationship_paths; end 422 | 423 | def self.merge_relationship_path(path, data) 424 | parts = path.split('.', 2) 425 | current_level = parts[0].strip 426 | data[current_level] ||= {} 427 | 428 | if parts.length == 1 429 | # Leaf node. 430 | data[current_level].merge!({_include: true}) 431 | elsif parts.length == 2 432 | # Need to recurse more. 433 | merge_relationship_path(parts[1], data[current_level]) 434 | end 435 | end 436 | class << self; protected :merge_relationship_path; end 437 | end 438 | end -------------------------------------------------------------------------------- /spec/serializer_spec.rb: -------------------------------------------------------------------------------- 1 | describe JSONAPI::Serializer do 2 | def serialize_primary(object, options = {}) 3 | # Note: intentional high-coupling to protected method for tests. 4 | JSONAPI::Serializer.send(:serialize_primary, object, options) 5 | end 6 | 7 | describe 'internal-only serialize_primary' do 8 | it 'serializes nil to nil' do 9 | # Spec: Primary data MUST be either: 10 | # - a single resource object or null, for requests that target single resources 11 | # http://jsonapi.org/format/#document-structure-top-level 12 | primary_data = serialize_primary(nil, {serializer: MyApp::PostSerializer}) 13 | expect(primary_data).to be_nil 14 | end 15 | it 'can serialize primary data for a simple object' do 16 | post = create(:post) 17 | primary_data = serialize_primary(post, {serializer: MyApp::SimplestPostSerializer}) 18 | expect(primary_data).to eq({ 19 | 'id' => '1', 20 | 'type' => 'posts', 21 | 'attributes' => { 22 | 'title' => 'Title for Post 1', 23 | 'long-content' => 'Body for Post 1', 24 | }, 25 | 'links' => { 26 | 'self' => '/posts/1', 27 | }, 28 | 'relationships' => {}, 29 | }) 30 | end 31 | it 'can serialize primary data for a simple object with a long name' do 32 | long_comment = create(:long_comment, post: create(:post)) 33 | primary_data = serialize_primary(long_comment, {serializer: MyApp::LongCommentSerializer}) 34 | expect(primary_data).to eq({ 35 | 'id' => '1', 36 | 'type' => 'long-comments', 37 | 'attributes' => { 38 | 'body' => 'Body for LongComment 1', 39 | }, 40 | 'links' => { 41 | 'self' => '/long-comments/1', 42 | }, 43 | 'relationships' => { 44 | 'user' => { 45 | 'self' => '/long-comments/1/links/user', 46 | 'related' => '/long-comments/1/user', 47 | }, 48 | 'post' => { 49 | 'self' => '/long-comments/1/links/post', 50 | 'related' => '/long-comments/1/post', 51 | }, 52 | }, 53 | }) 54 | end 55 | it 'can serialize primary data for a simple object with resource-level metadata' do 56 | post = create(:post) 57 | primary_data = serialize_primary(post, {serializer: MyApp::PostSerializerWithMetadata}) 58 | expect(primary_data).to eq({ 59 | 'id' => '1', 60 | 'type' => 'posts', 61 | 'attributes' => { 62 | 'title' => 'Title for Post 1', 63 | 'long-content' => 'Body for Post 1', 64 | }, 65 | 'links' => { 66 | 'self' => '/posts/1', 67 | }, 68 | 'relationships' => {}, 69 | 'meta' => { 70 | 'copyright' => 'Copyright 2015 Example Corp.', 71 | 'authors' => [ 72 | 'Aliens', 73 | ], 74 | }, 75 | }) 76 | end 77 | context 'without any linkage includes (default)' do 78 | it 'can serialize primary data for an object with to-one and to-many relationships' do 79 | post = create(:post) 80 | primary_data = serialize_primary(post, {serializer: MyApp::PostSerializer}) 81 | expect(primary_data).to eq({ 82 | 'id' => '1', 83 | 'type' => 'posts', 84 | 'attributes' => { 85 | 'title' => 'Title for Post 1', 86 | 'long-content' => 'Body for Post 1', 87 | }, 88 | 'links' => { 89 | 'self' => '/posts/1', 90 | }, 91 | 'relationships' => { 92 | # Both to-one and to-many links are present, but neither include linkage: 93 | 'author' => { 94 | 'self' => '/posts/1/links/author', 95 | 'related' => '/posts/1/author', 96 | }, 97 | 'long-comments' => { 98 | 'self' => '/posts/1/links/long-comments', 99 | 'related' => '/posts/1/long-comments', 100 | }, 101 | }, 102 | }) 103 | end 104 | end 105 | context 'with linkage includes' do 106 | it 'can serialize primary data for a null to-one relationship' do 107 | post = create(:post, author: nil) 108 | options = { 109 | serializer: MyApp::PostSerializer, 110 | include_linkages: ['author', 'long-comments'], 111 | } 112 | primary_data = serialize_primary(post, options) 113 | expect(primary_data).to eq({ 114 | 'id' => '1', 115 | 'type' => 'posts', 116 | 'attributes' => { 117 | 'title' => 'Title for Post 1', 118 | 'long-content' => 'Body for Post 1', 119 | }, 120 | 'links' => { 121 | 'self' => '/posts/1', 122 | }, 123 | 'relationships' => { 124 | 'author' => { 125 | 'self' => '/posts/1/links/author', 126 | 'related' => '/posts/1/author', 127 | # Spec: Resource linkage MUST be represented as one of the following: 128 | # - null for empty to-one relationships. 129 | # http://jsonapi.org/format/#document-structure-resource-relationships 130 | 'linkage' => nil, 131 | }, 132 | 'long-comments' => { 133 | 'self' => '/posts/1/links/long-comments', 134 | 'related' => '/posts/1/long-comments', 135 | 'linkage' => [], 136 | }, 137 | }, 138 | }) 139 | end 140 | it 'can serialize primary data for a simple to-one relationship' do 141 | post = create(:post, :with_author) 142 | options = { 143 | serializer: MyApp::PostSerializer, 144 | include_linkages: ['author', 'long-comments'], 145 | } 146 | primary_data = serialize_primary(post, options) 147 | expect(primary_data).to eq({ 148 | 'id' => '1', 149 | 'type' => 'posts', 150 | 'attributes' => { 151 | 'title' => 'Title for Post 1', 152 | 'long-content' => 'Body for Post 1', 153 | }, 154 | 'links' => { 155 | 'self' => '/posts/1', 156 | }, 157 | 'relationships' => { 158 | 'author' => { 159 | 'self' => '/posts/1/links/author', 160 | 'related' => '/posts/1/author', 161 | # Spec: Resource linkage MUST be represented as one of the following: 162 | # - a 'linkage object' (defined below) for non-empty to-one relationships. 163 | # http://jsonapi.org/format/#document-structure-resource-relationships 164 | 'linkage' => { 165 | 'type' => 'users', 166 | 'id' => '1', 167 | }, 168 | }, 169 | 'long-comments' => { 170 | 'self' => '/posts/1/links/long-comments', 171 | 'related' => '/posts/1/long-comments', 172 | 'linkage' => [], 173 | }, 174 | }, 175 | }) 176 | end 177 | it 'can serialize primary data for an empty to-many relationship' do 178 | post = create(:post, long_comments: []) 179 | options = { 180 | serializer: MyApp::PostSerializer, 181 | include_linkages: ['author', 'long-comments'], 182 | } 183 | primary_data = serialize_primary(post, options) 184 | expect(primary_data).to eq({ 185 | 'id' => '1', 186 | 'type' => 'posts', 187 | 'attributes' => { 188 | 'title' => 'Title for Post 1', 189 | 'long-content' => 'Body for Post 1', 190 | }, 191 | 'links' => { 192 | 'self' => '/posts/1', 193 | }, 194 | 'relationships' => { 195 | 'author' => { 196 | 'self' => '/posts/1/links/author', 197 | 'related' => '/posts/1/author', 198 | 'linkage' => nil, 199 | }, 200 | 'long-comments' => { 201 | 'self' => '/posts/1/links/long-comments', 202 | 'related' => '/posts/1/long-comments', 203 | # Spec: Resource linkage MUST be represented as one of the following: 204 | # - an empty array ([]) for empty to-many relationships. 205 | # http://jsonapi.org/format/#document-structure-resource-relationships 206 | 'linkage' => [], 207 | }, 208 | }, 209 | }) 210 | end 211 | it 'can serialize primary data for a simple to-many relationship' do 212 | long_comments = create_list(:long_comment, 2) 213 | post = create(:post, long_comments: long_comments) 214 | options = { 215 | serializer: MyApp::PostSerializer, 216 | include_linkages: ['author', 'long-comments'], 217 | } 218 | primary_data = serialize_primary(post, options) 219 | expect(primary_data).to eq({ 220 | 'id' => '1', 221 | 'type' => 'posts', 222 | 'attributes' => { 223 | 'title' => 'Title for Post 1', 224 | 'long-content' => 'Body for Post 1', 225 | }, 226 | 'links' => { 227 | 'self' => '/posts/1', 228 | }, 229 | 'relationships' => { 230 | 'author' => { 231 | 'self' => '/posts/1/links/author', 232 | 'related' => '/posts/1/author', 233 | 'linkage' => nil, 234 | }, 235 | 'long-comments' => { 236 | 'self' => '/posts/1/links/long-comments', 237 | 'related' => '/posts/1/long-comments', 238 | # Spec: Resource linkage MUST be represented as one of the following: 239 | # - an array of linkage objects for non-empty to-many relationships. 240 | # http://jsonapi.org/format/#document-structure-resource-relationships 241 | 'linkage' => [ 242 | { 243 | 'type' => 'long-comments', 244 | 'id' => '1', 245 | }, 246 | { 247 | 'type' => 'long-comments', 248 | 'id' => '2', 249 | }, 250 | ], 251 | }, 252 | }, 253 | }) 254 | end 255 | end 256 | end 257 | 258 | describe 'JSONAPI::Serializer.serialize' do 259 | # The following tests rely on the fact that serialize_primary has been tested above, so object 260 | # primary data is not explicitly tested here. If things are broken, look above here first. 261 | 262 | it 'can serialize a nil object' do 263 | expect(JSONAPI::Serializer.serialize(nil)).to eq({'data' => nil}) 264 | end 265 | it 'can serialize a nil object with includes' do 266 | # Also, the include argument is not validated in this case because we don't know the type. 267 | data = JSONAPI::Serializer.serialize(nil, include: ['fake']) 268 | expect(data).to eq({'data' => nil, 'included' => []}) 269 | end 270 | it 'can serialize an empty array' do 271 | # Also, the include argument is not validated in this case because we don't know the type. 272 | data = JSONAPI::Serializer.serialize([], is_collection: true, include: ['fake']) 273 | expect(data).to eq({'data' => [], 'included' => []}) 274 | end 275 | it 'can serialize a simple object' do 276 | post = create(:post) 277 | expect(JSONAPI::Serializer.serialize(post)).to eq({ 278 | 'data' => serialize_primary(post, {serializer: MyApp::PostSerializer}), 279 | }) 280 | end 281 | it 'can serialize a collection' do 282 | posts = create_list(:post, 2) 283 | expect(JSONAPI::Serializer.serialize(posts, is_collection: true)).to eq({ 284 | 'data' => [ 285 | serialize_primary(posts.first, {serializer: MyApp::PostSerializer}), 286 | serialize_primary(posts.last, {serializer: MyApp::PostSerializer}), 287 | ], 288 | }) 289 | end 290 | it 'raises AmbiguousCollectionError if is_collection is not passed' do 291 | posts = create_list(:post, 2) 292 | error = JSONAPI::Serializer::AmbiguousCollectionError 293 | expect { JSONAPI::Serializer.serialize(posts) }.to raise_error(error) 294 | end 295 | it 'can serialize a nil object when given serializer' do 296 | options = {serializer: MyApp::PostSerializer} 297 | expect(JSONAPI::Serializer.serialize(nil, options)).to eq({'data' => nil}) 298 | end 299 | it 'can serialize an empty array when given serializer' do 300 | options = {is_collection: true, serializer: MyApp::PostSerializer} 301 | expect(JSONAPI::Serializer.serialize([], options)).to eq({'data' => []}) 302 | end 303 | it 'can serialize a simple object when given serializer' do 304 | post = create(:post) 305 | options = {serializer: MyApp::SimplestPostSerializer} 306 | expect(JSONAPI::Serializer.serialize(post, options)).to eq({ 307 | 'data' => serialize_primary(post, {serializer: MyApp::SimplestPostSerializer}), 308 | }) 309 | end 310 | it 'handles include of nil to-one relationship with compound document' do 311 | post = create(:post) 312 | 313 | expected_primary_data = serialize_primary(post, { 314 | serializer: MyApp::PostSerializer, 315 | include_linkages: ['author'], 316 | }) 317 | expect(JSONAPI::Serializer.serialize(post, include: ['author'])).to eq({ 318 | 'data' => expected_primary_data, 319 | 'included' => [], 320 | }) 321 | end 322 | it 'handles include of simple to-one relationship with compound document' do 323 | post = create(:post, :with_author) 324 | 325 | expected_primary_data = serialize_primary(post, { 326 | serializer: MyApp::PostSerializer, 327 | include_linkages: ['author'], 328 | }) 329 | expect(JSONAPI::Serializer.serialize(post, include: ['author'])).to eq({ 330 | 'data' => expected_primary_data, 331 | 'included' => [ 332 | serialize_primary(post.author, {serializer: MyApp::UserSerializer}), 333 | ], 334 | }) 335 | end 336 | it 'handles include of empty to-many relationships with compound document' do 337 | post = create(:post, :with_author, long_comments: []) 338 | 339 | expected_primary_data = serialize_primary(post, { 340 | serializer: MyApp::PostSerializer, 341 | include_linkages: ['long-comments'], 342 | }) 343 | expect(JSONAPI::Serializer.serialize(post, include: ['long-comments'])).to eq({ 344 | 'data' => expected_primary_data, 345 | 'included' => [], 346 | }) 347 | end 348 | it 'handles include of to-many relationships with compound document' do 349 | long_comments = create_list(:long_comment, 2) 350 | post = create(:post, :with_author, long_comments: long_comments) 351 | 352 | expected_primary_data = serialize_primary(post, { 353 | serializer: MyApp::PostSerializer, 354 | include_linkages: ['long-comments'], 355 | }) 356 | expect(JSONAPI::Serializer.serialize(post, include: ['long-comments'])).to eq({ 357 | 'data' => expected_primary_data, 358 | 'included' => [ 359 | serialize_primary(long_comments.first, {serializer: MyApp::LongCommentSerializer}), 360 | serialize_primary(long_comments.last, {serializer: MyApp::LongCommentSerializer}), 361 | ], 362 | }) 363 | end 364 | it 'only includes one copy of each referenced relationship' do 365 | long_comment = create(:long_comment) 366 | long_comments = [long_comment, long_comment] 367 | post = create(:post, :with_author, long_comments: long_comments) 368 | 369 | expected_primary_data = serialize_primary(post, { 370 | serializer: MyApp::PostSerializer, 371 | include_linkages: ['long-comments'], 372 | }) 373 | expect(JSONAPI::Serializer.serialize(post, include: ['long-comments'])).to eq({ 374 | 'data' => expected_primary_data, 375 | 'included' => [ 376 | serialize_primary(long_comment, {serializer: MyApp::LongCommentSerializer}), 377 | ], 378 | }) 379 | end 380 | it 'handles circular-referencing relationships with compound document' do 381 | long_comments = create_list(:long_comment, 2) 382 | post = create(:post, :with_author, long_comments: long_comments) 383 | 384 | # Make sure each long-comment has a circular reference back to the post. 385 | long_comments.each { |c| c.post = post } 386 | 387 | expected_primary_data = serialize_primary(post, { 388 | serializer: MyApp::PostSerializer, 389 | include_linkages: ['long-comments'], 390 | }) 391 | expect(JSONAPI::Serializer.serialize(post, include: ['long-comments'])).to eq({ 392 | 'data' => expected_primary_data, 393 | 'included' => [ 394 | serialize_primary(post.long_comments.first, {serializer: MyApp::LongCommentSerializer}), 395 | serialize_primary(post.long_comments.last, {serializer: MyApp::LongCommentSerializer}), 396 | ], 397 | }) 398 | end 399 | it 'errors if include is not a defined attribute' do 400 | user = create(:user) 401 | expect { JSONAPI::Serializer.serialize(user, include: ['fake-attr']) }.to raise_error 402 | end 403 | it 'handles recursive loading of relationships' do 404 | user = create(:user) 405 | long_comments = create_list(:long_comment, 2, user: user) 406 | post = create(:post, :with_author, long_comments: long_comments) 407 | # Make sure each long-comment has a circular reference back to the post. 408 | long_comments.each { |c| c.post = post } 409 | 410 | expected_data = { 411 | # Note that in this case the primary data does not include linkage for 'long-comments', 412 | # forcing clients to still have to request linkage from long-comments and post. This is an 413 | # odd but valid data state because the user requested to only include the leaf author node, 414 | # and we only automatically expose direct children linkages if they match given includes. 415 | # 416 | # Spec: Resource linkage in a compound document allows a client to link together 417 | # all of the included resource objects without having to GET any relationship URLs. 418 | # http://jsonapi.org/format/#document-structure-resource-relationships 419 | # 420 | # Also, spec: A request for comments.author should not automatically also include 421 | # comments in the response. This can happen if the client already has the comments locally, 422 | # and now wants to fetch the associated authors without fetching the comments again. 423 | # http://jsonapi.org/format/#fetching-includes 424 | 'data' => serialize_primary(post, {serializer: MyApp::PostSerializer}), 425 | 'included' => [ 426 | # Only the author is included: 427 | serialize_primary(post.author, {serializer: MyApp::UserSerializer}), 428 | ], 429 | } 430 | includes = ['long-comments.post.author'] 431 | actual_data = JSONAPI::Serializer.serialize(post, include: includes) 432 | # Multiple expectations for better diff output for debugging. 433 | expect(actual_data['data']).to eq(expected_data['data']) 434 | expect(actual_data['included']).to eq(expected_data['included']) 435 | expect(actual_data).to eq(expected_data) 436 | end 437 | it 'handles recursive loading of multiple to-one relationships on children' do 438 | first_user = create(:user) 439 | second_user = create(:user) 440 | first_comment = create(:long_comment, user: first_user) 441 | second_comment = create(:long_comment, user: second_user) 442 | long_comments = [first_comment, second_comment] 443 | post = create(:post, :with_author, long_comments: long_comments) 444 | # Make sure each long-comment has a circular reference back to the post. 445 | long_comments.each { |c| c.post = post } 446 | 447 | expected_data = { 448 | # Same note about primary data linkages as above. 449 | 'data' => serialize_primary(post, {serializer: MyApp::PostSerializer}), 450 | 'included' => [ 451 | serialize_primary(first_user, {serializer: MyApp::UserSerializer}), 452 | serialize_primary(second_user, {serializer: MyApp::UserSerializer}), 453 | ], 454 | } 455 | includes = ['long-comments.user'] 456 | actual_data = JSONAPI::Serializer.serialize(post, include: includes) 457 | 458 | # Multiple expectations for better diff output for debugging. 459 | expect(actual_data['data']).to eq(expected_data['data']) 460 | expect(actual_data['included']).to eq(expected_data['included']) 461 | expect(actual_data).to eq(expected_data) 462 | end 463 | it 'includes linkage in compounded resources only if the immediate parent was also included' do 464 | comment_user = create(:user) 465 | long_comments = [create(:long_comment, user: comment_user)] 466 | post = create(:post, :with_author, long_comments: long_comments) 467 | 468 | expected_primary_data = serialize_primary(post, { 469 | serializer: MyApp::PostSerializer, 470 | include_linkages: ['long-comments'], 471 | }) 472 | expected_data = { 473 | 'data' => expected_primary_data, 474 | 'included' => [ 475 | serialize_primary(long_comments.first, { 476 | serializer: MyApp::LongCommentSerializer, 477 | include_linkages: ['user'], 478 | }), 479 | # Note: post.author does not show up here because it was not included. 480 | serialize_primary(comment_user, {serializer: MyApp::UserSerializer}), 481 | ], 482 | } 483 | includes = ['long-comments', 'long-comments.user'] 484 | actual_data = JSONAPI::Serializer.serialize(post, include: includes) 485 | 486 | # Multiple expectations for better diff output for debugging. 487 | expect(actual_data['data']).to eq(expected_data['data']) 488 | expect(actual_data['included']).to eq(expected_data['included']) 489 | expect(actual_data).to eq(expected_data) 490 | end 491 | it 'handles recursive loading of to-many relationships with overlapping include paths' do 492 | user = create(:user) 493 | long_comments = create_list(:long_comment, 2, user: user) 494 | post = create(:post, :with_author, long_comments: long_comments) 495 | # Make sure each long-comment has a circular reference back to the post. 496 | long_comments.each { |c| c.post = post } 497 | 498 | expected_primary_data = serialize_primary(post, { 499 | serializer: MyApp::PostSerializer, 500 | include_linkages: ['long-comments'], 501 | }) 502 | expected_data = { 503 | 'data' => expected_primary_data, 504 | 'included' => [ 505 | serialize_primary(long_comments.first, {serializer: MyApp::LongCommentSerializer}), 506 | serialize_primary(long_comments.last, {serializer: MyApp::LongCommentSerializer}), 507 | serialize_primary(post.author, {serializer: MyApp::UserSerializer}), 508 | ], 509 | } 510 | # Also test that it handles string include arguments. 511 | includes = 'long-comments, long-comments.post.author' 512 | actual_data = JSONAPI::Serializer.serialize(post, include: includes) 513 | 514 | # Multiple expectations for better diff output for debugging. 515 | expect(actual_data['data']).to eq(expected_data['data']) 516 | expect(actual_data['included']).to eq(expected_data['included']) 517 | expect(actual_data).to eq(expected_data) 518 | end 519 | 520 | context 'on collection' do 521 | it 'handles include of has_many relationships with compound document' do 522 | long_comments = create_list(:long_comment, 2) 523 | posts = create_list(:post, 2, :with_author, long_comments: long_comments) 524 | 525 | expected_primary_data = JSONAPI::Serializer.send(:serialize_primary_multi, posts, { 526 | serializer: MyApp::PostSerializer, 527 | include_linkages: ['long-comments'], 528 | }) 529 | data = JSONAPI::Serializer.serialize(posts, is_collection: true, include: ['long-comments']) 530 | expect(data).to eq({ 531 | 'data' => expected_primary_data, 532 | 'included' => [ 533 | serialize_primary(long_comments.first, {serializer: MyApp::LongCommentSerializer}), 534 | serialize_primary(long_comments.last, {serializer: MyApp::LongCommentSerializer}), 535 | ], 536 | }) 537 | end 538 | end 539 | end 540 | 541 | describe 'serialize (class method)' do 542 | it 'delegates to module method but overrides serializer' do 543 | post = create(:post) 544 | expect(MyApp::SimplestPostSerializer.serialize(post)).to eq({ 545 | 'data' => serialize_primary(post, {serializer: MyApp::SimplestPostSerializer}), 546 | }) 547 | end 548 | end 549 | 550 | describe 'internal-only parse_relationship_paths' do 551 | it 'correctly handles empty arrays' do 552 | result = JSONAPI::Serializer.send(:parse_relationship_paths, []) 553 | expect(result).to eq({}) 554 | end 555 | it 'correctly handles single-level relationship paths' do 556 | result = JSONAPI::Serializer.send(:parse_relationship_paths, ['foo']) 557 | expect(result).to eq({ 558 | 'foo' => {_include: true} 559 | }) 560 | end 561 | it 'correctly handles multi-level relationship paths' do 562 | result = JSONAPI::Serializer.send(:parse_relationship_paths, ['foo.bar']) 563 | expect(result).to eq({ 564 | 'foo' => {'bar' => {_include: true}} 565 | }) 566 | end 567 | it 'correctly handles multi-level relationship paths with same parent' do 568 | paths = ['foo', 'foo.bar'] 569 | result = JSONAPI::Serializer.send(:parse_relationship_paths, paths) 570 | expect(result).to eq({ 571 | 'foo' => {_include: true, 'bar' => {_include: true}} 572 | }) 573 | end 574 | it 'correctly handles multi-level relationship paths with different parent' do 575 | paths = ['foo', 'bar', 'bar.baz'] 576 | result = JSONAPI::Serializer.send(:parse_relationship_paths, paths) 577 | expect(result).to eq({ 578 | 'foo' => {_include: true}, 579 | 'bar' => {_include: true, 'baz' => {_include: true}}, 580 | }) 581 | end 582 | it 'correctly handles three-leveled path' do 583 | paths = ['foo', 'foo.bar', 'foo.bar.baz'] 584 | result = JSONAPI::Serializer.send(:parse_relationship_paths, paths) 585 | expect(result).to eq({ 586 | 'foo' => {_include: true, 'bar' => {_include: true, 'baz' => {_include: true}}} 587 | }) 588 | end 589 | it 'correctly handles three-leveled path with skipped middle' do 590 | paths = ['foo', 'foo.bar.baz'] 591 | result = JSONAPI::Serializer.send(:parse_relationship_paths, paths) 592 | expect(result).to eq({ 593 | 'foo' => {_include: true, 'bar' => {'baz' => {_include: true}}} 594 | }) 595 | end 596 | end 597 | describe 'if/unless handling with contexts' do 598 | it 'can be used to show/hide attributes' do 599 | post = create(:post) 600 | options = {serializer: MyApp::PostSerializerWithContextHandling} 601 | 602 | options[:context] = {show_body: false} 603 | data = JSONAPI::Serializer.serialize(post, options) 604 | expect(data['data']['attributes']).to_not have_key('body') 605 | 606 | options[:context] = {show_body: true} 607 | data = JSONAPI::Serializer.serialize(post, options) 608 | expect(data['data']['attributes']).to have_key('body') 609 | expect(data['data']['attributes']['body']).to eq('Body for Post 1') 610 | 611 | options[:context] = {hide_body: true} 612 | data = JSONAPI::Serializer.serialize(post, options) 613 | expect(data['data']['attributes']).to_not have_key('body') 614 | 615 | options[:context] = {hide_body: false} 616 | data = JSONAPI::Serializer.serialize(post, options) 617 | expect(data['data']['attributes']).to have_key('body') 618 | expect(data['data']['attributes']['body']).to eq('Body for Post 1') 619 | 620 | options[:context] = {show_body: false, hide_body: false} 621 | data = JSONAPI::Serializer.serialize(post, options) 622 | expect(data['data']['attributes']).to_not have_key('body') 623 | 624 | options[:context] = {show_body: true, hide_body: false} 625 | data = JSONAPI::Serializer.serialize(post, options) 626 | expect(data['data']['attributes']).to have_key('body') 627 | expect(data['data']['attributes']['body']).to eq('Body for Post 1') 628 | 629 | # Remember: attribute is configured as if: show_body?, unless: hide_body? 630 | # and the results should be logically AND'd together: 631 | options[:context] = {show_body: false, hide_body: true} 632 | data = JSONAPI::Serializer.serialize(post, options) 633 | expect(data['data']['attributes']).to_not have_key('body') 634 | 635 | options[:context] = {show_body: true, hide_body: true} 636 | data = JSONAPI::Serializer.serialize(post, options) 637 | expect(data['data']['attributes']).to_not have_key('body') 638 | end 639 | end 640 | describe 'context' do 641 | xit 'is correctly passed through all serializers' do 642 | end 643 | end 644 | end --------------------------------------------------------------------------------