Skip to content

Commit 6a4686e

Browse files
b-boogaardLeFnord
authored andcommitted
Add expose_nil option (#293)
* expose_nil defaults to true * is implemented as an unless condition * the option is transformed as part of Exposure.compile_conditions
1 parent 4b0ea7f commit 6a4686e

File tree

6 files changed

+250
-5
lines changed

6 files changed

+250
-5
lines changed

.rubocop_todo.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Metrics/AbcSize:
1818
# Offense count: 35
1919
# Configuration parameters: CountComments, ExcludedMethods.
2020
Metrics/BlockLength:
21-
Max: 1496
21+
Max: 1625
2222

2323
# Offense count: 2
2424
# Configuration parameters: CountComments.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* [#264](https://github.com/ruby-grape/grape-entity/pull/264): Adds Rubocop config and todo list - [@james2m](https://github.com/james2m).
99
* [#255](https://github.com/ruby-grape/grape-entity/pull/255): Adds code coverage w/ coveralls - [@LeFnord](https://github.com/LeFnord).
1010
* [#268](https://github.com/ruby-grape/grape-entity/pull/268): Loosens the version dependency for activesupport - [@anakinj](https://github.com/anakinj).
11+
* [#293](https://github.com/ruby-grape/grape-entity/pull/293): Adds expose_nil option - [@b-boogaard](https://github.com/b-boogaard).
1112

1213
* Your contribution here.
1314

README.md

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ module Entities
321321
with_options(format_with: :iso_timestamp) do
322322
expose :created_at
323323
expose :updated_at
324-
end
324+
end
325325
end
326326
end
327327
```
@@ -350,6 +350,86 @@ module Entities
350350
end
351351
```
352352

353+
#### Expose Nil
354+
355+
By default, exposures that contain `nil` values will be represented in the resulting JSON as `null`.
356+
357+
As an example, a hash with the following values:
358+
359+
```ruby
360+
{
361+
name: nil,
362+
age: 100
363+
}
364+
```
365+
366+
will result in a JSON object that looks like:
367+
368+
```javascript
369+
{
370+
"name": null,
371+
"age": 100
372+
}
373+
```
374+
375+
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:
376+
377+
```javascript
378+
{
379+
"age": 100
380+
}
381+
```
382+
383+
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.
384+
385+
```ruby
386+
module Entities
387+
class MyModel < Grape::Entity
388+
expose :name, expose_nil: false
389+
expose :age, expose_nil: false
390+
end
391+
end
392+
```
393+
394+
`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:
395+
396+
```ruby
397+
module Entities
398+
class MyModel < Grape::Entity
399+
expose :name, expose_nil: false
400+
expose :age # since expose_nil is omitted nil values will be rendered as null
401+
end
402+
end
403+
```
404+
405+
It is also possible to use `expose_nil` with `with_options` if you want to add the configuration to multiple exposures at once.
406+
407+
```ruby
408+
module Entities
409+
class MyModel < Grape::Entity
410+
# None of the exposures in the with_options block will render nil values as null
411+
with_options(expose_nil: false) do
412+
expose :name
413+
expose :age
414+
end
415+
end
416+
end
417+
```
418+
419+
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.
420+
421+
```ruby
422+
module Entities
423+
class MyModel < Grape::Entity
424+
# None of the exposures in the with_options block will render nil values as null
425+
with_options(expose_nil: false) do
426+
expose :name
427+
expose :age, expose_nil: true # nil values would be rendered as null in the JSON
428+
end
429+
end
430+
end
431+
```
432+
353433
#### Documentation
354434

355435
Expose documentation with the field. Gets bubbled up when used with Grape and various API documentation systems.

lib/grape_entity/entity.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ def self.inherited(subclass)
126126
# This method is the primary means by which you will declare what attributes
127127
# should be exposed by the entity.
128128
#
129+
# @option options :expose_nil When set to false the associated exposure will not
130+
# be rendered if its value is nil.
131+
#
129132
# @option options :as Declare an alias for the representation of this attribute.
130133
# If a proc is presented it is evaluated in the context of the entity so object
131134
# and the entity methods are available to it.
@@ -170,6 +173,7 @@ def self.expose(*args, &block)
170173

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

@@ -523,7 +527,7 @@ def to_xml(options = {})
523527

524528
# All supported options.
525529
OPTIONS = %i[
526-
rewrite as if unless using with proc documentation format_with safe attr_path if_extras unless_extras merge
530+
rewrite as if unless using with proc documentation format_with safe attr_path if_extras unless_extras merge expose_nil
527531
].to_set.freeze
528532

529533
# Merges the given options with current block options.

lib/grape_entity/exposure.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Entity
1414
module Exposure
1515
class << self
1616
def new(attribute, options)
17-
conditions = compile_conditions(options)
17+
conditions = compile_conditions(attribute, options)
1818
base_args = [attribute, options, conditions]
1919

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

3737
private
3838

39-
def compile_conditions(options)
39+
def compile_conditions(attribute, options)
4040
if_conditions = [
4141
options[:if_extras],
4242
options[:if]
@@ -47,9 +47,17 @@ def compile_conditions(options)
4747
options[:unless]
4848
].compact.flatten.map { |cond| Condition.new_unless(cond) }
4949

50+
unless_conditions << expose_nil_condition(attribute) if options[:expose_nil] == false
51+
5052
if_conditions + unless_conditions
5153
end
5254

55+
def expose_nil_condition(attribute)
56+
Condition.new_unless(
57+
proc { |object, _options| Delegator.new(object).delegate(attribute).nil? }
58+
)
59+
end
60+
5361
def build_class_exposure(base_args, using_class, passed_proc)
5462
exposure =
5563
if passed_proc

spec/grape_entity/entity_spec.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,130 @@
6868
end
6969
end
7070

71+
context 'with :expose_nil option' do
72+
let(:a) { nil }
73+
let(:b) { nil }
74+
let(:c) { 'value' }
75+
76+
context 'when model is a PORO' do
77+
let(:model) { Model.new(a, b, c) }
78+
79+
before do
80+
stub_const 'Model', Class.new
81+
Model.class_eval do
82+
attr_accessor :a, :b, :c
83+
84+
def initialize(a, b, c)
85+
@a = a
86+
@b = b
87+
@c = c
88+
end
89+
end
90+
end
91+
92+
context 'when expose_nil option is not provided' do
93+
it 'exposes nil attributes' do
94+
subject.expose(:a)
95+
subject.expose(:b)
96+
subject.expose(:c)
97+
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
98+
end
99+
end
100+
101+
context 'when expose_nil option is true' do
102+
it 'exposes nil attributes' do
103+
subject.expose(:a, expose_nil: true)
104+
subject.expose(:b, expose_nil: true)
105+
subject.expose(:c)
106+
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
107+
end
108+
end
109+
110+
context 'when expose_nil option is false' do
111+
it 'does not expose nil attributes' do
112+
subject.expose(:a, expose_nil: false)
113+
subject.expose(:b, expose_nil: false)
114+
subject.expose(:c)
115+
expect(subject.represent(model).serializable_hash).to eq(c: 'value')
116+
end
117+
118+
it 'is only applied per attribute' do
119+
subject.expose(:a, expose_nil: false)
120+
subject.expose(:b)
121+
subject.expose(:c)
122+
expect(subject.represent(model).serializable_hash).to eq(b: nil, c: 'value')
123+
end
124+
125+
it 'raises an error when applied to multiple attribute exposures' do
126+
expect { subject.expose(:a, :b, :c, expose_nil: false) }.to raise_error ArgumentError
127+
end
128+
end
129+
end
130+
131+
context 'when model is a hash' do
132+
let(:model) { { a: a, b: b, c: c } }
133+
134+
context 'when expose_nil option is not provided' do
135+
it 'exposes nil attributes' do
136+
subject.expose(:a)
137+
subject.expose(:b)
138+
subject.expose(:c)
139+
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
140+
end
141+
end
142+
143+
context 'when expose_nil option is true' do
144+
it 'exposes nil attributes' do
145+
subject.expose(:a, expose_nil: true)
146+
subject.expose(:b, expose_nil: true)
147+
subject.expose(:c)
148+
expect(subject.represent(model).serializable_hash).to eq(a: nil, b: nil, c: 'value')
149+
end
150+
end
151+
152+
context 'when expose_nil option is false' do
153+
it 'does not expose nil attributes' do
154+
subject.expose(:a, expose_nil: false)
155+
subject.expose(:b, expose_nil: false)
156+
subject.expose(:c)
157+
expect(subject.represent(model).serializable_hash).to eq(c: 'value')
158+
end
159+
160+
it 'is only applied per attribute' do
161+
subject.expose(:a, expose_nil: false)
162+
subject.expose(:b)
163+
subject.expose(:c)
164+
expect(subject.represent(model).serializable_hash).to eq(b: nil, c: 'value')
165+
end
166+
167+
it 'raises an error when applied to multiple attribute exposures' do
168+
expect { subject.expose(:a, :b, :c, expose_nil: false) }.to raise_error ArgumentError
169+
end
170+
end
171+
end
172+
173+
context 'with nested structures' do
174+
let(:model) { { a: a, b: b, c: { d: nil, e: nil, f: { g: nil, h: nil } } } }
175+
176+
context 'when expose_nil option is false' do
177+
it 'does not expose nil attributes' do
178+
subject.expose(:a, expose_nil: false)
179+
subject.expose(:b)
180+
subject.expose(:c) do
181+
subject.expose(:d, expose_nil: false)
182+
subject.expose(:e)
183+
subject.expose(:f) do
184+
subject.expose(:g, expose_nil: false)
185+
subject.expose(:h)
186+
end
187+
end
188+
189+
expect(subject.represent(model).serializable_hash).to eq(b: nil, c: { e: nil, f: { h: nil } })
190+
end
191+
end
192+
end
193+
end
194+
71195
context 'with a block' do
72196
it 'errors out if called with multiple attributes' do
73197
expect { subject.expose(:name, :email) { true } }.to raise_error ArgumentError
@@ -633,6 +757,34 @@ class Parent < Person
633757
exposure = subject.find_exposure(:awesome_thing)
634758
expect(exposure.documentation).to eq(desc: 'Other description.')
635759
end
760+
761+
it 'propagates expose_nil option' do
762+
subject.class_eval do
763+
with_options(expose_nil: false) do
764+
expose :awesome_thing
765+
end
766+
end
767+
768+
exposure = subject.find_exposure(:awesome_thing)
769+
expect(exposure.conditions[0].inversed?).to be true
770+
expect(exposure.conditions[0].block.call(awesome_thing: nil)).to be true
771+
end
772+
773+
it 'overrides nested :expose_nil option' do
774+
subject.class_eval do
775+
with_options(expose_nil: true) do
776+
expose :awesome_thing, expose_nil: false
777+
expose :other_awesome_thing
778+
end
779+
end
780+
781+
exposure = subject.find_exposure(:awesome_thing)
782+
expect(exposure.conditions[0].inversed?).to be true
783+
expect(exposure.conditions[0].block.call(awesome_thing: nil)).to be true
784+
# Conditions are only added for exposures that do not expose nil
785+
exposure = subject.find_exposure(:other_awesome_thing)
786+
expect(exposure.conditions[0]).to be_nil
787+
end
636788
end
637789

638790
describe '.represent' do

0 commit comments

Comments
 (0)