Skip to content
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 @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions lib/transmutation/association.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions lib/transmutation/attribute.rb
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions lib/transmutation/field.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 8 additions & 6 deletions lib/transmutation/serialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion lib/transmutation/serialization/rendering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 20 additions & 52 deletions lib/transmutation/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,20 @@ 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 = {})
as_json(options).to_json
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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions spec/lib/transmutation/serialization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions spec/lib/transmutation/serializer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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