From d5bae0c2f0a1e5709b42bca4015617938eb72624 Mon Sep 17 00:00:00 2001 From: Gary Gordon Date: Thu, 23 Oct 2014 11:46:51 -0400 Subject: [PATCH] Include 'linked' member for json-api collections The options passed to the render are partitioned into adapter options and serializer options. 'include' and 'root' are sent to the adapter, not sure what options would go directly to serializer, but leaving this in until I understand that better. --- .gitignore | 2 +- README.md | 67 ++++++----- lib/action_controller/serialization.rb | 10 +- lib/active_model/serializer/adapter.rb | 1 + .../serializer/adapter/json_api.rb | 47 +++++--- .../action_controller/json_api_linked_test.rb | 104 ++++++++++++++++++ test/adapter/json/belongs_to_test.rb | 3 +- test/adapter/json_api/belongs_to_test.rb | 3 + test/adapter/json_api/collection_test.rb | 7 +- .../json_api/has_many_embed_ids_test.rb | 2 + test/adapter/json_api/has_many_test.rb | 4 + test/adapter/json_api/linked_test.rb | 40 +++++++ test/fixtures/poro.rb | 1 + test/serializers/associations_test.rb | 15 ++- 14 files changed, 250 insertions(+), 56 deletions(-) create mode 100644 test/action_controller/json_api_linked_test.rb create mode 100644 test/adapter/json_api/linked_test.rb diff --git a/.gitignore b/.gitignore index 838a74e0..1ecf6e4d 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 77f1a108..4121dfe2 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 -``` -gem 'active_model_serializers' +The `:json_api` adapter will include the associated resources in the `"linked"` +member when the resource names are included in the `include` option. + +```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 f26f0112..2b460cb4 100644 --- a/lib/action_controller/serialization.rb +++ b/lib/action_controller/serialization.rb @@ -6,15 +6,18 @@ module ActionController 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 ActionController end end end - diff --git a/lib/active_model/serializer/adapter.rb b/lib/active_model/serializer/adapter.rb index f0a82573..864adbb4 100644 --- a/lib/active_model/serializer/adapter.rb +++ b/lib/active_model/serializer/adapter.rb @@ -10,6 +10,7 @@ module ActiveModel 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 596d54c0..693ec316 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -5,16 +5,19 @@ module ActiveModel 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 @@ module ActiveModel @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 @@ module ActiveModel @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 @@ module ActiveModel 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 00000000..f02e90e2 --- /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 663907de..ea118647 100644 --- a/test/adapter/json/belongs_to_test.rb +++ b/test/adapter/json/belongs_to_test.rb @@ -11,8 +11,9 @@ module ActiveModel @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 0db303a6..e25a71d8 100644 --- a/test/adapter/json_api/belongs_to_test.rb +++ b/test/adapter/json_api/belongs_to_test.rb @@ -13,11 +13,13 @@ module ActiveModel @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 @@ module ActiveModel 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 5eaec7a5..922103ea 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 @@ module ActiveModel @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 @@ module ActiveModel 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 d5c448b5..4690e3c6 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 @@ module ActiveModel @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 6bcd43ca..975c5b63 100644 --- a/test/adapter/json_api/has_many_test.rb +++ b/test/adapter/json_api/has_many_test.rb @@ -7,10 +7,13 @@ module ActiveModel 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 @@ module ActiveModel 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 00000000..160d3daa --- /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 77e3506f..7d8d57b4 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -52,6 +52,7 @@ CommentSerializer = Class.new(ActiveModel::Serializer) do 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 0891f365..99162acc 100644 --- a/test/serializers/associations_test.rb +++ b/test/serializers/associations_test.rb @@ -30,6 +30,7 @@ module ActiveModel @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 @@ module ActiveModel 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