From f03663ef8c2bce9d574c2b5f89b4dafb5502a960 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Mon, 17 Oct 2016 18:29:34 +0200 Subject: [PATCH] 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 --- .travis.yml | 2 +- README.md | 58 ++++++++++++- lib/jsonapi-serializers/serializer.rb | 39 +++++++-- spec/serializer_spec.rb | 119 ++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index fcb57f3..8efeb21 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/README.md b/README.md index 2ae7542..1f942ce 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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 diff --git a/lib/jsonapi-serializers/serializer.rb b/lib/jsonapi-serializers/serializer.rb index 99d6749..09ab9b2 100644 --- a/lib/jsonapi-serializers/serializer.rb +++ b/lib/jsonapi-serializers/serializer.rb @@ -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] || [] @@ -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 @@ -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 @@ -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 @@ -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. @@ -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] @@ -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] } @@ -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] diff --git a/spec/serializer_spec.rb b/spec/serializer_spec.rb index 0725bdd..45e3b27 100644 --- a/spec/serializer_spec.rb +++ b/spec/serializer_spec.rb @@ -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