Skip to content

Commit 4a01b6f

Browse files
committed
Add conditional attribute rendering
While implementing this feature, I realised there is a bit of logic to the conditional rendering. This made `Serializer#as_json` bloat in complexity. To address the added complexity, I extracted out the `Attribute` and `Association` classes. This provides an added benefit of the `Attribute` and `Association` classes defining the `#included?` method, which checks depth vs max_depth, along with user defined conditions.
1 parent fc90cdf commit 4a01b6f

File tree

7 files changed

+142
-29
lines changed

7 files changed

+142
-29
lines changed

lib/transmutation/association.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Transmutation
4+
# @api private
5+
class Association < Attribute
6+
def as_json(json_options = {})
7+
serializer
8+
.serialize(super, **options.slice(:namespace, :serializer), depth: depth + 1, max_depth:)
9+
.as_json(json_options)
10+
end
11+
12+
def included?
13+
depth + 1 <= max_depth && super
14+
end
15+
16+
private
17+
18+
def depth
19+
serializer.send(:depth)
20+
end
21+
22+
def max_depth
23+
serializer.send(:max_depth)
24+
end
25+
end
26+
end

lib/transmutation/attribute.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
module Transmutation
4+
# @api private
5+
class Attribute
6+
attr_reader :name, :options, :block, :serializer
7+
8+
def initialize(name, **options, &block)
9+
@name = name
10+
@options = options
11+
@block = block || -> { object.send(name) }
12+
end
13+
14+
def with_serializer(serializer)
15+
@serializer = serializer
16+
self
17+
end
18+
19+
def as_json(_options = {})
20+
serializer.instance_exec(&block)
21+
end
22+
23+
def included?
24+
evaluate_condition(options[:if]) && !evaluate_condition(options[:unless], default: false)
25+
end
26+
27+
private
28+
29+
def evaluate_condition(condition, default: true)
30+
case condition
31+
when Symbol
32+
callable = serializer.method(condition)
33+
when Proc
34+
callable = condition
35+
when nil
36+
return default
37+
end
38+
39+
serializer.instance_exec(*([as_json] * callable.arity), &callable)
40+
end
41+
end
42+
end

lib/transmutation/serializer.rb

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,50 +20,62 @@ class Serializer
2020

2121
include Transmutation::Serialization
2222

23+
attr_reader :attributes
24+
2325
def initialize(object, depth: 0, max_depth: 1)
2426
@object = object
2527
@depth = depth
2628
@max_depth = max_depth
29+
30+
@attributes = attributes_config.transform_values { _1.dup.with_serializer(self) }
2731
end
2832

2933
def to_json(options = {})
3034
as_json(options).to_json
3135
end
3236

3337
def as_json(options = {})
34-
attributes_config.each_with_object({}) do |(attr_name, attr_options), hash|
35-
if attr_options[:association]
36-
hash[attr_name.to_s] = instance_exec(&attr_options[:block]).as_json(options) if @depth + 1 <= @max_depth
37-
else
38-
hash[attr_name.to_s] = attr_options[:block] ? instance_exec(&attr_options[:block]) : object.send(attr_name)
39-
end
38+
attributes.each_with_object({}) do |(attribute_name, attribute), hash|
39+
hash[attribute_name.to_s] = attribute.as_json(options) if attribute.included?
4040
end
4141
end
4242

4343
class << self
4444
# Define an attribute to be serialized
4545
#
4646
# @param attribute_name [Symbol] The name of the attribute to serialize
47+
# @param if [Symbol, Proc] The condition to check before serializing the attribute
48+
# @param unless [Symbol, Proc] The condition to check before serializing the attribute
4749
# @yield [object] The block to call to get the value of the attribute
4850
# - The block is called in the context of the serializer instance
4951
#
5052
# @example
5153
# class UserSerializer < Transmutation::Serializer
5254
# attribute :first_name
55+
# attribute :age, if: -> (value) { value && value >= 18 }
56+
# attributes :email, :phone_number, if: :some_feature_flag
5357
#
5458
# attribute :full_name do
5559
# "#{object.first_name} #{object.last_name}".strip
5660
# end
61+
#
62+
# private
63+
#
64+
# def some_feature_flag
65+
# ENV["FEATURE_FLAG__CONTACT_DETAILS"].present?
66+
# end
5767
# end
58-
def attribute(attribute_name, &block)
59-
attributes_config[attribute_name] = { block: }
68+
def attribute(attribute_name, if: nil, unless: nil, &block)
69+
attributes_config[attribute_name] = Attribute.new(attribute_name, if:, unless:, &block)
6070
end
6171

6272
# Define an association to be serialized
6373
#
6474
# @note By default, the serializer for the association is looked up in the same namespace as the serializer
6575
#
6676
# @param association_name [Symbol] The name of the association to serialize
77+
# @param if [Symbol, Proc] The condition to check before serializing the attribute
78+
# @param unless [Symbol, Proc] The condition to check before serializing the attribute
6779
# @param namespace [String, Symbol, Module] The namespace to lookup the association's serializer in
6880
# @param serializer [String, Symbol, Class] The serializer to use for the association's serialization
6981
# @yield [object] The block to call to get the value of the association
@@ -78,14 +90,9 @@ def attribute(attribute_name, &block)
7890
# object.posts.archived
7991
# end
8092
# end
81-
def association(association_name, namespace: nil, serializer: nil, &custom_block)
82-
block = lambda do
83-
association_instance = custom_block ? instance_exec(&custom_block) : object.send(association_name)
84-
85-
serialize(association_instance, namespace:, serializer:, depth: @depth + 1, max_depth: @max_depth)
86-
end
87-
88-
attributes_config[association_name] = { block:, association: true }
93+
def association(association_name, if: nil, unless: nil, namespace: nil, serializer: nil, &block)
94+
attributes_config[association_name] =
95+
Association.new(association_name, if:, unless:, namespace:, serializer:, &block)
8996
end
9097

9198
# Shorthand for defining multiple attributes
@@ -125,7 +132,7 @@ def associations(*association_names, **, &)
125132

126133
class_attribute :attributes_config, instance_writer: false, default: {}
127134

128-
attr_reader :object
135+
attr_reader :object, :depth, :max_depth
129136

130137
private_class_method def self.inherited(subclass)
131138
super

spec/system/dummy/models/user.rb

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# frozen_string_literal: true
22

33
class User
4-
attr_accessor :id, :first_name, :last_name
4+
attr_accessor :id, :first_name, :last_name, :age
55

6-
def initialize(id:, first_name:, last_name:)
6+
def initialize(id:, first_name:, last_name:, age:)
77
self.id = id
88
self.first_name = first_name
99
self.last_name = last_name
10+
self.age = age
1011
end
1112

1213
def posts
@@ -23,10 +24,10 @@ def comments
2324

2425
def self.all
2526
[
26-
User.new(id: 1, first_name: "John", last_name: "Doe"),
27-
User.new(id: 2, first_name: "Jane", last_name: "Doe"),
28-
User.new(id: 3, first_name: "Adam", last_name: "Smith"),
29-
User.new(id: 4, first_name: "Eve", last_name: "Smith")
27+
User.new(id: 1, first_name: "John", last_name: "Doe", age: 18),
28+
User.new(id: 2, first_name: "Jane", last_name: "Doe", age: 17),
29+
User.new(id: 3, first_name: "Adam", last_name: "Smith", age: 30),
30+
User.new(id: 4, first_name: "Eve", last_name: "Smith", age: 28)
3031
]
3132
end
3233

spec/system/dummy/serializers/api/v1/detailed/user_serializer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module V1
55
module Detailed
66
class UserSerializer < Api::V1::UserSerializer
77
attributes :first_name, :last_name
8+
attribute :age, if: ->(value) { value >= 18 }
89

910
has_many :posts, :comments, namespace: "::Api::V1"
1011

spec/system/dummy/serializers/api/v1/post_serializer.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ module Api
44
module V1
55
class PostSerializer < Transmutation::Serializer
66
attributes :id, :title
7+
8+
attribute :published, if: :first_user? do
9+
!object.published_at.nil?
10+
end
11+
12+
private
13+
14+
def first_user?
15+
object.user_id == 1
16+
end
717
end
818
end
919
end

spec/system/rendering_spec.rb

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,48 @@
2828
"first_name" => "John",
2929
"last_name" => "Doe",
3030
"full_name" => "John Doe",
31+
"age" => 18,
3132
"posts" => [
32-
{ "id" => 1, "title" => "First post" },
33-
{ "id" => 3, "title" => "Second post!?" }
33+
{ "id" => 1, "title" => "First post", "published" => true },
34+
{ "id" => 3, "title" => "Second post!?", "published" => false }
3435
],
3536
"comments" => [
3637
{ "id" => 1, "body" => "First!" },
3738
{ "id" => 3, "body" => "Third!" }
3839
],
3940
"published_posts" => [
40-
{ "id" => 1, "title" => "First post" }
41+
{ "id" => 1, "title" => "First post", "published" => true }
4142
]
4243
}
4344
end
4445

4546
it "returns a user serialized with the Api::V1::Detailed::UserSerializer" do
4647
expect(controller.show(1)).to eq(expected_json)
4748
end
49+
50+
context "with a conditional attribute excluded" do
51+
let(:expected_json) do
52+
{
53+
"id" => 2,
54+
"first_name" => "Jane",
55+
"last_name" => "Doe",
56+
"full_name" => "Jane Doe",
57+
"posts" => [
58+
{ "id" => 2, "title" => "How does this work?" }
59+
],
60+
"comments" => [
61+
{ "body" => "Second!", "id" => 2 }
62+
],
63+
"published_posts" => [
64+
{ "id" => 2, "title" => "How does this work?" }
65+
]
66+
}
67+
end
68+
69+
it "returns a user serialized with the Api::V1::Detailed::UserSerializer" do
70+
expect(controller.show(2)).to eq(expected_json)
71+
end
72+
end
4873
end
4974
end
5075

@@ -54,9 +79,9 @@
5479
describe "#index" do
5580
let(:expected_json) do
5681
[
57-
{ "id" => 1, "title" => "First post" },
82+
{ "id" => 1, "title" => "First post", "published" => true },
5883
{ "id" => 2, "title" => "How does this work?" },
59-
{ "id" => 3, "title" => "Second post!?" }
84+
{ "id" => 3, "title" => "Second post!?", "published" => false }
6085
]
6186
end
6287

@@ -71,7 +96,8 @@
7196
"id" => 1,
7297
"title" => "First post",
7398
"body" => "First!",
74-
"user" => { "id" => 1, "first_name" => "John", "last_name" => "Doe", "full_name" => "John Doe" }
99+
"published" => true,
100+
"user" => { "id" => 1, "first_name" => "John", "last_name" => "Doe", "full_name" => "John Doe", "age" => 18 }
75101
}
76102
end
77103

0 commit comments

Comments
 (0)