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

Add expose_nil option #293

Merged
merged 1 commit into from
Jan 25, 2018
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 .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Metrics/AbcSize:
# Offense count: 35
# Configuration parameters: CountComments, ExcludedMethods.
Metrics/BlockLength:
Max: 1496
Max: 1625

# Offense count: 2
# Configuration parameters: CountComments.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* [#264](https://github.com/ruby-grape/grape-entity/pull/264): Adds Rubocop config and todo list - [@james2m](https://github.com/james2m).
* [#255](https://github.com/ruby-grape/grape-entity/pull/255): Adds code coverage w/ coveralls - [@LeFnord](https://github.com/LeFnord).
* [#268](https://github.com/ruby-grape/grape-entity/pull/268): Loosens the version dependency for activesupport - [@anakinj](https://github.com/anakinj).
* [#293](https://github.com/ruby-grape/grape-entity/pull/293): Adds expose_nil option - [@b-boogaard](https://github.com/b-boogaard).

* Your contribution here.

Expand Down
82 changes: 81 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ module Entities
with_options(format_with: :iso_timestamp) do
expose :created_at
expose :updated_at
end
end
end
end
```
Expand Down Expand Up @@ -350,6 +350,86 @@ module Entities
end
```

#### Expose Nil

By default, exposures that contain `nil` values will be represented in the resulting JSON as `null`.

As an example, a hash with the following values:

```ruby
{
name: nil,
age: 100
}
```

will result in a JSON object that looks like:

```javascript
{
"name": null,
"age": 100
}
```

There are also times when, rather than displaying an attribute with a `null` value, it is more desirable to not display the attribute at all. Using the hash from above the desired JSON would look like:

```javascript
{
"age": 100
}
```

In order to turn on this behavior for an as-exposure basis, the option `expose_nil` can be used. By default, `expose_nil` is considered to be `true`, meaning that `nil` values will be represented in JSON as `null`. If `false` is provided, then attributes with `nil` values will be omitted from the resulting JSON completely.

```ruby
module Entities
class MyModel < Grape::Entity
expose :name, expose_nil: false
expose :age, expose_nil: false
end
end
```

`expose_nil` is per exposure, so you can suppress exposures from resulting in `null` or express `null` values on a per exposure basis as you need:

```ruby
module Entities
class MyModel < Grape::Entity
expose :name, expose_nil: false
expose :age # since expose_nil is omitted nil values will be rendered as null
end
end
```

It is also possible to use `expose_nil` with `with_options` if you want to add the configuration to multiple exposures at once.

```ruby
module Entities
class MyModel < Grape::Entity
# None of the exposures in the with_options block will render nil values as null
with_options(expose_nil: false) do
expose :name
expose :age
end
end
end
```

When using `with_options`, it is possible to again override which exposures will render `nil` as `null` by adding the option on a specific exposure.

```ruby
module Entities
class MyModel < Grape::Entity
# None of the exposures in the with_options block will render nil values as null
with_options(expose_nil: false) do
expose :name
expose :age, expose_nil: true # nil values would be rendered as null in the JSON
end
end
end
```

#### Documentation

Expose documentation with the field. Gets bubbled up when used with Grape and various API documentation systems.
Expand Down
6 changes: 5 additions & 1 deletion lib/grape_entity/entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ def self.inherited(subclass)
# This method is the primary means by which you will declare what attributes
# should be exposed by the entity.
#
# @option options :expose_nil When set to false the associated exposure will not
# be rendered if its value is nil.
#
# @option options :as Declare an alias for the representation of this attribute.
# If a proc is presented it is evaluated in the context of the entity so object
# and the entity methods are available to it.
Expand Down Expand Up @@ -170,6 +173,7 @@ def self.expose(*args, &block)

if args.size > 1
raise ArgumentError, 'You may not use the :as option on multi-attribute exposures.' if options[:as]
raise ArgumentError, 'You may not use the :expose_nil on multi-attribute exposures.' if options.key?(:expose_nil)
raise ArgumentError, 'You may not use block-setting on multi-attribute exposures.' if block_given?
end

Expand Down Expand Up @@ -523,7 +527,7 @@ def to_xml(options = {})

# All supported options.
OPTIONS = %i[
rewrite as if unless using with proc documentation format_with safe attr_path if_extras unless_extras merge
rewrite as if unless using with proc documentation format_with safe attr_path if_extras unless_extras merge expose_nil
].to_set.freeze

# Merges the given options with current block options.
Expand Down
12 changes: 10 additions & 2 deletions lib/grape_entity/exposure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Entity
module Exposure
class << self
def new(attribute, options)
conditions = compile_conditions(options)
conditions = compile_conditions(attribute, options)
base_args = [attribute, options, conditions]

passed_proc = options[:proc]
Expand All @@ -36,7 +36,7 @@ def new(attribute, options)

private

def compile_conditions(options)
def compile_conditions(attribute, options)
if_conditions = [
options[:if_extras],
options[:if]
Expand All @@ -47,9 +47,17 @@ def compile_conditions(options)
options[:unless]
].compact.flatten.map { |cond| Condition.new_unless(cond) }

unless_conditions << expose_nil_condition(attribute) if options[:expose_nil] == false

if_conditions + unless_conditions
end

def expose_nil_condition(attribute)
Condition.new_unless(
proc { |object, _options| Delegator.new(object).delegate(attribute).nil? }
)
end

def build_class_exposure(base_args, using_class, passed_proc)
exposure =
if passed_proc
Expand Down
152 changes: 152 additions & 0 deletions spec/grape_entity/entity_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,130 @@
end
end

context 'with :expose_nil option' do
let(:a) { nil }
let(:b) { nil }
let(:c) { 'value' }

context 'when model is a PORO' do
let(:model) { Model.new(a, b, c) }

before do
stub_const 'Model', Class.new
Model.class_eval do
attr_accessor :a, :b, :c

def initialize(a, b, c)
@a = a
@b = b
@c = c
end
end
end

context 'when expose_nil option is not provided' do
it 'exposes nil attributes' do
subject.expose(:a)
subject.expose(:b)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
end
end

context 'when expose_nil option is true' do
it 'exposes nil attributes' do
subject.expose(:a, expose_nil: true)
subject.expose(:b, expose_nil: true)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
end
end

context 'when expose_nil option is false' do
it 'does not expose nil attributes' do
subject.expose(:a, expose_nil: false)
subject.expose(:b, expose_nil: false)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(c: 'value')
end

it 'is only applied per attribute' do
subject.expose(:a, expose_nil: false)
subject.expose(:b)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(b: nil, c: 'value')
end

it 'raises an error when applied to multiple attribute exposures' do
expect { subject.expose(:a, :b, :c, expose_nil: false) }.to raise_error ArgumentError
end
end
end

context 'when model is a hash' do
let(:model) { { a: a, b: b, c: c } }

context 'when expose_nil option is not provided' do
it 'exposes nil attributes' do
subject.expose(:a)
subject.expose(:b)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
end
end

context 'when expose_nil option is true' do
it 'exposes nil attributes' do
subject.expose(:a, expose_nil: true)
subject.expose(:b, expose_nil: true)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
end
end

context 'when expose_nil option is false' do
it 'does not expose nil attributes' do
subject.expose(:a, expose_nil: false)
subject.expose(:b, expose_nil: false)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(c: 'value')
end

it 'is only applied per attribute' do
subject.expose(:a, expose_nil: false)
subject.expose(:b)
subject.expose(:c)
expect(subject.represent(model).serializable_hash).to eq(b: nil, c: 'value')
end

it 'raises an error when applied to multiple attribute exposures' do
expect { subject.expose(:a, :b, :c, expose_nil: false) }.to raise_error ArgumentError
end
end
end

context 'with nested structures' do
let(:model) { { a: a, b: b, c: { d: nil, e: nil, f: { g: nil, h: nil } } } }

context 'when expose_nil option is false' do
it 'does not expose nil attributes' do
subject.expose(:a, expose_nil: false)
subject.expose(:b)
subject.expose(:c) do
subject.expose(:d, expose_nil: false)
subject.expose(:e)
subject.expose(:f) do
subject.expose(:g, expose_nil: false)
subject.expose(:h)
end
end

expect(subject.represent(model).serializable_hash).to eq(b: nil, c: { e: nil, f: { h: nil } })
end
end
end
end

context 'with a block' do
it 'errors out if called with multiple attributes' do
expect { subject.expose(:name, :email) { true } }.to raise_error ArgumentError
Expand Down Expand Up @@ -633,6 +757,34 @@ class Parent < Person
exposure = subject.find_exposure(:awesome_thing)
expect(exposure.documentation).to eq(desc: 'Other description.')
end

it 'propagates expose_nil option' do
subject.class_eval do
with_options(expose_nil: false) do
expose :awesome_thing
end
end

exposure = subject.find_exposure(:awesome_thing)
expect(exposure.conditions[0].inversed?).to be true
expect(exposure.conditions[0].block.call(awesome_thing: nil)).to be true
end

it 'overrides nested :expose_nil option' do
subject.class_eval do
with_options(expose_nil: true) do
expose :awesome_thing, expose_nil: false
expose :other_awesome_thing
end
end

exposure = subject.find_exposure(:awesome_thing)
expect(exposure.conditions[0].inversed?).to be true
expect(exposure.conditions[0].block.call(awesome_thing: nil)).to be true
# Conditions are only added for exposures that do not expose nil
exposure = subject.find_exposure(:other_awesome_thing)
expect(exposure.conditions[0]).to be_nil
end
end

describe '.represent' do
Expand Down