Skip to content

Commit f03663e

Browse files
Azdarothfotinakis
authored andcommitted
implement sparse fieldsets (#86)
* 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
1 parent a13dead commit f03663e

File tree

4 files changed

+209
-9
lines changed

4 files changed

+209
-9
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ rvm:
55
- 1.9.3
66
- 2.1.1
77
- 2.2.2
8-
- ruby-head
8+
- 2.3.1
99
before_install:
1010
- gem install bundler
1111
script: bundle exec rspec

README.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This library is up-to-date with the finalized v1 JSON API spec.
2424
* [Root jsonapi object](#root-jsonapi-object)
2525
* [Explicit serializer discovery](#explicit-serializer-discovery)
2626
* [Namespace serializers](#namespace-serializers)
27+
* [Sparse fieldsets](#sparse-fieldsets)
2728
* [Relationships](#relationships)
2829
* [Compound documents and includes](#compound-documents-and-includes)
2930
* [Relationship path handling](#relationship-path-handling)
@@ -416,6 +417,62 @@ JSONAPI::Serializer.serialize(post, namespace: Api::V2)
416417

417418
This option overrides the `jsonapi_serializer_class_name` method.
418419

420+
### Sparse fieldsets
421+
422+
The JSON:API spec allows to return only [specific fields](http://jsonapi.org/format/#fetching-sparse-fieldsets).
423+
424+
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:
425+
426+
```ruby
427+
fields = {posts: 'title,author', users: 'name'}
428+
JSONAPI::Serializer.serialize(post, fields: fields, include: 'author')
429+
```
430+
431+
Returns:
432+
433+
```json
434+
{
435+
"data": {
436+
"type": "posts",
437+
"id": "1",
438+
"attributes": {
439+
"title": "Title for Post 1"
440+
},
441+
"links": {
442+
"self": "/posts/1"
443+
},
444+
"relationships": {
445+
"author": {
446+
"links": {
447+
"self": "/posts/1/relationships/author",
448+
"related": "/posts/1/author"
449+
},
450+
"data": { "type": "users", "id": "3" }
451+
}
452+
}
453+
},
454+
"included": [
455+
{
456+
"type": "users",
457+
"id": "3",
458+
"attributes": {
459+
"name": "User #3"
460+
},
461+
"links": {
462+
"self": "/users/3"
463+
}
464+
}
465+
]
466+
}
467+
```
468+
469+
You could also pass an array of specific fields for given type instead of comma-separated values:
470+
471+
``` ruby
472+
fields = {posts: ['title', 'author'], users: ['name']}
473+
JSONAPI::Serializer.serialize(post, fields: fields, include: 'author')
474+
```
475+
419476
## Relationships
420477

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

784841
## Unfinished business
785842

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

789845
## Contributing

lib/jsonapi-serializers/serializer.rb

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ module InstanceMethods
2525
attr_accessor :object
2626
attr_accessor :context
2727
attr_accessor :base_url
28+
attr_accessor :fields
2829

2930
def initialize(object, options = {})
3031
@object = object
3132
@options = options
3233
@context = options[:context] || {}
3334
@base_url = options[:base_url]
35+
@fields = options[:fields] || {}
3436

3537
# Internal serializer options, not exposed through attr_accessor. No touchie.
3638
@_include_linkages = options[:include_linkages] || []
@@ -164,8 +166,7 @@ def relationships
164166
def attributes
165167
return {} if self.class.attributes_map.nil?
166168
attributes = {}
167-
self.class.attributes_map.each do |attribute_name, attr_data|
168-
next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
169+
self.class.attributes_map.select(&should_include_attr_proc).each do |attribute_name, attr_data|
169170
value = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
170171
attributes[format_name(attribute_name)] = value
171172
end
@@ -175,8 +176,7 @@ def attributes
175176
def has_one_relationships
176177
return {} if self.class.to_one_associations.nil?
177178
data = {}
178-
self.class.to_one_associations.each do |attribute_name, attr_data|
179-
next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
179+
self.class.to_one_associations.select(&should_include_attr_proc).each do |attribute_name, attr_data|
180180
data[attribute_name] = attr_data
181181
end
182182
data
@@ -189,8 +189,7 @@ def has_one_relationship(attribute_name, attr_data)
189189
def has_many_relationships
190190
return {} if self.class.to_many_associations.nil?
191191
data = {}
192-
self.class.to_many_associations.each do |attribute_name, attr_data|
193-
next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
192+
self.class.to_many_associations.select(&should_include_attr_proc).each do |attribute_name, attr_data|
194193
data[attribute_name] = attr_data
195194
end
196195
data
@@ -200,15 +199,23 @@ def has_many_relationship(attribute_name, attr_data)
200199
evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
201200
end
202201

203-
def should_include_attr?(if_method_name, unless_method_name)
202+
def should_include_attr?(attribute_name, if_method_name, unless_method_name)
204203
# Allow "if: :show_title?" and "unless: :hide_title?" attribute options.
205204
show_attr = true
206205
show_attr &&= send(if_method_name) if if_method_name
207206
show_attr &&= !send(unless_method_name) if unless_method_name
207+
show_attr &&= @fields[type].include?(attribute_name) if @fields[type]
208208
show_attr
209209
end
210210
protected :should_include_attr?
211211

212+
def should_include_attr_proc
213+
lambda do |attribute_name, attr_data|
214+
should_include_attr?(attribute_name, attr_data[:options][:if], attr_data[:options][:unless])
215+
end
216+
end
217+
private :should_include_attr_proc
218+
212219
def evaluate_attr_or_block(attribute_name, attr_or_block)
213220
if attr_or_block.is_a?(Proc)
214221
# A custom block was given, call it to get the value.
@@ -252,6 +259,7 @@ def self.serialize(objects, options = {})
252259
options[:jsonapi] = options.delete('jsonapi') || options[:jsonapi]
253260
options[:meta] = options.delete('meta') || options[:meta]
254261
options[:links] = options.delete('links') || options[:links]
262+
options[:fields] = options.delete('fields') || options[:fields]
255263

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

271+
fields = options[:fields] || {}
272+
# Transforms input so that the comma-separated fields are separate symbols in array
273+
# and keys are stringified
274+
# Example:
275+
# {posts: 'title,author,long_comments'} => {'posts' => [:title, :author, :long_comments]}
276+
# {posts: ['title', 'author', 'long_comments'} => {'posts' => [:title, :author, :long_comments]}
277+
#
278+
fields = Hash[fields.map do |type, whitelisted_fields|
279+
if whitelisted_fields.respond_to?(:to_ary)
280+
[type.to_s, whitelisted_fields.map(&:to_sym)]
281+
else
282+
[type.to_s, whitelisted_fields.split(",").map(&:to_sym)]
283+
end
284+
end]
285+
263286
# An internal-only structure that is passed through serializers as they are created.
264287
passthrough_options = {
265288
context: options[:context],
266289
serializer: options[:serializer],
267290
namespace: options[:namespace],
268291
include: includes,
292+
fields: fields,
269293
base_url: options[:base_url]
270294
}
271295

@@ -328,6 +352,7 @@ def self.serialize(objects, options = {})
328352
included_passthrough_options = {}
329353
included_passthrough_options[:base_url] = passthrough_options[:base_url]
330354
included_passthrough_options[:context] = passthrough_options[:context]
355+
included_passthrough_options[:fields] = passthrough_options[:fields]
331356
included_passthrough_options[:serializer] = find_serializer_class(data[:object], options)
332357
included_passthrough_options[:namespace] = passthrough_options[:namespace]
333358
included_passthrough_options[:include_linkages] = data[:include_linkages]

spec/serializer_spec.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,125 @@ def read_attribute_for_validation(attr)
854854
})
855855
end
856856
end
857+
858+
context 'sparse fieldsets' do
859+
it 'allows to limit fields(attributes) for serialized resource' do
860+
first_user = create(:user)
861+
second_user = create(:user)
862+
first_comment = create(:long_comment, user: first_user)
863+
second_comment = create(:long_comment, user: second_user)
864+
long_comments = [first_comment, second_comment]
865+
post = create(:post, :with_author, long_comments: long_comments)
866+
867+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title'})
868+
expect(serialized_data).to eq ({
869+
'data' => {
870+
'type' => 'posts',
871+
'id' => post.id.to_s,
872+
'attributes' => {
873+
'title' => post.title,
874+
},
875+
'links' => {
876+
'self' => '/posts/1'
877+
}
878+
}
879+
})
880+
end
881+
882+
it 'allows to limit fields(relationships) for serialized resource' do
883+
first_user = create(:user)
884+
second_user = create(:user)
885+
first_comment = create(:long_comment, user: first_user)
886+
second_comment = create(:long_comment, user: second_user)
887+
long_comments = [first_comment, second_comment]
888+
post = create(:post, :with_author, long_comments: long_comments)
889+
890+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author,long_comments'})
891+
expect(serialized_data['data']['relationships']).to eq ({
892+
'author' => {
893+
'links' => {
894+
'self' => '/posts/1/relationships/author',
895+
'related' => '/posts/1/author'
896+
}
897+
},
898+
'long-comments' => {
899+
'links' => {
900+
'self' => '/posts/1/relationships/long-comments',
901+
'related' => '/posts/1/long-comments'
902+
}
903+
}
904+
})
905+
end
906+
907+
it "allows also to pass specific fields as array instead of comma-separates values" do
908+
first_user = create(:user)
909+
second_user = create(:user)
910+
first_comment = create(:long_comment, user: first_user)
911+
second_comment = create(:long_comment, user: second_user)
912+
long_comments = [first_comment, second_comment]
913+
post = create(:post, :with_author, long_comments: long_comments)
914+
915+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: ['title', 'author']})
916+
expect(serialized_data['data']['attributes']).to eq ({
917+
'title' => post.title
918+
})
919+
expect(serialized_data['data']['relationships']).to eq ({
920+
'author' => {
921+
'links' => {
922+
'self' => '/posts/1/relationships/author',
923+
'related' => '/posts/1/author'
924+
}
925+
}
926+
})
927+
end
928+
929+
it 'allows to limit fields(attributes and relationships) for included resources' do
930+
first_user = create(:user)
931+
second_user = create(:user)
932+
first_comment = create(:long_comment, user: first_user)
933+
second_comment = create(:long_comment, user: second_user)
934+
long_comments = [first_comment, second_comment]
935+
post = create(:post, :with_author, long_comments: long_comments)
936+
937+
expected_primary_data = serialize_primary(post, {
938+
serializer: MyApp::PostSerializer,
939+
include_linkages: ['author'],
940+
fields: { 'posts' => [:title, :author] }
941+
})
942+
943+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author', users: ''}, include: 'author')
944+
expect(serialized_data).to eq ({
945+
'data' => expected_primary_data,
946+
'included' => [
947+
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer, fields: { 'users' => [] })
948+
]
949+
})
950+
951+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author'}, include: 'author')
952+
expect(serialized_data).to eq ({
953+
'data' => expected_primary_data,
954+
'included' => [
955+
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer)
956+
]
957+
})
958+
959+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author', users: 'nonexistent'}, include: 'author')
960+
expect(serialized_data).to eq ({
961+
'data' => expected_primary_data,
962+
'included' => [
963+
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer, fields: { 'users' => [:nonexistent] })
964+
]
965+
})
966+
967+
serialized_data = JSONAPI::Serializer.serialize(post, fields: {posts: 'title,author', users: 'name'}, include: 'author')
968+
expect(serialized_data).to eq ({
969+
'data' => expected_primary_data,
970+
'included' => [
971+
serialize_primary(post.author, serializer: MyAppOtherNamespace::UserSerializer, fields: { 'users' => [:name] })
972+
]
973+
})
974+
end
975+
end
857976
end
858977

859978
describe 'serialize (class method)' do

0 commit comments

Comments
 (0)