Skip to content

implement sparse fieldsets #86

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

Merged
merged 7 commits into from
Oct 17, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ rvm:
- 1.9.3
- 2.1.1
- 2.2.2
- ruby-head
- 2.3.1
before_install:
- gem install bundler
script: bundle exec rspec
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This library is up-to-date with the finalized v1 JSON API spec.
* [Root jsonapi object](#root-jsonapi-object)
* [Explicit serializer discovery](#explicit-serializer-discovery)
* [Namespace serializers](#namespace-serializers)
* [Sparse fieldsets](#sparse-fieldsets)
* [Relationships](#relationships)
* [Compound documents and includes](#compound-documents-and-includes)
* [Relationship path handling](#relationship-path-handling)
Expand Down Expand Up @@ -416,6 +417,62 @@ JSONAPI::Serializer.serialize(post, namespace: Api::V2)

This option overrides the `jsonapi_serializer_class_name` method.

### Sparse fieldsets

The JSON:API spec allows to return only [specific fields](http://jsonapi.org/format/#fetching-sparse-fieldsets).

For example, if you wanted to return only `title` and `author` fields for `posts` type and `name` field for `users` type, you could write the following code:

```ruby
fields = {posts: 'title,author', users: 'name'}
JSONAPI::Serializer.serialize(post, fields: fields, include: 'author')
```

Returns:

```json
{
"data": {
"type": "posts",
"id": "1",
"attributes": {
"title": "Title for Post 1"
},
"links": {
"self": "/posts/1"
},
"relationships": {
"author": {
"links": {
"self": "/posts/1/relationships/author",
"related": "/posts/1/author"
},
"data": { "type": "users", "id": "3" }
}
}
},
"included": [
{
"type": "users",
"id": "3",
"attributes": {
"name": "User #3"
},
"links": {
"self": "/users/3"
}
}
]
}
```

You could also pass an array of specific fields for given type instead of comma-separated values:

``` ruby
fields = {posts: ['title', 'author'], users: ['name']}
JSONAPI::Serializer.serialize(post, fields: fields, include: 'author')
```

## Relationships

You can easily specify relationships with the `has_one` and `has_many` directives.
Expand Down Expand Up @@ -783,7 +840,6 @@ See [Releases](https://github.com/fotinakis/jsonapi-serializers/releases).

## Unfinished business

* Support for the `fields` spec is planned, would love a PR contribution for this.
* 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.

## Contributing
Expand Down
39 changes: 32 additions & 7 deletions lib/jsonapi-serializers/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ module InstanceMethods
attr_accessor :object
attr_accessor :context
attr_accessor :base_url
attr_accessor :fields

def initialize(object, options = {})
@object = object
@options = options
@context = options[:context] || {}
@base_url = options[:base_url]
@fields = options[:fields] || {}

# Internal serializer options, not exposed through attr_accessor. No touchie.
@_include_linkages = options[:include_linkages] || []
Expand Down Expand Up @@ -164,8 +166,7 @@ def relationships
def attributes
return {} if self.class.attributes_map.nil?
attributes = {}
self.class.attributes_map.each do |attribute_name, attr_data|
next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
self.class.attributes_map.select(&should_include_attr_proc).each do |attribute_name, attr_data|
value = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
attributes[format_name(attribute_name)] = value
end
Expand All @@ -175,8 +176,7 @@ def attributes
def has_one_relationships
return {} if self.class.to_one_associations.nil?
data = {}
self.class.to_one_associations.each do |attribute_name, attr_data|
next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
self.class.to_one_associations.select(&should_include_attr_proc).each do |attribute_name, attr_data|
data[attribute_name] = attr_data
end
data
Expand All @@ -189,8 +189,7 @@ def has_one_relationship(attribute_name, attr_data)
def has_many_relationships
return {} if self.class.to_many_associations.nil?
data = {}
self.class.to_many_associations.each do |attribute_name, attr_data|
next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
self.class.to_many_associations.select(&should_include_attr_proc).each do |attribute_name, attr_data|
data[attribute_name] = attr_data
end
data
Expand All @@ -200,15 +199,23 @@ def has_many_relationship(attribute_name, attr_data)
evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
end

def should_include_attr?(if_method_name, unless_method_name)
def should_include_attr?(attribute_name, if_method_name, unless_method_name)
# Allow "if: :show_title?" and "unless: :hide_title?" attribute options.
show_attr = true
show_attr &&= send(if_method_name) if if_method_name
show_attr &&= !send(unless_method_name) if unless_method_name
show_attr &&= @fields[type].include?(attribute_name) if @fields[type]
show_attr
end
protected :should_include_attr?

def should_include_attr_proc
lambda do |attribute_name, attr_data|
should_include_attr?(attribute_name, attr_data[:options][:if], attr_data[:options][:unless])
end
end
private :should_include_attr_proc

def evaluate_attr_or_block(attribute_name, attr_or_block)
if attr_or_block.is_a?(Proc)
# A custom block was given, call it to get the value.
Expand Down Expand Up @@ -252,6 +259,7 @@ def self.serialize(objects, options = {})
options[:jsonapi] = options.delete('jsonapi') || options[:jsonapi]
options[:meta] = options.delete('meta') || options[:meta]
options[:links] = options.delete('links') || options[:links]
options[:fields] = options.delete('fields') || options[:fields]

# Deprecated: use serialize_errors method instead
options[:errors] = options.delete('errors') || options[:errors]
Expand All @@ -260,12 +268,28 @@ def self.serialize(objects, options = {})
includes = options[:include]
includes = (includes.is_a?(String) ? includes.split(',') : includes).uniq if includes

fields = options[:fields] || {}
# Transforms input so that the comma-separated fields are separate symbols in array
# and keys are stringified
# Example:
# {posts: 'title,author,long_comments'} => {'posts' => [:title, :author, :long_comments]}
# {posts: ['title', 'author', 'long_comments'} => {'posts' => [:title, :author, :long_comments]}
#
fields = Hash[fields.map do |type, whitelisted_fields|
Copy link
Owner

Choose a reason for hiding this comment

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

This could be cleaned up and commented a bit, it's very non-obvious what it's doing by looking at it.

if whitelisted_fields.respond_to?(:to_ary)
[type.to_s, whitelisted_fields.map(&:to_sym)]
else
[type.to_s, whitelisted_fields.split(",").map(&:to_sym)]
end
end]

# An internal-only structure that is passed through serializers as they are created.
passthrough_options = {
context: options[:context],
serializer: options[:serializer],
namespace: options[:namespace],
include: includes,
fields: fields,
base_url: options[:base_url]
}

Expand Down Expand Up @@ -328,6 +352,7 @@ def self.serialize(objects, options = {})
included_passthrough_options = {}
included_passthrough_options[:base_url] = passthrough_options[:base_url]
included_passthrough_options[:context] = passthrough_options[:context]
included_passthrough_options[:fields] = passthrough_options[:fields]
included_passthrough_options[:serializer] = find_serializer_class(data[:object], options)
included_passthrough_options[:namespace] = passthrough_options[:namespace]
included_passthrough_options[:include_linkages] = data[:include_linkages]
Expand Down
119 changes: 119 additions & 0 deletions spec/serializer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,125 @@ def read_attribute_for_validation(attr)
})
end
end

context 'sparse fieldsets' do
it 'allows to limit fields(attributes) for serialized resource' do
first_user = create(:user)
second_user = create(:user)
first_comment = create(:long_comment, user: first_user)
second_comment = create(:long_comment, user: second_user)
long_comments = [first_comment, second_comment]
post = create(:post, :with_author, long_comments: long_comments)

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title'})
expect(serialized_data).to eq ({
'data' => {
'type' => 'posts',
'id' => post.id.to_s,
'attributes' => {
'title' => post.title,
},
'links' => {
'self' => '/posts/1'
}
}
})
end

it 'allows to limit fields(relationships) for serialized resource' do
first_user = create(:user)
second_user = create(:user)
first_comment = create(:long_comment, user: first_user)
second_comment = create(:long_comment, user: second_user)
long_comments = [first_comment, second_comment]
post = create(:post, :with_author, long_comments: long_comments)

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author,long_comments'})
expect(serialized_data['data']['relationships']).to eq ({
'author' => {
'links' => {
'self' => '/posts/1/relationships/author',
'related' => '/posts/1/author'
}
},
'long-comments' => {
'links' => {
'self' => '/posts/1/relationships/long-comments',
'related' => '/posts/1/long-comments'
}
}
})
end

it "allows also to pass specific fields as array instead of comma-separates values" do
first_user = create(:user)
second_user = create(:user)
first_comment = create(:long_comment, user: first_user)
second_comment = create(:long_comment, user: second_user)
long_comments = [first_comment, second_comment]
post = create(:post, :with_author, long_comments: long_comments)

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: ['title', 'author']})
expect(serialized_data['data']['attributes']).to eq ({
'title' => post.title
})
expect(serialized_data['data']['relationships']).to eq ({
'author' => {
'links' => {
'self' => '/posts/1/relationships/author',
'related' => '/posts/1/author'
}
}
})
end

it 'allows to limit fields(attributes and relationships) for included resources' do
first_user = create(:user)
second_user = create(:user)
first_comment = create(:long_comment, user: first_user)
second_comment = create(:long_comment, user: second_user)
long_comments = [first_comment, second_comment]
post = create(:post, :with_author, long_comments: long_comments)

expected_primary_data = serialize_primary(post, {
serializer: MyApp::PostSerializer,
include_linkages: ['author'],
fields: { 'posts' => [:title, :author] }
})

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author', users: ''}, include: 'author')
expect(serialized_data).to eq ({
Copy link
Owner

Choose a reason for hiding this comment

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

The style of these tests should be updated to match how the rest of the tests look, and they should be much shorter if possible. I think the way to do that would be split them into two different concerns like the rests of the tests: 1) put all attribute handling tests in the top internal-only serialize_primary block. 2) Now that sparse fieldset attributes are guaranteed to be tested above, have only short relationships handling tests here. They should look like the other tests, which rely on serialize_primary for the attrributes and only test the relations. See inherits relations test for an example.

Also, nitpicks, should match the style of the other tests as well -- single quotes, spaces around hash-rocket, etc.

'data' => expected_primary_data,
'included' => [
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer, fields: { 'users' => [] })
]
})

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author'}, include: 'author')
expect(serialized_data).to eq ({
'data' => expected_primary_data,
'included' => [
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer)
]
})

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author', users: 'nonexistent'}, include: 'author')
expect(serialized_data).to eq ({
'data' => expected_primary_data,
'included' => [
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer, fields: { 'users' => [:nonexistent] })
]
})

serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author', users: 'name'}, include: 'author')
expect(serialized_data).to eq ({
'data' => expected_primary_data,
'included' => [
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer, fields: { 'users' => [:name] })
]
})
end
end
end

describe 'serialize (class method)' do
Expand Down