diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e2516b0..e6e3be4 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -9,7 +9,7 @@ # Offense count: 1 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 12 + Max: 13 # Offense count: 6 # Configuration parameters: CountAsOne. diff --git a/lib/transmutation/association.rb b/lib/transmutation/association.rb new file mode 100644 index 0000000..c559356 --- /dev/null +++ b/lib/transmutation/association.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Transmutation + # @api private + # + # An association: resolves the associated object (via a block or a method on the object) and + # serializes it with the looked up serializer, one level deeper. Only rendered within `max_depth`. + class Association < Field + def initialize(name, namespace: nil, serializer: nil, **options, &block) + super(name, **options, &block) + + @namespace = namespace + @serializer = serializer + end + + def render?(serializer) + super && serializer.depth < serializer.max_depth + end + + def value(serializer, options) + target = block ? serializer.instance_exec(&block) : serializer.object.send(name) + + serializer.serialize( + target, + namespace: @namespace, + serializer: @serializer, + depth: serializer.depth + 1, + max_depth: serializer.max_depth, + context: serializer.context + ).as_json(options) + end + end +end diff --git a/lib/transmutation/attribute.rb b/lib/transmutation/attribute.rb new file mode 100644 index 0000000..bd91072 --- /dev/null +++ b/lib/transmutation/attribute.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Transmutation + # @api private + # + # A plain attribute: either a block evaluated in the serializer's context, or a method sent to the + # serialized object. + class Attribute < Field + def value(serializer, _options) + block ? serializer.instance_exec(&block) : serializer.object.send(name) + end + end +end diff --git a/lib/transmutation/field.rb b/lib/transmutation/field.rb new file mode 100644 index 0000000..b991315 --- /dev/null +++ b/lib/transmutation/field.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Transmutation + # @api private + # + # Base class for a serializable field. Encapsulates the name, the precomputed JSON key, and the + # optional `if:`/`unless:` rendering conditions shared by attributes and associations. + class Field + attr_reader :name, :key + + def initialize(name, **options, &block) + @name = name + @key = name.to_s.freeze + @block = block + @if = options[:if] + @unless = options[:unless] + end + + # Whether this field should be rendered for the given serializer instance. + def render?(serializer) + return false if @if && !evaluate(@if, serializer) + return false if @unless && evaluate(@unless, serializer) + + true + end + + private + + attr_reader :block + + # A Symbol condition is sent to the serializer; anything else (e.g. a Proc) runs in its context. + def evaluate(condition, serializer) + condition.is_a?(Symbol) ? serializer.send(condition) : serializer.instance_exec(&condition) + end + end +end diff --git a/lib/transmutation/serialization.rb b/lib/transmutation/serialization.rb index 728f9f5..70e4e6a 100644 --- a/lib/transmutation/serialization.rb +++ b/lib/transmutation/serialization.rb @@ -8,12 +8,14 @@ module Serialization # @param namespace [String, Symbol, Module] The namespace to lookup the serializer in. # @param serializer [String, Symbol, Class] The serializer to use. # @param max_depth [Integer] The maximum depth of nested associations to serialize. + # @param context [Object] Arbitrary context made available to the serializer as `#context`. + # Defaults to the caller (e.g. the controller), so serializers can reach `context.current_user`. # # @return [Transmutation::Serializer] The serialized object. This will respond to `#as_json` and `#to_json`. - def serialize(object, namespace: nil, serializer: nil, depth: 0, max_depth: Transmutation.max_depth) - return serialize_collection(object, namespace:, serializer:, depth:, max_depth:) if collection?(object) + def serialize(object, namespace: nil, serializer: nil, depth: 0, max_depth: Transmutation.max_depth, context: self) + return serialize_collection(object, namespace:, serializer:, depth:, max_depth:, context:) if collection?(object) - lookup_serializer(object, namespace:, serializer:).new(object, depth:, max_depth:) + lookup_serializer(object, namespace:, serializer:).new(object, depth:, max_depth:, context:) end # Lookup the serializer for the given object. @@ -58,19 +60,19 @@ def collection?(object) # # Items of the same class resolve to the same serializer, so the lookup is memoised on the item's # class and reused across siblings rather than rebuilding the cache key (and hashing it) per item. - def serialize_collection(collection, namespace:, serializer:, depth:, max_depth:) + def serialize_collection(collection, namespace:, serializer:, depth:, max_depth:, context:) serializer_class = nil serialized_class = nil collection.map do |item| - next serialize(item, namespace:, serializer:, depth:, max_depth:) if collection?(item) + next serialize(item, namespace:, serializer:, depth:, max_depth:, context:) if collection?(item) if item.class != serialized_class serializer_class = lookup_serializer(item, namespace:, serializer:) serialized_class = item.class end - serializer_class.new(item, depth:, max_depth:) + serializer_class.new(item, depth:, max_depth:, context:) end end diff --git a/lib/transmutation/serialization/rendering.rb b/lib/transmutation/serialization/rendering.rb index 2f99712..8933ebb 100644 --- a/lib/transmutation/serialization/rendering.rb +++ b/lib/transmutation/serialization/rendering.rb @@ -19,11 +19,12 @@ def render(options = nil, **kwargs) namespace = kwargs.delete(:namespace) serializer = kwargs.delete(:serializer) max_depth = kwargs.delete(:max_depth) || Transmutation.max_depth + context = kwargs.delete(:context) { self } return super(**kwargs) unless json return super(**kwargs, json:) unless should_serialize - super(**kwargs, json: serialize(json, namespace:, serializer:, max_depth:)) + super(**kwargs, json: serialize(json, namespace:, serializer:, max_depth:, context:)) end end end diff --git a/lib/transmutation/serializer.rb b/lib/transmutation/serializer.rb index 4c3ea30..f9c4388 100644 --- a/lib/transmutation/serializer.rb +++ b/lib/transmutation/serializer.rb @@ -20,10 +20,11 @@ class Serializer include Transmutation::Serialization - def initialize(object, depth: 0, max_depth: 1) + def initialize(object, depth: 0, max_depth: 1, context: nil) @object = object @depth = depth @max_depth = max_depth + @context = context end def to_json(options = {}) @@ -31,11 +32,8 @@ def to_json(options = {}) end def as_json(options = {}) - attributes_config.each_with_object({}) do |(attr_name, attr_options), hash| - next if attr_options[:conditional] && !render_field?(attr_options) - next if attr_options[:association] && @depth + 1 > @max_depth - - hash[attr_name.to_s] = field_value(attr_name, attr_options, options) + fields.each_with_object({}) do |field, hash| + hash[field.key] = field.value(self, options) if field.render?(self) end end @@ -60,7 +58,7 @@ class << self # attribute :email, if: :admin? # end def attribute(attribute_name, **options, &block) - attributes_config[attribute_name] = field_config({ block: }, options) + fields[attribute_name] = Attribute.new(attribute_name, **options, &block) end # Define an association to be serialized @@ -70,6 +68,8 @@ def attribute(attribute_name, **options, &block) # @param association_name [Symbol] The name of the association to serialize # @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 + # @param if [Symbol, Proc] Only include the association when the condition evaluates truthy + # @param unless [Symbol, Proc] Exclude the association when the condition evaluates truthy # @yield [object] The block to call to get the value of the association # - The block is called in the context of the serializer instance # - The return value from the block is automatically serialized @@ -82,14 +82,8 @@ def attribute(attribute_name, **options, &block) # object.posts.archived # end # end - def association(association_name, namespace: nil, serializer: nil, **options, &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] = field_config({ block:, association: true }, options) + def association(association_name, namespace: nil, serializer: nil, **options, &block) + fields[association_name] = Association.new(association_name, namespace:, serializer:, **options, &block) end # Shorthand for defining multiple attributes @@ -123,52 +117,26 @@ def associations(*association_names, **, &) alias belongs_to associations alias has_one associations alias has_many associations - - private - - # Merge conditional rendering options into a field's config. - # - # The `:conditional` flag lets {#as_json} skip condition evaluation entirely for fields that - # don't declare `if:`/`unless:`, keeping the common path a single hash lookup. - def field_config(config, options) - return config unless options[:if] || options[:unless] - - config.merge(if: options[:if], unless: options[:unless], conditional: true) - end end - private - - class_attribute :attributes_config, instance_writer: false, default: {} - - attr_reader :object - - # Resolve the value for a field: a serialized association, a block result, or a method call. - def field_value(attr_name, attr_options, options) - return instance_exec(&attr_options[:block]).as_json(options) if attr_options[:association] - return instance_exec(&attr_options[:block]) if attr_options[:block] + # @return [Object] the object being serialized + # @return [Integer] depth the current nesting depth + # @return [Integer] max_depth the maximum nesting depth to serialize + # @return [Object, nil] context the caller-supplied context (defaults to the caller of `serialize`) + attr_reader :object, :depth, :max_depth, :context - object.send(attr_name) - end - - # Evaluate the `if:`/`unless:` conditions for a field. - # - # Only called for fields flagged `:conditional`, so unconditional fields incur no overhead. - def render_field?(attr_options) - return false if attr_options[:if] && !evaluate_condition(attr_options[:if]) - return false if attr_options[:unless] && evaluate_condition(attr_options[:unless]) + private - true - end + class_attribute :fields, instance_accessor: false, default: {} - # A Symbol condition is sent to the serializer; a Proc (or other callable) is run in its context. - def evaluate_condition(condition) - condition.is_a?(Symbol) ? send(condition) : instance_exec(&condition) + # The fields declared on this serializer, in declaration order. + def fields + self.class.fields.values end private_class_method def self.inherited(subclass) super - subclass.attributes_config = attributes_config.dup + subclass.fields = fields.dup end end end diff --git a/spec/lib/transmutation/serialization_spec.rb b/spec/lib/transmutation/serialization_spec.rb index 2b2c4a0..a4430eb 100644 --- a/spec/lib/transmutation/serialization_spec.rb +++ b/spec/lib/transmutation/serialization_spec.rb @@ -241,4 +241,64 @@ def initialize(id:) end end end + + describe "context" do + before do + stub_const("Api::V1::Admin::Chat::UserSerializer", Class.new(Transmutation::Serializer)) + end + + it "defaults the context to the caller" do + expect(caller.serialize(object).context).to be(caller) + end + + it "uses an explicit context when provided" do + given_context = Object.new + + expect(caller.serialize(object, context: given_context).context).to be(given_context) + end + + context "when serializing associations" do + let(:context_class) do + Class.new do + def initialize(visible:) + @visible = visible + end + + attr_reader :visible + alias_method :visible?, :visible + end + end + + before do + author_class = Class.new do + def name = "Jane" + end + + post_class = Class.new do + define_method(:author) { @author ||= Ctx::Author.new } + end + + stub_const("Ctx::Author", author_class) + stub_const("Ctx::Post", post_class) + stub_const("Ctx::AuthorSerializer", Class.new(Transmutation::Serializer) do + attribute :name, if: -> { context.visible? } + end) + stub_const("Ctx::PostSerializer", Class.new(Transmutation::Serializer) do + belongs_to :author + end) + end + + it "propagates the context to associated serializers" do + serialized = Ctx::PostSerializer.new(Ctx::Post.new, context: context_class.new(visible: true), max_depth: 2) + + expect(serialized.as_json).to eq({ "author" => { "name" => "Jane" } }) + end + + it "lets associated serializers gate attributes on the shared context" do + serialized = Ctx::PostSerializer.new(Ctx::Post.new, context: context_class.new(visible: false), max_depth: 2) + + expect(serialized.as_json).to eq({ "author" => {} }) + end + end + end end diff --git a/spec/lib/transmutation/serializer_spec.rb b/spec/lib/transmutation/serializer_spec.rb index 84a002e..aae22a2 100644 --- a/spec/lib/transmutation/serializer_spec.rb +++ b/spec/lib/transmutation/serializer_spec.rb @@ -139,4 +139,36 @@ def admin? end end end + + describe "context" do + let(:context_class) do + Class.new do + def initialize(admin:) + @admin = admin + end + + attr_reader :admin + alias_method :admin?, :admin + end + end + + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: -> { context.admin? } + end + end + + it "exposes the context to conditions and includes the attribute when it passes" do + serialized = example_serializer.new(example_object, context: context_class.new(admin: true)) + + expect(serialized.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when the context condition fails" do + serialized = example_serializer.new(example_object, context: context_class.new(admin: false)) + + expect(serialized.as_json).to eq({ "first_name" => "John" }) + end + end end