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 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