Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RC3 Updates for JSON API #853

Merged
merged 13 commits into from
Mar 24, 2015
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

* adds support for `meta` and `meta_key` [@kurko]
* adds method to override association [adcb99e, @kurko]
* add `has_one` attribute for backwards compatibility [@ggordon]
* adds `has_one` attribute for backwards compatibility [@ggordon]
* updates JSON API support to RC3 [@mateomurphy]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,9 @@ end

#### JSONAPI

This adapter follows the format specified in
This adapter follows RC3 of the format specified in
[jsonapi.org/format](http://jsonapi.org/format). It will include the associated
resources in the `"linked"` member when the resource names are included in the
resources in the `"included"` member when the resource names are included in the
`include` option.

```ruby
Expand Down
10 changes: 10 additions & 0 deletions lib/active_model/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ def json_key
end
end

def id
object.id if object

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON API spec requires resource id to be a string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, great 👍

end

def type
object.class.to_s.demodulize.underscore.pluralize
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I believe this should go into the adapter because it's not really used by anyone else and it's a JSONAPI need.
  2. Having this in the serializer, we're forced to keep it public, which I think doesn't make much sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see what you're doing. You are:

  1. relying on the serialized object's type, not the serializer name.
  2. leaving it here so people can override the inferred type.

Is that it? I'm onboard with that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, easily overriding the type is one of the main reasons I put this here. One can create an ApplicationSerializer and use different inflection rules.

I put the id here as well because it made thing more symmetrical, but I can remove it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't be a good idea to add comments or GOTCHA's to those methods with explanation why the was placed here and what the original intention was behind that decision?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I was already planning on adding it to the documentation.

However, we need to figure out what to do about the id here. The main issue is that the jsonapi adapter requires an id attribute, so how do we handle the cases where the user doesn't add the id attribute to the configuration (or any other required attribute)? Raise an error, or add the id transparently? If the latter, I can implement a generic solution, something like

    def attributes(options = {})
      attributes =
        if options[:fields]
          self.class._attributes & options[:fields]
        else
          self.class._attributes.dup
        end

      attributes += options[:required_fields] if options[:required_fields]

      attributes.each_with_object({}) do |name, hash|
        hash[name] = read_attribute(name)
      end
    end

    def read_attribute(attr)
      if respond_to?(attr)
        send(attr)
      else
        object.read_attribute_for_serialization(attr)
      end
    end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest raising an error when the id attribute is missing since in some cases the id used to build the URL isn't the resource's original id but a slug maybe.

end

def attributes(options = {})
attributes =
if options[:fields]
Expand All @@ -172,6 +180,8 @@ def attributes(options = {})
self.class._attributes.dup
end

attributes += options[:required_fields] if options[:required_fields]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed that this could lead to duplicates, I'll fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm it's fine


attributes.each_with_object({}) do |name, hash|
hash[name] = send(name)
end
Expand Down
62 changes: 18 additions & 44 deletions lib/active_model/serializer/adapter/json_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def initialize(serializer, options = {})
end

def serializable_hash(options = {})
@root = (@options[:root] || serializer.json_key.to_s.pluralize).to_sym
@root = :data

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this fixed by standard whould be better to move that into initialize method?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, there's no reason to store the root, as it's not used elsewhere


if serializer.respond_to?(:each)
@hash[@root] = serializer.map do |s|
Expand All @@ -35,58 +35,40 @@ def serializable_hash(options = {})
private

def add_links(resource, name, serializers)
type = serialized_object_type(serializers)
resource[:links] ||= {}

if name.to_s == type || !type
resource[:links][name] ||= []
resource[:links][name] += serializers.map{|serializer| serializer.id.to_s }
else
resource[:links][name] ||= {}
resource[:links][name][:type] = type
resource[:links][name][:ids] ||= []
resource[:links][name][:ids] += serializers.map{|serializer| serializer.id.to_s }
end
resource[:links][name] ||= { linkage: [] }
resource[:links][name][:linkage] += serializers.map { |serializer| { type: serializer.type, id: serializer.id.to_s } }
end

def add_link(resource, name, serializer)
resource[:links] ||= {}
resource[:links][name] = nil
resource[:links][name] = { linkage: nil }

if serializer && serializer.object
type = serialized_object_type(serializer)
if name.to_s == type || !type
resource[:links][name] = serializer.id.to_s
else
resource[:links][name] ||= {}
resource[:links][name][:type] = type
resource[:links][name][:id] = serializer.id.to_s
end
resource[:links][name][:linkage] = { type: serializer.type, id: serializer.id.to_s }
end
end

def add_linked(resource_name, serializers, parent = nil)
def add_included(resource_name, serializers, parent = nil)
serializers = Array(serializers) unless serializers.respond_to?(:each)

resource_path = [parent, resource_name].compact.join('.')

if include_assoc?(resource_path) && resource_type = serialized_object_type(serializers)
plural_name = resource_type.pluralize.to_sym
@top[:linked] ||= {}
@top[:linked][plural_name] ||= []
if include_assoc?(resource_path)
@top[:included] ||= []

serializers.each do |serializer|
attrs = attributes_for_serializer(serializer, @options)

add_resource_links(attrs, serializer, add_linked: false)
add_resource_links(attrs, serializer, add_included: false)

@top[:linked][plural_name].push(attrs) unless @top[:linked][plural_name].include?(attrs)
@top[:included].push(attrs) unless @top[:included].include?(attrs)
end
end

serializers.each do |serializer|
serializer.each_association do |name, association, opts|
add_linked(name, association, resource_path) if association
add_included(name, association, resource_path) if association
end if include_nested_assoc? resource_path
end
end
Expand All @@ -97,14 +79,16 @@ def attributes_for_serializer(serializer, options)
result = []
serializer.each do |object|
options[:fields] = @fieldset && @fieldset.fields_for(serializer)
options[:required_fields] = [:id, :type]
attributes = object.attributes(options)
attributes[:id] = attributes[:id].to_s if attributes[:id]
attributes[:id] = attributes[:id].to_s
result << attributes
end
else
options[:fields] = @fieldset && @fieldset.fields_for(serializer)
options[:required_fields] = [:id, :type]
result = serializer.attributes(options)
result[:id] = result[:id].to_s if result[:id]
result[:id] = result[:id].to_s
end

result
Expand All @@ -128,18 +112,8 @@ def check_assoc(assoc)
end
end

def serialized_object_type(serializer)
return false unless Array(serializer).first
type_name = Array(serializer).first.object.class.to_s.demodulize.underscore
if serializer.respond_to?(:first)
type_name.pluralize
else
type_name
end
end

def add_resource_links(attrs, serializer, options = {})
options[:add_linked] = options.fetch(:add_linked, true)
options[:add_included] = options.fetch(:add_included, true)

serializer.each_association do |name, association, opts|
attrs[:links] ||= {}
Expand All @@ -150,9 +124,9 @@ def add_resource_links(attrs, serializer, options = {})
add_link(attrs, name, association)
end

if @options[:embed] != :ids && options[:add_linked]
if options[:add_included]
Array(association).each do |association|
add_linked(name, association)
add_included(name, association)
end
end
end
Expand Down
12 changes: 11 additions & 1 deletion test/action_controller/adapter_selector_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,17 @@ def test_render_using_default_adapter

def test_render_using_adapter_override
get :render_using_adapter_override
assert_equal '{"profiles":{"name":"Name 1","description":"Description 1"}}', response.body

expected = {
data: {
name: "Name 1",
description: "Description 1",
id: assigns(:profile).id.to_s,
type: "profiles"
}
}

assert_equal expected.to_json, response.body
end

def test_render_skipping_adapter
Expand Down
57 changes: 32 additions & 25 deletions test/action_controller/json_api_linked_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,80 +83,87 @@ def render_collection_with_include
def test_render_resource_without_include
get :render_resource_without_include
response = JSON.parse(@response.body)
refute response.key? 'linked'
refute response.key? 'included'
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']
assert response.key? 'included'
assert_equal 1, response['included'].size
assert_equal 'Steve K.', response['included'].first['name']
end

def test_render_resource_with_nested_has_many_include
get :render_resource_with_nested_has_many_include
response = JSON.parse(@response.body)
expected_linked = {
"authors" => [{
expected_linked = [
{
"id" => "1",
"type" => "authors",
"name" => "Steve K.",
"links" => {
"posts" => [],
"roles" => ["1", "2"],
"bio" => nil
"posts" => { "linkage" => [] },
"roles" => { "linkage" => [{ "type" =>"roles", "id" => "1" }, { "type" =>"roles", "id" => "2" }] },
"bio" => { "linkage" => nil }
}
}],
"roles"=>[{
}, {
"id" => "1",
"type" => "roles",
"name" => "admin",
"links" => {
"author" => "1"
"author" => { "linkage" => { "type" =>"authors", "id" => "1" } }
}
}, {
"id" => "2",
"type" => "roles",
"name" => "colab",
"links" => {
"author" => "1"
"author" => { "linkage" => { "type" =>"authors", "id" => "1" } }
}
}]
}
assert_equal expected_linked, response['linked']
}
]
assert_equal expected_linked, response['included']
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']
assert response.key? 'included'
assert_equal 1, response['included'].size
assert_equal 'Anonymous', response['included'].first['name']
end

def test_render_collection_without_include
get :render_collection_without_include
response = JSON.parse(@response.body)
refute response.key? 'linked'
refute response.key? 'included'
end

def test_render_collection_with_include
get :render_collection_with_include
response = JSON.parse(@response.body)
assert response.key? 'linked'
assert response.key? 'included'
end

def test_render_resource_with_nested_attributes_even_when_missing_associations
get :render_resource_with_missing_nested_has_many_include
response = JSON.parse(@response.body)
assert response.key? 'linked'
refute response['linked'].key? 'roles'
assert response.key? 'included'
refute has_type?(response['included'], 'roles')
end

def test_render_collection_with_missing_nested_has_many_include
get :render_collection_with_missing_nested_has_many_include
response = JSON.parse(@response.body)
assert response.key? 'linked'
assert response['linked'].key? 'roles'
assert response.key? 'included'
assert has_type?(response['included'], 'roles')
end

def has_type?(collection, value)
collection.detect { |i| i['type'] == value}
end

end
end
end
16 changes: 8 additions & 8 deletions test/action_controller/serialization_scope_name_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
require 'pathname'

class DefaultScopeNameTest < ActionController::TestCase
TestUser = Struct.new(:name, :admin)
TestUser = Struct.new(:id, :name, :admin)

class UserSerializer < ActiveModel::Serializer
attributes :admin?
Expand All @@ -17,24 +17,24 @@ class UserTestController < ActionController::Base
before_filter { request.format = :json }

def current_user
TestUser.new('Pete', false)
TestUser.new(1, 'Pete', false)
end

def render_new_user
render json: TestUser.new('pete', false), serializer: UserSerializer, adapter: :json_api
render json: TestUser.new(1, 'pete', false), serializer: UserSerializer, adapter: :json_api
end
end

tests UserTestController

def test_default_scope_name
get :render_new_user
assert_equal '{"users":{"admin?":false}}', @response.body
assert_equal '{"data":{"admin?":false,"id":"1","type":"test_users"}}', @response.body
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The type here should be "type": "user" (or users).
  2. Given it is type, in singular, I tend to prefer user. Just personal experience.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the resource type should stay pluralized as the urls in a rails app are normally pluralized and the jsonapi api spec recommends that the URL for a collection of resources be formed from the resource type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. As noted above, it's test_user because that's the class of the resource, and I think it makes more sense to use that to determine the type, allowing one to make generic serializers that decorate different models. But I am open to discussion on this. In this particular case, I think it would make sense to rename TestUser to User so that the type string is users.
  2. I would prefer singular as well, but I decided to follow the examples in the spec which are all plural. But this is also why I wanted to make it very easy to change the inflection rules use.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kurko I've refactored the test code a little bit, the output of type is now "users" as expected

end
end

class SerializationScopeNameTest < ActionController::TestCase
TestUser = Struct.new(:name, :admin)
TestUser = Struct.new(:id, :name, :admin)

class AdminUserSerializer < ActiveModel::Serializer
attributes :admin?
Expand All @@ -50,18 +50,18 @@ class AdminUserTestController < ActionController::Base
before_filter { request.format = :json }

def current_admin
TestUser.new('Bob', true)
TestUser.new(1, 'Bob', true)
end

def render_new_user
render json: TestUser.new('pete', false), serializer: AdminUserSerializer, adapter: :json_api
render json: TestUser.new(1, 'pete', false), serializer: AdminUserSerializer, adapter: :json_api
end
end

tests AdminUserTestController

def test_override_scope_name_with_controller
get :render_new_user
assert_equal '{"admin_users":{"admin?":true}}', @response.body
assert_equal '{"data":{"admin?":true,"id":"1","type":"test_users"}}', @response.body
end
end
Loading