Skip to content

Commit

Permalink
Add around and around_blueprint hooks (primarily for instrumenting)
Browse files Browse the repository at this point in the history
Signed-off-by: Jordan Hollinger <[email protected]>
  • Loading branch information
jhollinger committed Dec 11, 2024
1 parent d0198a6 commit cced12e
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 35 deletions.
54 changes: 37 additions & 17 deletions lib/blueprinter/extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ module Blueprinter
#
# V2 hook call order:
# - collection? (skipped if calling render_object/render_collection)
# - input_object | input_collection
# - prepare
# - blueprint_fields
# - blueprint_input
# - field_value
# - exclude_field?
# - object_value
# - exclude_object?
# - collection_value
# - exclude_collection?
# - blueprint_output
# - output_object | output_collection
# - around
# - input_object | input_collection
# - around_blueprint
# - prepare (only first time during a given render)
# - blueprint_fields (only first time during a given render)
# - blueprint_input
# - field_value
# - exclude_field?
# - object_value
# - exclude_object?
# - collection_value
# - exclude_collection?
# - blueprint_output
# - output_object | output_collection
#
# V1 hook call order:
# - pre_render
Expand All @@ -26,30 +28,48 @@ class Extension
#
# Returns true if the given object should be treated as a collection (i.e. supports `map { |obj| ... }`).
#
# @param [Object]
# @param _object [Object]
# @return [Boolean]
#
def collection?(_object)
false
end

#
# Runs around the entire rendering process. MUST yield!
#
# @param _context [Blueprinter::V2::Context]
#
def around(_context)
yield
end

#
# Runs around any Blueprint serialization. Surrounds the `prepare` through `blueprint_output` hooks. MUST yield!
#
# @param _context [Blueprinter::V2::Context]
#
def around_blueprint(_context)
yield
end

#
# Called once per blueprint per render. A common use is to pre-calculate certain options
# and cache them in context.data, so we don't have to recalculate them for every field.
#
# @param context [Blueprinter::V2::Context]
# @param _context [Blueprinter::V2::Context]
#
def prepare(context); end
def prepare(_context); end

#
# Returns the fields that should be included in the correct order. Default is all fields in the order in which they were defined.
#
# NOTE Only runs once per Blueprint per render.
#
# @param context [Blueprinter::V2::Context]
# @param _context [Blueprinter::V2::Context]
# @return [Array<Blueprinter::V2::Field|Blueprinter::V2::Object|Blueprinter::V2::Collection>]
#
def blueprint_fields(ctx)
def blueprint_fields(_context)
[]
end

Expand Down
20 changes: 20 additions & 0 deletions lib/blueprinter/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,25 @@ def reduce_into(hook, target_obj, target_attr)
end
target_obj[target_attr]
end

#
# Runs nested hooks that yield. A block MUST be passed, and it will be run at the "apex" of
# the nested hooks.
#
# @param hook [Symbol] Name of hook to call
# @param arg [Object] Argument to hook
# @return [Object] The return value from the block passed to this method
#
def around(hook, arg)
result = nil
@hooks.fetch(hook).reverse.reduce(-> { result = yield }) do |f, ext|
proc do
yielded = false
ext.public_send(hook, arg) { yielded = true; f.call }
raise BlueprinterError, "Extension hook '#{ext.class.name}##{hook}' did not yield" unless yielded
end
end.call
result
end
end
end
4 changes: 2 additions & 2 deletions lib/blueprinter/v2/extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
module Blueprinter
module V2
module Extensions
autoload :Collections, 'blueprinter/v2/extensions/collections'
autoload :Postlude, 'blueprinter/v2/extensions/postlude'
autoload :Prelude, 'blueprinter/v2/extensions/prelude'
autoload :Exclusions, 'blueprinter/v2/extensions/exclusions'
autoload :FieldOrder, 'blueprinter/v2/extensions/field_order'
autoload :Output, 'blueprinter/v2/extensions/output'
autoload :Values, 'blueprinter/v2/extensions/values'
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
module Blueprinter
module V2
module Extensions
class Output < Extension
# Hooks that should run after everything else
class Postlude < Extension
def output_object(ctx)
root_name = ctx.options[:root] || ctx.blueprint.class.options[:root]
return ctx.value if root_name.nil?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
module Blueprinter
module V2
module Extensions
class Collections < Extension
# Hooks that should run before anything else
class Prelude < Extension
def collection?(object)
case object
when Array, Set, Enumerator then true
Expand Down
20 changes: 10 additions & 10 deletions lib/blueprinter/v2/render.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ def to_hash
post_hook = @collection ? :output_collection : :output_object

ctx = Context.new(blueprint, nil, nil, @object, @options, instance_cache, {})
object = @serializer.hooks.reduce_into(pre_hook, ctx, :object)

ctx.value =
if @collection
object.map { |obj| @serializer.call(obj, @options, instance_cache, ctx.store) }
else
@serializer.call(object, @options, instance_cache, ctx.store)
end

@serializer.hooks.reduce_into(post_hook, ctx, :value)
@serializer.hooks.around(:around, ctx) do
object = @serializer.hooks.reduce_into(pre_hook, ctx, :object)
ctx.value =
if @collection
object.map { |obj| @serializer.call(obj, @options, instance_cache, ctx.store) }
else
@serializer.call(object, @options, instance_cache, ctx.store)
end
@serializer.hooks.reduce_into(post_hook, ctx, :value)
end
end

def to_json
Expand Down
15 changes: 13 additions & 2 deletions lib/blueprinter/v2/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class Serializer
attr_reader :blueprint, :formatter, :hooks, :values, :exclusions

def initialize(blueprint)
@hooks = Hooks.new([Extensions::Collections.new] + blueprint.extensions + [Extensions::Output.new])
@hooks = Hooks.new([Extensions::Prelude.new] + blueprint.extensions + [Extensions::Postlude.new])
@formatter = Formatter.new(blueprint)
@blueprint = blueprint
# "Unroll" these hooks for a significant speed boost
Expand All @@ -19,9 +19,19 @@ def initialize(blueprint)
end

def call(object, options, instances, store)
if @run_around_blueprint
ctx = Context.new(instances[blueprint], nil, nil, object, options, instances, store)
hooks.around(:around_blueprint, ctx) { call_blueprint(object, options, instances, store) }
else
call_blueprint(object, options, instances, store)
end
end

private

def call_blueprint(object, options, instances, store)
ctx = Context.new(instances[blueprint], nil, nil, nil, options, instances, store)
store[blueprint.object_id] ||= prepare! ctx

ctx.object = object
hooks.reduce_into(:blueprint_input, ctx, :object) if @run_blueprint_input

Expand Down Expand Up @@ -71,6 +81,7 @@ def prepare!(ctx)
end

def block_unused_hooks!
@run_around_blueprint = hooks.has? :around_blueprint
@run_prepare = hooks.has? :prepare
@run_blueprint_input = hooks.has? :blueprint_input
@run_blueprint_output = hooks.has? :blueprint_output
Expand Down
67 changes: 67 additions & 0 deletions spec/extensions/hooks_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,71 @@ def exclude_field?(context)
expect(result).to eq({ name: 'Foo' })
end
end

context 'around' do
let(:ext_a) do
Class.new(Blueprinter::Extension) do
def initialize(log)
@log = log
end

def around_blueprint(ctx)
@log << "A: #{ctx.store[:value]}"
yield
@log << "A END"
end
end
end

let(:ext_b) do
Class.new(ext_a) do
def around_blueprint(ctx)
@log << "B: #{ctx.store[:value]}"
yield
@log << "B END"
end
end
end

let(:ext_c) do
Class.new(ext_a) do
def around_blueprint(ctx)
@log << "C: #{ctx.store[:value]}"
yield
@log << "C END"
end
end
end

it 'should nest calls' do
log = []
ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, { value: 42 })
hooks = described_class.new [ext_a.new(log), ext_b.new(log), ext_c.new(log)]
hooks.around(:around_blueprint, ctx) { log << 'INNER' }
expect(log).to eq ['A: 42', 'B: 42', 'C: 42', 'INNER', 'C END', 'B END', 'A END',]
end

it 'should return the inner value' do
ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, {})
hooks = described_class.new [ext_a.new([]), ext_b.new([]), ext_c.new([])]
result = hooks.around(:around_blueprint, ctx) { 42 }
expect(result).to eq 42
end

it 'should return the inner with no hooks' do
ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, {})
hooks = described_class.new []
result = hooks.around(:around_blueprint, ctx) { 42 }
expect(result).to eq 42
end

it "should raise if a hook doesn't yield" do
ext = Class.new(Blueprinter::Extension) do
def around_blueprint(_ctx); end
end
ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, {})
hooks = described_class.new [ext.new]
expect { hooks.around(:around_blueprint, ctx) { 42 } }.to raise_error Blueprinter::BlueprinterError
end
end
end
2 changes: 1 addition & 1 deletion spec/v2/extensions/output_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

describe Blueprinter::V2::Extensions::Output do
describe Blueprinter::V2::Extensions::Postlude do
subject { described_class.new }
let(:context) { Blueprinter::V2::Context }
let(:blueprint) do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

require 'ostruct'

describe Blueprinter::V2::Extensions::Collections do
describe Blueprinter::V2::Extensions::Prelude do
include ExtensionHelpers

subject { described_class.new }
Expand Down
45 changes: 45 additions & 0 deletions spec/v2/render_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,49 @@ def input_collection(ctx)
}]
})
end

it 'should run the around hook around all other render hooks' do
ext = Class.new(Blueprinter::Extension) do
def initialize(log)
@log = log
end

def around(ctx)
@log << 'around: a'
yield
@log << 'around: b'
end

def input_object(ctx)
@log << 'input_object'
ctx.object
end

def input_collection(ctx)
@log << 'input_collection'
ctx.object
end

def output_object(ctx)
@log << 'output_object'
ctx.value
end

def output_collection(ctx)
@log << 'output_collection'
ctx.value
end
end
log = []
category_blueprint.extensions << ext.new(log)
serializer = Blueprinter::V2::Serializer.new(category_blueprint)
result = described_class.new({ n: 'Foo' }, {}, serializer: serializer, collection: false).to_hash
expect(result).to eq({ name: 'Foo' })
expect(log).to eq ['around: a', 'input_object', 'output_object', 'around: b']

log.clear
result = described_class.new([{ n: 'Foo' }], {}, serializer: serializer, collection: true).to_hash
expect(result).to eq([{ name: 'Foo' }])
expect(log).to eq ['around: a', 'input_collection', 'output_collection', 'around: b']
end
end
41 changes: 41 additions & 0 deletions spec/v2/serializer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,47 @@ def blueprint_output(_ctx)
expect(result).to eq({ name: 'Foo' })
end

it 'should run around_blueprint around all other serializer hooks' do
ext = Class.new(Blueprinter::Extension) do
def initialize(log)
@log = log
end

def around_blueprint(ctx)
@log << "around_blueprint (#{ctx.object[:name]}): a"
yield
@log << "around_blueprint (#{ctx.object[:name]}): b"
end

def prepare(ctx)
@log << "prepare (#{ctx.object || "sans object"})"
end

def blueprint_input(ctx)
@log << 'blueprint_input'
ctx.object
end

def blueprint_output(ctx)
@log << 'blueprint_output'
ctx.value
end
end
log = []
widget_blueprint.extensions << ext.new(log)
widget = { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }, { num: 43 }] }

result = described_class.new(widget_blueprint).call(widget, {}, instance_cache, {})
expect(result).to eq(widget)
expect(log).to eq [
'around_blueprint (Foo): a',
'prepare (sans object)',
'blueprint_input',
'blueprint_output',
'around_blueprint (Foo): b',
]
end

it 'should put fields in the order they were defined' do
blueprint = Class.new(widget_blueprint) do
field :description
Expand Down

0 comments on commit cced12e

Please sign in to comment.