diff --git a/lib/transmutation/association.rb b/lib/transmutation/association.rb new file mode 100644 index 0000000..70b4b13 --- /dev/null +++ b/lib/transmutation/association.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Transmutation + # @api private + class Association < Attribute + def as_json(json_options = {}) + serializer + .serialize(super, **options.slice(:namespace, :serializer), depth: depth + 1, max_depth:) + .as_json(json_options) + end + + def included? + depth + 1 <= max_depth && super + end + + private + + def depth + serializer.send(:depth) + end + + def max_depth + serializer.send(:max_depth) + end + end +end diff --git a/lib/transmutation/attribute.rb b/lib/transmutation/attribute.rb new file mode 100644 index 0000000..e727498 --- /dev/null +++ b/lib/transmutation/attribute.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Transmutation + # @api private + class Attribute + attr_reader :name, :options, :block, :serializer + + def initialize(name, **options, &block) + @name = name + @options = options + @block = block || -> { object.send(name) } + end + + def with_serializer(serializer) + @serializer = serializer + self + end + + def as_json(_options = {}) + serializer.instance_exec(&block) + end + + def included? + evaluate_condition(options[:if]) && !evaluate_condition(options[:unless], default: false) + end + + private + + def evaluate_condition(condition, default: true) + case condition + when Symbol + callable = serializer.method(condition) + when Proc + callable = condition + when nil + return default + end + + serializer.instance_exec(*([as_json] * callable.arity), &callable) + end + end +end diff --git a/lib/transmutation/serializer.rb b/lib/transmutation/serializer.rb index 3126864..92c2563 100644 --- a/lib/transmutation/serializer.rb +++ b/lib/transmutation/serializer.rb @@ -20,10 +20,14 @@ class Serializer include Transmutation::Serialization + attr_reader :attributes + def initialize(object, depth: 0, max_depth: 1) @object = object @depth = depth @max_depth = max_depth + + @attributes = attributes_config.transform_values { _1.dup.with_serializer(self) } end def to_json(options = {}) @@ -31,12 +35,8 @@ def to_json(options = {}) end def as_json(options = {}) - attributes_config.each_with_object({}) do |(attr_name, attr_options), hash| - if attr_options[:association] - hash[attr_name.to_s] = instance_exec(&attr_options[:block]).as_json(options) if @depth + 1 <= @max_depth - else - hash[attr_name.to_s] = attr_options[:block] ? instance_exec(&attr_options[:block]) : object.send(attr_name) - end + attributes.each_with_object({}) do |(attribute_name, attribute), hash| + hash[attribute_name.to_s] = attribute.as_json(options) if attribute.included? end end @@ -44,19 +44,29 @@ class << self # Define an attribute to be serialized # # @param attribute_name [Symbol] The name of the attribute to serialize + # @param if [Symbol, Proc] The condition to check before serializing the attribute + # @param unless [Symbol, Proc] The condition to check before serializing the attribute # @yield [object] The block to call to get the value of the attribute # - The block is called in the context of the serializer instance # # @example # class UserSerializer < Transmutation::Serializer # attribute :first_name + # attribute :age, if: -> (value) { value && value >= 18 } + # attributes :email, :phone_number, if: :some_feature_flag # # attribute :full_name do # "#{object.first_name} #{object.last_name}".strip # end + # + # private + # + # def some_feature_flag + # ENV["FEATURE_FLAG__CONTACT_DETAILS"].present? + # end # end - def attribute(attribute_name, &block) - attributes_config[attribute_name] = { block: } + def attribute(attribute_name, if: nil, unless: nil, &block) + attributes_config[attribute_name] = Attribute.new(attribute_name, if:, unless:, &block) end # Define an association to be serialized @@ -64,6 +74,8 @@ def attribute(attribute_name, &block) # @note By default, the serializer for the association is looked up in the same namespace as the serializer # # @param association_name [Symbol] The name of the association to serialize + # @param if [Symbol, Proc] The condition to check before serializing the attribute + # @param unless [Symbol, Proc] The condition to check before serializing the attribute # @param namespace [String, Symbol, Module] The namespace to lookup the association's serializer in # @param serializer [String, Symbol, Class] The serializer to use for the association's serialization # @yield [object] The block to call to get the value of the association @@ -78,14 +90,9 @@ def attribute(attribute_name, &block) # object.posts.archived # end # end - def association(association_name, namespace: nil, serializer: nil, &custom_block) - block = lambda do - association_instance = custom_block ? instance_exec(&custom_block) : object.send(association_name) - - serialize(association_instance, namespace:, serializer:, depth: @depth + 1, max_depth: @max_depth) - end - - attributes_config[association_name] = { block:, association: true } + def association(association_name, if: nil, unless: nil, namespace: nil, serializer: nil, &block) + attributes_config[association_name] = + Association.new(association_name, if:, unless:, namespace:, serializer:, &block) end # Shorthand for defining multiple attributes @@ -125,7 +132,7 @@ def associations(*association_names, **, &) class_attribute :attributes_config, instance_writer: false, default: {} - attr_reader :object + attr_reader :object, :depth, :max_depth private_class_method def self.inherited(subclass) super diff --git a/spec/system/dummy/models/user.rb b/spec/system/dummy/models/user.rb index 859dcb5..e5775a7 100644 --- a/spec/system/dummy/models/user.rb +++ b/spec/system/dummy/models/user.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true class User - attr_accessor :id, :first_name, :last_name + attr_accessor :id, :first_name, :last_name, :age - def initialize(id:, first_name:, last_name:) + def initialize(id:, first_name:, last_name:, age:) self.id = id self.first_name = first_name self.last_name = last_name + self.age = age end def posts @@ -23,10 +24,10 @@ def comments def self.all [ - User.new(id: 1, first_name: "John", last_name: "Doe"), - User.new(id: 2, first_name: "Jane", last_name: "Doe"), - User.new(id: 3, first_name: "Adam", last_name: "Smith"), - User.new(id: 4, first_name: "Eve", last_name: "Smith") + User.new(id: 1, first_name: "John", last_name: "Doe", age: 18), + User.new(id: 2, first_name: "Jane", last_name: "Doe", age: 17), + User.new(id: 3, first_name: "Adam", last_name: "Smith", age: 30), + User.new(id: 4, first_name: "Eve", last_name: "Smith", age: 28) ] end diff --git a/spec/system/dummy/serializers/api/v1/detailed/user_serializer.rb b/spec/system/dummy/serializers/api/v1/detailed/user_serializer.rb index 6b3d9a2..1adf503 100644 --- a/spec/system/dummy/serializers/api/v1/detailed/user_serializer.rb +++ b/spec/system/dummy/serializers/api/v1/detailed/user_serializer.rb @@ -5,6 +5,7 @@ module V1 module Detailed class UserSerializer < Api::V1::UserSerializer attributes :first_name, :last_name + attribute :age, if: ->(value) { value >= 18 } has_many :posts, :comments, namespace: "::Api::V1" diff --git a/spec/system/dummy/serializers/api/v1/post_serializer.rb b/spec/system/dummy/serializers/api/v1/post_serializer.rb index 6e124d0..d43439c 100644 --- a/spec/system/dummy/serializers/api/v1/post_serializer.rb +++ b/spec/system/dummy/serializers/api/v1/post_serializer.rb @@ -4,6 +4,16 @@ module Api module V1 class PostSerializer < Transmutation::Serializer attributes :id, :title + + attribute :published, if: :first_user? do + !object.published_at.nil? + end + + private + + def first_user? + object.user_id == 1 + end end end end diff --git a/spec/system/rendering_spec.rb b/spec/system/rendering_spec.rb index df0a029..67dc56d 100644 --- a/spec/system/rendering_spec.rb +++ b/spec/system/rendering_spec.rb @@ -28,16 +28,17 @@ "first_name" => "John", "last_name" => "Doe", "full_name" => "John Doe", + "age" => 18, "posts" => [ - { "id" => 1, "title" => "First post" }, - { "id" => 3, "title" => "Second post!?" } + { "id" => 1, "title" => "First post", "published" => true }, + { "id" => 3, "title" => "Second post!?", "published" => false } ], "comments" => [ { "id" => 1, "body" => "First!" }, { "id" => 3, "body" => "Third!" } ], "published_posts" => [ - { "id" => 1, "title" => "First post" } + { "id" => 1, "title" => "First post", "published" => true } ] } end @@ -45,6 +46,30 @@ it "returns a user serialized with the Api::V1::Detailed::UserSerializer" do expect(controller.show(1)).to eq(expected_json) end + + context "with a conditional attribute excluded" do + let(:expected_json) do + { + "id" => 2, + "first_name" => "Jane", + "last_name" => "Doe", + "full_name" => "Jane Doe", + "posts" => [ + { "id" => 2, "title" => "How does this work?" } + ], + "comments" => [ + { "body" => "Second!", "id" => 2 } + ], + "published_posts" => [ + { "id" => 2, "title" => "How does this work?" } + ] + } + end + + it "returns a user serialized with the Api::V1::Detailed::UserSerializer" do + expect(controller.show(2)).to eq(expected_json) + end + end end end @@ -54,9 +79,9 @@ describe "#index" do let(:expected_json) do [ - { "id" => 1, "title" => "First post" }, + { "id" => 1, "title" => "First post", "published" => true }, { "id" => 2, "title" => "How does this work?" }, - { "id" => 3, "title" => "Second post!?" } + { "id" => 3, "title" => "Second post!?", "published" => false } ] end @@ -71,7 +96,8 @@ "id" => 1, "title" => "First post", "body" => "First!", - "user" => { "id" => 1, "first_name" => "John", "last_name" => "Doe", "full_name" => "John Doe" } + "published" => true, + "user" => { "id" => 1, "first_name" => "John", "last_name" => "Doe", "full_name" => "John Doe", "age" => 18 } } end