diff --git a/.gitignore b/.gitignore index 838a74e06..1ecf6e4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ test/tmp test/version_tmp tmp *.swp -.ruby-version \ No newline at end of file +.ruby-version diff --git a/README.md b/README.md index 77f1a1087..4121dfe2c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# ActiveModel::Serializers - +# ActiveModel::Serializers + [![Build Status](https://travis-ci.org/rails-api/active_model_serializers.svg)](https://travis-ci.org/rails-api/active_model_serializers) -ActiveModel::Serializers brings convention over configuration to your JSON generation. +ActiveModel::Serializers brings convention over configuration to your JSON generation. AMS does this through two components: **serializers** and **adapters**. Serializers describe which attributes and relationships should be serialized. Adapters describe how attributes and relationships should be serialized. @@ -32,7 +32,7 @@ serializers: ```ruby class PostSerializer < ActiveModel::Serializer attributes :title, :body - + has_many :comments url :post @@ -61,7 +61,7 @@ ActiveModel::Serializer.config.adapter = ActiveModel::Serializer::Adapter::HalAd ``` or - + ```ruby ActiveModel::Serializer.config.adapter = :hal ``` @@ -85,18 +85,27 @@ end In this case, Rails will look for a serializer named `PostSerializer`, and if it exists, use it to serialize the `Post`. -## Installation - -Add this line to your application's Gemfile: +### Built in Adapters + +The `:json_api` adapter will include the associated resources in the `"linked"` +member when the resource names are included in the `include` option. -``` -gem 'active_model_serializers' +```ruby + render @posts, include: 'authors,comments' ``` - -And then execute: -``` -$ bundle +## Installation + +Add this line to your application's Gemfile: + +``` +gem 'active_model_serializers' +``` + +And then execute: + +``` +$ bundle ``` ## Creating a Serializer @@ -141,29 +150,27 @@ class CommentSerializer < ActiveModel::Serializer end ``` -The attribute names are a **whitelist** of attributes to be serialized. - +The attribute names are a **whitelist** of attributes to be serialized. + The `has_many` and `belongs_to` declarations describe relationships between -resources. By default, when you serialize a `Post`, you will -get its `Comment`s as well. +resources. By default, when you serialize a `Post`, you will get its `Comment`s +as well. The `url` declaration describes which named routes to use while generating URLs for your JSON. Not every adapter will require URLs. ## Getting Help -If you find a bug, please report an -[Issue](https://github.com/rails-api/active_model_serializers/issues/new). +If you find a bug, please report an [Issue](https://github.com/rails-api/active_model_serializers/issues/new). -If you have a question, please [post to Stack -Overflow](http://stackoverflow.com/questions/tagged/active-model-serializers). +If you have a question, please [post to Stack Overflow](http://stackoverflow.com/questions/tagged/active-model-serializers). Thanks! - -## Contributing - -1. Fork it ( https://github.com/rails-api/active_model_serializers/fork ) -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request + +## Contributing + +1. Fork it ( https://github.com/rails-api/active_model_serializers/fork ) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request diff --git a/lib/action_controller/serialization.rb b/lib/action_controller/serialization.rb index f26f01124..2b460cb4f 100644 --- a/lib/action_controller/serialization.rb +++ b/lib/action_controller/serialization.rb @@ -6,15 +6,18 @@ module Serialization include ActionController::Renderers + ADAPTER_OPTION_KEYS = [:include, :root] + [:_render_option_json, :_render_with_renderer_json].each do |renderer_method| define_method renderer_method do |resource, options| serializer = ActiveModel::Serializer.serializer_for(resource) if serializer + adapter_opts, serializer_opts = + options.partition { |k, _| ADAPTER_OPTION_KEYS.include? k } # omg hax - object = serializer.new(resource, options) - adapter = ActiveModel::Serializer.adapter.new(object) - + object = serializer.new(resource, Hash[serializer_opts]) + adapter = ActiveModel::Serializer.adapter.new(object, Hash[adapter_opts]) super(adapter, options) else super(resource, options) @@ -23,4 +26,3 @@ module Serialization end end end - diff --git a/lib/active_model/serializer/adapter.rb b/lib/active_model/serializer/adapter.rb index f0a82573f..864adbb49 100644 --- a/lib/active_model/serializer/adapter.rb +++ b/lib/active_model/serializer/adapter.rb @@ -10,6 +10,7 @@ class Adapter def initialize(serializer, options = {}) @serializer = serializer + @options = options end def serializable_hash(options = {}) diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb index 596d54c0c..693ec316b 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -5,16 +5,19 @@ class JsonApi < Adapter def initialize(serializer, options = {}) super serializer.root = true + @hash = {} + @top = @options.fetch(:top) { @hash } end def serializable_hash(options = {}) - @root = (options[:root] || serializer.json_key.to_s.pluralize).to_sym - @hash = {} + @root = (@options[:root] || serializer.json_key.to_s.pluralize).to_sym if serializer.respond_to?(:each) - @hash[@root] = serializer.map{|s| self.class.new(s).serializable_hash[@root] } + @hash[@root] = serializer.map do |s| + self.class.new(s, @options.merge(top: @top)).serializable_hash[@root] + end else - @hash[@root] = attributes_for_serializer(serializer, {}) + @hash[@root] = attributes_for_serializer(serializer, @options) serializer.each_association do |name, association, opts| @hash[@root][:links] ||= {} @@ -44,10 +47,10 @@ def add_links(name, serializers, options) @hash[@root][:links][name][:ids] += serializers.map{|serializer| serializer.id.to_s } end - unless options[:embed] == :ids || serializers.count == 0 - @hash[:linked] ||= {} - @hash[:linked][name] ||= [] - @hash[:linked][name] += serializers.map { |item| attributes_for_serializer(item, options) } + unless serializers.none? || @options[:embed] == :ids + serializers.each do |serializer| + add_linked(name, serializer) + end end end @@ -62,17 +65,31 @@ def add_link(name, serializer, options) @hash[@root][:links][name][:id] = serializer.id.to_s end - unless options[:embed] == :ids - plural_name = name.to_s.pluralize.to_sym - @hash[:linked] ||= {} - @hash[:linked][plural_name] ||= [] - @hash[:linked][plural_name].push attributes_for_serializer(serializer, options) + unless @options[:embed] == :ids + add_linked(name, serializer) end else @hash[@root][:links][name] = nil end end + def add_linked(resource, serializer, parent = nil) + resource_path = [parent, resource].compact.join('.') + if include_assoc? resource_path + plural_name = resource.to_s.pluralize.to_sym + attrs = attributes_for_serializer(serializer, @options) + @top[:linked] ||= {} + @top[:linked][plural_name] ||= [] + @top[:linked][plural_name].push attrs unless @top[:linked][plural_name].include? attrs + end + + unless serializer.respond_to?(:each) + serializer.each_association do |name, association, opts| + add_linked(name, association, resource) if association + end + end + end + private def attributes_for_serializer(serializer, options) @@ -80,6 +97,10 @@ def attributes_for_serializer(serializer, options) attributes[:id] = attributes[:id].to_s if attributes[:id] attributes end + + def include_assoc? assoc + @options[:include] && @options[:include].split(',').include?(assoc.to_s) + end end end end diff --git a/test/action_controller/json_api_linked_test.rb b/test/action_controller/json_api_linked_test.rb new file mode 100644 index 000000000..f02e90e2c --- /dev/null +++ b/test/action_controller/json_api_linked_test.rb @@ -0,0 +1,104 @@ +require 'test_helper' + +module ActionController + module Serialization + class JsonApiLinkedTest < ActionController::TestCase + class MyController < ActionController::Base + def setup_post + @author = Author.new(id: 1, name: 'Steve K.') + @author.posts = [] + @author2 = Author.new(id: 2, name: 'Anonymous') + @author2.posts = [] + @post = Post.new(id: 1, title: 'New Post', body: 'Body') + @first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') + @second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT') + @post.comments = [@first_comment, @second_comment] + @post.author = @author + @first_comment.post = @post + @first_comment.author = @author2 + @second_comment.post = @post + @second_comment.author = nil + end + + def with_json_api_adapter + old_adapter = ActiveModel::Serializer.config.adapter + ActiveModel::Serializer.config.adapter = :json_api + yield + ensure + ActiveModel::Serializer.config.adapter = old_adapter + end + + def render_resource_without_include + with_json_api_adapter do + setup_post + render json: @post + end + end + + def render_resource_with_include + with_json_api_adapter do + setup_post + render json: @post, include: 'author' + end + end + + def render_resource_with_nested_include + with_json_api_adapter do + setup_post + render json: @post, include: 'comments.author' + end + end + + def render_collection_without_include + with_json_api_adapter do + setup_post + render json: [@post] + end + end + + def render_collection_with_include + with_json_api_adapter do + setup_post + render json: [@post], include: 'author,comments' + end + end + end + + tests MyController + + def test_render_resource_without_include + get :render_resource_without_include + response = JSON.parse(@response.body) + refute response.key? 'linked' + end + + def test_render_resource_with_include + get :render_resource_with_include + response = JSON.parse(@response.body) + assert response.key? 'linked' + assert_equal 1, response['linked']['authors'].size + assert_equal 'Steve K.', response['linked']['authors'].first['name'] + end + + def test_render_resource_with_nested_include + get :render_resource_with_nested_include + response = JSON.parse(@response.body) + assert response.key? 'linked' + assert_equal 1, response['linked']['authors'].size + assert_equal 'Anonymous', response['linked']['authors'].first['name'] + end + + def test_render_collection_without_include + get :render_collection_without_include + response = JSON.parse(@response.body) + refute response.key? 'linked' + end + + def test_render_collection_with_include + get :render_collection_with_include + response = JSON.parse(@response.body) + assert response.key? 'linked' + end + end + end +end diff --git a/test/adapter/json/belongs_to_test.rb b/test/adapter/json/belongs_to_test.rb index 663907dee..ea1186475 100644 --- a/test/adapter/json/belongs_to_test.rb +++ b/test/adapter/json/belongs_to_test.rb @@ -11,8 +11,9 @@ def setup @comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') @post.comments = [@comment] @anonymous_post.comments = [] - @comment.post = @post @post.author = @author + @comment.post = @post + @comment.author = nil @anonymous_post.author = nil @serializer = CommentSerializer.new(@comment) diff --git a/test/adapter/json_api/belongs_to_test.rb b/test/adapter/json_api/belongs_to_test.rb index 0db303a68..e25a71d87 100644 --- a/test/adapter/json_api/belongs_to_test.rb +++ b/test/adapter/json_api/belongs_to_test.rb @@ -13,11 +13,13 @@ def setup @post.comments = [@comment] @anonymous_post.comments = [] @comment.post = @post + @comment.author = nil @post.author = @author @anonymous_post.author = nil @blog = Blog.new(id: 1, name: "My Blog!!") @blog.writer = @author @blog.articles = [@post, @anonymous_post] + @author.posts = [] @serializer = CommentSerializer.new(@comment) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer) @@ -28,6 +30,7 @@ def test_includes_post_id end def test_includes_linked_post + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'post') assert_equal([{id: "42", title: 'New Post', body: 'Body'}], @adapter.serializable_hash[:linked][:posts]) end diff --git a/test/adapter/json_api/collection_test.rb b/test/adapter/json_api/collection_test.rb index 5eaec7a55..922103ea2 100644 --- a/test/adapter/json_api/collection_test.rb +++ b/test/adapter/json_api/collection_test.rb @@ -4,7 +4,7 @@ module ActiveModel class Serializer class Adapter class JsonApi - class Collection < Minitest::Test + class CollectionTest < Minitest::Test def setup @author = Author.new(id: 1, name: 'Steve K.') @first_post = Post.new(id: 1, title: 'Hello!!', body: 'Hello, world!!') @@ -13,6 +13,7 @@ def setup @second_post.comments = [] @first_post.author = @author @second_post.author = @author + @author.posts = [@first_post, @second_post] @serializer = ArraySerializer.new([@first_post, @second_post]) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer) @@ -20,8 +21,8 @@ def setup def test_include_multiple_posts assert_equal([ - {title: "Hello!!", body: "Hello, world!!", id: "1", links: {comments: [], author: "1"}}, - {title: "New Post", body: "Body", id: "2", links: {comments: [], author: "1"}} + { title: "Hello!!", body: "Hello, world!!", id: "1", links: { comments: [], author: "1" } }, + { title: "New Post", body: "Body", id: "2", links: { comments: [], author: "1" } } ], @adapter.serializable_hash[:posts]) end end diff --git a/test/adapter/json_api/has_many_embed_ids_test.rb b/test/adapter/json_api/has_many_embed_ids_test.rb index d5c448b51..4690e3c66 100644 --- a/test/adapter/json_api/has_many_embed_ids_test.rb +++ b/test/adapter/json_api/has_many_embed_ids_test.rb @@ -12,6 +12,8 @@ def setup @author.posts = [@first_post, @second_post] @first_post.author = @author @second_post.author = @author + @first_post.comments = [] + @second_post.comments = [] @serializer = AuthorSerializer.new(@author) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer) diff --git a/test/adapter/json_api/has_many_test.rb b/test/adapter/json_api/has_many_test.rb index 6bcd43ca6..975c5b63d 100644 --- a/test/adapter/json_api/has_many_test.rb +++ b/test/adapter/json_api/has_many_test.rb @@ -7,10 +7,13 @@ class JsonApi class HasManyTest < Minitest::Test def setup @author = Author.new(id: 1, name: 'Steve K.') + @author.posts = [] @post = Post.new(id: 1, title: 'New Post', body: 'Body') @post_without_comments = Post.new(id: 2, title: 'Second Post', body: 'Second') @first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') + @first_comment.author = nil @second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT') + @second_comment.author = nil @post.comments = [@first_comment, @second_comment] @post_without_comments.comments = [] @first_comment.post = @post @@ -30,6 +33,7 @@ def test_includes_comment_ids end def test_includes_linked_comments + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'comments') assert_equal([ {id: "1", body: 'ZOMG A COMMENT'}, {id: "2", body: 'ZOMG ANOTHER COMMENT'} diff --git a/test/adapter/json_api/linked_test.rb b/test/adapter/json_api/linked_test.rb new file mode 100644 index 000000000..160d3daae --- /dev/null +++ b/test/adapter/json_api/linked_test.rb @@ -0,0 +1,40 @@ +require 'test_helper' + +module ActiveModel + class Serializer + class Adapter + class JsonApi + class LinkedTest < Minitest::Test + def setup + @author = Author.new(id: 1, name: 'Steve K.') + @first_post = Post.new(id: 1, title: 'Hello!!', body: 'Hello, world!!') + @second_post = Post.new(id: 2, title: 'New Post', body: 'Body') + @first_post.comments = [] + @second_post.comments = [] + @first_post.author = @author + @second_post.author = @author + @author.posts = [@first_post, @second_post] + + @serializer = ArraySerializer.new([@first_post, @second_post]) + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,comments') + end + + def test_include_multiple_posts_and_linked + @first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') + @second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT') + @first_post.comments = [@first_comment, @second_comment] + @first_comment.post = @first_post + @first_comment.author = nil + @second_comment.post = @first_post + @second_comment.author = nil + assert_equal([ + { title: "Hello!!", body: "Hello, world!!", id: "1", links: { comments: ['1', '2'], author: "1" } }, + { title: "New Post", body: "Body", id: "2", links: { comments: [], :author => "1" } } + ], @adapter.serializable_hash[:posts]) + assert_equal({ :comments => [{ :id => "1", :body => "ZOMG A COMMENT" }, { :id => "2", :body => "ZOMG ANOTHER COMMENT" }], :authors => [{ :id => "1", :name => "Steve K." }] }, @adapter.serializable_hash[:linked]) + end + end + end + end + end +end diff --git a/test/fixtures/poro.rb b/test/fixtures/poro.rb index 77e3506fb..7d8d57b42 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -52,6 +52,7 @@ class ProfileSerializer < ActiveModel::Serializer attributes :id, :body belongs_to :post + belongs_to :author end AuthorSerializer = Class.new(ActiveModel::Serializer) do diff --git a/test/serializers/associations_test.rb b/test/serializers/associations_test.rb index 0891f3658..99162acc8 100644 --- a/test/serializers/associations_test.rb +++ b/test/serializers/associations_test.rb @@ -30,6 +30,7 @@ def setup @comment = Comment.new({ id: 1, body: 'ZOMG A COMMENT' }) @post.comments = [@comment] @comment.post = @post + @comment.author = nil @post.author = @author @author.posts = [@post] @@ -47,11 +48,17 @@ def test_has_many end def test_has_one - assert_equal({post: {type: :belongs_to, options: {}}}, @comment_serializer.class._associations) + assert_equal({post: {type: :belongs_to, options: {}}, :author=>{:type=>:belongs_to, :options=>{}}}, @comment_serializer.class._associations) @comment_serializer.each_association do |name, serializer, options| - assert_equal(:post, name) - assert_equal({}, options) - assert_kind_of(PostSerializer, serializer) + if name == :post + assert_equal({}, options) + assert_kind_of(PostSerializer, serializer) + elsif name == :author + assert_equal({}, options) + assert_nil serializer + else + flunk "Unknown association: #{name}" + end end end end