Skip to content

Commit

Permalink
implement sparse fieldsets (#86)
Browse files Browse the repository at this point in the history
* implement sparse fieldsets

* make hash conversions work with Ruby 1.9.x

* remove ruby-head and add 2.3.1 to run specs with on Travis

* post-review fixes

* allow also to pass specific fields as array instead of comma-separates values

* add docs

* Readme post-review fixes
  • Loading branch information
Azdaroth authored and fotinakis committed Oct 17, 2016
1 parent a13dead commit f03663e
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 9 deletions.
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|
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 ({
'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

0 comments on commit f03663e

Please sign in to comment.