From 3c3af7014de5f0b951b97cfc133a226fb8daeab0 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 15 Nov 2024 23:51:43 -0500 Subject: [PATCH] Add around and around_blueprint hooks (primarily for instrumenting) Signed-off-by: Jordan Hollinger --- lib/blueprinter/extension.rb | 36 ++++++---- lib/blueprinter/hooks.rb | 20 ++++++ lib/blueprinter/v2/extensions.rb | 4 +- .../v2/extensions/{output.rb => postlude.rb} | 3 +- .../extensions/{collections.rb => prelude.rb} | 3 +- lib/blueprinter/v2/render.rb | 20 +++--- lib/blueprinter/v2/serializer.rb | 15 ++++- spec/extensions/hooks_spec.rb | 67 +++++++++++++++++++ spec/v2/extensions/output_spec.rb | 2 +- .../{collections_spec.rb => prelude_spec.rb} | 2 +- spec/v2/render_spec.rb | 45 +++++++++++++ spec/v2/serializer_spec.rb | 41 ++++++++++++ 12 files changed, 227 insertions(+), 31 deletions(-) rename lib/blueprinter/v2/extensions/{output.rb => postlude.rb} (87%) rename lib/blueprinter/v2/extensions/{collections.rb => prelude.rb} (82%) rename spec/v2/extensions/{collections_spec.rb => prelude_spec.rb} (96%) diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index d5db666e..fdc83cbd 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -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 @@ -33,6 +35,14 @@ def collection?(_object) false end + def around(_context) + yield + end + + 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. @@ -49,7 +59,7 @@ def prepare(context); end # @param context [Blueprinter::V2::Context] # @return [Array] # - def blueprint_fields(ctx) + def blueprint_fields(context) [] end diff --git a/lib/blueprinter/hooks.rb b/lib/blueprinter/hooks.rb index 28096352..1e04268b 100644 --- a/lib/blueprinter/hooks.rb +++ b/lib/blueprinter/hooks.rb @@ -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 diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index 6565309b..59676f68 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -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 diff --git a/lib/blueprinter/v2/extensions/output.rb b/lib/blueprinter/v2/extensions/postlude.rb similarity index 87% rename from lib/blueprinter/v2/extensions/output.rb rename to lib/blueprinter/v2/extensions/postlude.rb index 2ea48341..1eb1a228 100644 --- a/lib/blueprinter/v2/extensions/output.rb +++ b/lib/blueprinter/v2/extensions/postlude.rb @@ -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? diff --git a/lib/blueprinter/v2/extensions/collections.rb b/lib/blueprinter/v2/extensions/prelude.rb similarity index 82% rename from lib/blueprinter/v2/extensions/collections.rb rename to lib/blueprinter/v2/extensions/prelude.rb index 52e30846..aececff7 100644 --- a/lib/blueprinter/v2/extensions/collections.rb +++ b/lib/blueprinter/v2/extensions/prelude.rb @@ -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 diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb index 2bbf894e..d4858faa 100644 --- a/lib/blueprinter/v2/render.rb +++ b/lib/blueprinter/v2/render.rb @@ -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 diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index ecf2b905..ae55ce5c 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/extensions/hooks_spec.rb b/spec/extensions/hooks_spec.rb index 10932b44..7c13eb45 100644 --- a/spec/extensions/hooks_spec.rb +++ b/spec/extensions/hooks_spec.rb @@ -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 diff --git a/spec/v2/extensions/output_spec.rb b/spec/v2/extensions/output_spec.rb index fe906167..8f9ebd55 100644 --- a/spec/v2/extensions/output_spec.rb +++ b/spec/v2/extensions/output_spec.rb @@ -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 diff --git a/spec/v2/extensions/collections_spec.rb b/spec/v2/extensions/prelude_spec.rb similarity index 96% rename from spec/v2/extensions/collections_spec.rb rename to spec/v2/extensions/prelude_spec.rb index 78fbcc9f..40e95e93 100644 --- a/spec/v2/extensions/collections_spec.rb +++ b/spec/v2/extensions/prelude_spec.rb @@ -2,7 +2,7 @@ require 'ostruct' -describe Blueprinter::V2::Extensions::Collections do +describe Blueprinter::V2::Extensions::Prelude do include ExtensionHelpers subject { described_class.new } diff --git a/spec/v2/render_spec.rb b/spec/v2/render_spec.rb index 2158586a..673e7b47 100644 --- a/spec/v2/render_spec.rb +++ b/spec/v2/render_spec.rb @@ -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 diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index 94155f58..e8cc9474 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -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