diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index 08e13484..38300514 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -1,3 +1,19 @@ # frozen_string_literal: true require 'blueprinter/v2/base' + +module Blueprinter + module V2 + module Extensions + autoload :ConditionalFields, 'blueprinter/v2/extensions/conditional_fields' + autoload :DefaultValues, 'blueprinter/v2/extensions/default_values' + autoload :ExcludeIfNil, 'blueprinter/v2/extensions/exclude_if_nil' + + DEFAULT = [ + ConditionalFields, + DefaultValues, + ExcludeIfNil + ].freeze + end + end +end diff --git a/lib/blueprinter/v2/association.rb b/lib/blueprinter/v2/association.rb index 9fc5afe4..5bde8e57 100644 --- a/lib/blueprinter/v2/association.rb +++ b/lib/blueprinter/v2/association.rb @@ -9,6 +9,7 @@ module V2 :legacy_view, :from, :value_proc, + :extractor, :options, keyword_init: true ) diff --git a/lib/blueprinter/v2/base.rb b/lib/blueprinter/v2/base.rb index d5ea288d..b8ca1ac0 100644 --- a/lib/blueprinter/v2/base.rb +++ b/lib/blueprinter/v2/base.rb @@ -2,6 +2,8 @@ require 'blueprinter/v2/dsl' require 'blueprinter/v2/reflection' +require 'blueprinter/v2/render' +require 'blueprinter/v2/serializer' require 'blueprinter/v2/view_builder' module Blueprinter @@ -12,10 +14,12 @@ class Base extend Reflection class << self - # Options set on this Blueprint + # Custom options set on this Blueprint for extensions attr_accessor :options # Extensions set on this Blueprint attr_accessor :extensions + # Extractor class to use by default + attr_accessor :extractor # @api private The fully-qualified name, e.g. "MyBlueprint", or "MyBlueprint.foo.bar" attr_accessor :blueprint_name # @api private @@ -28,6 +32,7 @@ class << self self.partials = {} self.used_partials = [] self.extensions = [] + self.extractor = nil self.options = {} self.blueprint_name = name self.eval_mutex = Mutex.new @@ -40,6 +45,7 @@ def self.inherited(subclass) subclass.partials = partials.dup subclass.used_partials = [] subclass.extensions = extensions.dup + subclass.extractor = extractor subclass.options = options.dup subclass.blueprint_name = subclass.name || blueprint_name subclass.eval_mutex = Mutex.new @@ -77,6 +83,7 @@ def self.[](name) children ? view[children] : view end + # MyBlueprint.render(obj).to_json def self.render(obj, options = {}) if array_like? obj render_collection(obj, options) @@ -85,12 +92,20 @@ def self.render(obj, options = {}) end end + # MyBlueprint.render_object(obj).to_json def self.render_object(obj, options = {}) - # TODO call external renderer + Render.new(obj, options, serializer: serializer, collection: false) end + # MyBlueprint.render_collection(objs).to_json def self.render_collection(objs, options = {}) - # TODO call external renderer + Render.new(obj, options, serializer: serializer, collection: true) + end + + # @api private + def self.serializer + eval! unless @evaled + @serializer end # Apply partials and field exclusions @@ -115,6 +130,7 @@ def self.run_eval! end excludes.each { |f| fields.delete f } + @serializer = Serializer.new(self) @evaled = true end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index 5ea8ba24..9918b00e 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -43,14 +43,16 @@ def use(*names) # # @param name [Symbol] Name of the field # @param from [Symbol] Optionally specify a different method to call to get the value for "name" + # @param extractor [Class] Extractor class to use for this field # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Field] # - def field(name, from: name, **options, &definition) + def field(name, from: name, extractor: nil, **options, &definition) fields[name.to_sym] = Field.new( name: name, from: from, value_proc: definition, + extractor: extractor, options: options.dup ) end @@ -62,10 +64,11 @@ def field(name, from: name, **options, &definition) # @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc # @param view [Symbol] Only for use with legacy (not V2) blueprints # @param from [Symbol] Optionally specify a different method to call to get the value for "name" + # @param extractor [Class] Extractor class to use for this association # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Association] # - def object(name, blueprint, from: name, view: nil, **options, &definition) + def object(name, blueprint, from: name, view: nil, extractor: nil, **options, &definition) raise ArgumentError, 'The :view argument may not be used with V2 Blueprints' if view && blueprint.is_a?(V2) fields[name.to_sym] = Association.new( @@ -75,6 +78,7 @@ def object(name, blueprint, from: name, view: nil, **options, &definition) legacy_view: view, from: from, value_proc: definition, + extractor: extractor, options: options.dup ) end @@ -86,10 +90,11 @@ def object(name, blueprint, from: name, view: nil, **options, &definition) # @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc # @param view [Symbol] Only for use with legacy (not V2) blueprints # @param from [Symbol] Optionally specify a different method to call to get the value for "name" + # @param extractor [Class] Extractor class to use for this association # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Association] # - def collection(name, blueprint, from: name, view: nil, **options, &definition) + def collection(name, blueprint, from: name, view: nil, extractor: nil, **options, &definition) raise ArgumentError, 'The :view argument may not be used with V2 Blueprints' if view && blueprint.is_a?(V2) fields[name.to_sym] = Association.new( @@ -99,6 +104,7 @@ def collection(name, blueprint, from: name, view: nil, **options, &definition) legacy_view: view, from: from, value_proc: definition, + extractor: extractor, options: options.dup ) end diff --git a/lib/blueprinter/v2/extension.rb b/lib/blueprinter/v2/extension.rb new file mode 100644 index 00000000..0783f60f --- /dev/null +++ b/lib/blueprinter/v2/extension.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + class Extension + class << self + attr_accessor :formatters + end + + def self.inherited(ext) + ext.formatters = {} + end + + # + # Add a formatter for instances of the given class. + # + # Example: + # class MyExtension < Blueprinter::V2::Extension + # format(Time) { |context| context.value.iso8601 } + # format Date, :date_str + # + # def date_str(context) + # context.value.iso8601 + # end + # end + # + # @param klass [Class] The class of objects to format + # @param formatter_method [Symbol] Name of a public instance method to call for formatting + # @yield Do formatting in the block instead + # + def self.format(klass, formatter_method = nil, &formatter_block) + formatters[klass] = formatter_method || formatter_block + end + + # Returning true will exclude this field and value from the results + # @param _context [Blueprinter::V2::Serializer::Context] + # @return [Boolean] + def exclude_field?(_context) + false + end + + # Returning true will exclude this association and value from the results + # @param _context [Blueprinter::V2::Serializer::Context] + # @return [Boolean] + def exclude_object?(_context) + false + end + + # Returning true will exclude this association and value from the results + # @param _context [Blueprinter::V2::Serializer::Context] + # @return [Boolean] + def exclude_collection?(_context) + false + end + + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + def field_value(context) + context.value + end + + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + def object_value(context) + context.value + end + + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + def collection_value(context) + context.value + end + + # Modify or replace the object passed to render/render_object/render_collection + def input(_blueprint, object, _options) + object + end + + # Modify or replace the result before final render (e.g. to JSON) + def output(_blueprint, result, _options) + result + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/conditional_fields.rb b/lib/blueprinter/v2/extensions/conditional_fields.rb new file mode 100644 index 00000000..4155663b --- /dev/null +++ b/lib/blueprinter/v2/extensions/conditional_fields.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + class ConditionalFields < ::Blueprinter::V2::Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_field?(ctx) + if (cond = ctx.options[:field_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:field_if]) + ctx.blueprint.instance_exec(ctx, &cond) + elsif (cond = ctx.options[:field_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:field_unless]) + !ctx.blueprint.instance_exec(ctx, &cond) + else + false + end + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_object?(ctx) + if (cond = ctx.options[:object_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:object_if]) + ctx.blueprint.instance_exec(ctx, &cond) + elsif (cond = ctx.options[:object_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:object_unless]) + !ctx.blueprint.instance_exec(ctx, &cond) + else + false + end + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_collection?(ctx) + if (cond = ctx.options[:collection_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:collection_if]) + ctx.blueprint.instance_exec(ctx, &cond) + elsif (cond = ctx.options[:collection_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:collection_unless]) + !ctx.blueprint.instance_exec(ctx, &cond) + else + false + end + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/default_values.rb b/lib/blueprinter/v2/extensions/default_values.rb new file mode 100644 index 00000000..a2a962ed --- /dev/null +++ b/lib/blueprinter/v2/extensions/default_values.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + class DefaultValues < ::Blueprinter::V2::Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def field_value(ctx) + default_if = ctx.options[:field_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:field_default_if] + return ctx.value unless ctx.value.nil? || default_if&.call(ctx.value) + + ctx.options[:field_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:field_default] + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def object_value(ctx) + default_if = ctx.options[:object_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:object_default_if] + return ctx.value unless ctx.value.nil? || default_if&.call(ctx.value) + + ctx.options[:object_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:object_default] + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def collection_value(ctx) + default_if = ctx.options[:collection_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:collection_default_if] + return ctx.value unless ctx.value.nil? || default_if&.call(ctx.value) + + ctx.options[:collection_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:collection_default] + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/exclude_if_nil.rb b/lib/blueprinter/v2/extensions/exclude_if_nil.rb new file mode 100644 index 00000000..64b2a2b2 --- /dev/null +++ b/lib/blueprinter/v2/extensions/exclude_if_nil.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + class ExcludeIfNil < Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_field?(ctx) + ctx.value.nil? && (ctx.options[:exclude_if_nil] || ctx.field.options[:exclude_if_nil] || ctx.blueprint.class.options[:exclude_if_nil]) + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_object?(...) + exclude_field?(...) + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_collection?(...) + exclude_field?(...) + end + end + end + end +end diff --git a/lib/blueprinter/v2/extractor.rb b/lib/blueprinter/v2/extractor.rb new file mode 100644 index 00000000..b4d96d3e --- /dev/null +++ b/lib/blueprinter/v2/extractor.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # The default extractor, and the suggested base class for custom extractors + class Extractor + def field(blueprint_instance, field, object, options) + if field.value_proc + blueprint_instance.instance_exec(object, options, &field.value_proc) + elsif object.is_a? Hash + object[field.from] + else + object.public_send(field.from) + end + end + + def object(blueprint_instance, field, object, options) + field(blueprint_instance, field, object, options) + end + + def collection(blueprint_instance, field, object, options) + field(blueprint_instance, field, object, options) + end + end + end +end diff --git a/lib/blueprinter/v2/field.rb b/lib/blueprinter/v2/field.rb index 58e8891e..b5c3d63a 100644 --- a/lib/blueprinter/v2/field.rb +++ b/lib/blueprinter/v2/field.rb @@ -6,6 +6,7 @@ module V2 :name, :from, :value_proc, + :extractor, :options, keyword_init: true ) diff --git a/lib/blueprinter/v2/formatter.rb b/lib/blueprinter/v2/formatter.rb new file mode 100644 index 00000000..c4fa596e --- /dev/null +++ b/lib/blueprinter/v2/formatter.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' +require 'blueprinter/v2/extractor' + +module Blueprinter + module V2 + # An interface for formatting values against extensions + class Formatter + def initialize(extensions) + @extensions = extensions + end + + # @param context [Blueprinter::V2::Serializer::Context] + def call(context) + fmt = formatters[context.value.class] + fmt ? fmt.call(context) : context.value + end + + private + + # @return [Hash] + def formatters + @formatters ||= @extensions.reduce({}) do |acc, ext| + fmts = ext.class.formatters.transform_values do |fmt| + fmt.is_a?(Proc) ? fmt : ext.public_method(fmt) + end + acc.merge(fmts) + end + end + end + end +end diff --git a/lib/blueprinter/v2/hooks.rb b/lib/blueprinter/v2/hooks.rb new file mode 100644 index 00000000..da55f5df --- /dev/null +++ b/lib/blueprinter/v2/hooks.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + # An interface for running extension hooks + class Hooks + def initialize(extensions) + @extensions = extensions + end + + # Return true if any of "hook" returns truthy + def any?(hook, *args) + hooks.fetch(hook).any? { |h| h.call(*args) } + end + + # Reduce the initial value (plus args) through all instances of "hook" + def reduce(hook, initial_val) + hooks.fetch(hook).reduce(initial_val) do |acc, h| + args = yield acc + args.is_a?(Array) ? h.call(*args) : h.call(args) + end + end + + private + + # @return [Hash] + def hooks + @hooks ||= Extension.public_instance_methods(false).each_with_object({}) do |hook, acc| + acc[hook] = @extensions. + select { |ext| ext.class.public_instance_methods(false).include? hook }. + map { |ext| ext.public_method(hook) } + end + end + end + end +end diff --git a/lib/blueprinter/v2/instance_cache.rb b/lib/blueprinter/v2/instance_cache.rb new file mode 100644 index 00000000..3b00e4e5 --- /dev/null +++ b/lib/blueprinter/v2/instance_cache.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + class InstanceCache + def initialize + @cache = {} + end + + def [](obj) + if obj.is_a? Class + @cache[obj] ||= obj.new + else + obj + end + end + end + end +end diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb new file mode 100644 index 00000000..5fc71443 --- /dev/null +++ b/lib/blueprinter/v2/render.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/instance_cache' + +module Blueprinter + module V2 + class Render + def initialize(obj, options, serializer:, collection:) + @obj = obj + @options = options + @serializer = serializer + @collection = collection + end + + def to_hash + instance_cache = InstanceCache.new + blueprint = instance_cache[@serializer.blueprint] + obj = @serializer.hooks.reduce(:input, @obj) { |acc| [blueprint, acc, @options] } + + result = + if @collection + obj.each.map { |o| @serializer.call(o, @options, instance_cache) } + else + @serializer.call(obj, @options, instance_cache) + end + + @serializer.hooks.reduce(:output, result) { |acc| [blueprint, acc, @options] } + end + + def to_json + # TODO MultiJson.dump to_hash + to_hash.to_json + end + end + end +end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb new file mode 100644 index 00000000..db27b5fb --- /dev/null +++ b/lib/blueprinter/v2/serializer.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extractor' +require 'blueprinter/v2/formatter' +require 'blueprinter/v2/hooks' + +module Blueprinter + module V2 + class Serializer + Context = Struct.new(:blueprint, :field, :value, :object, :options) + + attr_reader :blueprint, :formatter, :hooks, :default_extractor + + def initialize(blueprint) + extensions = blueprint.extensions + Extensions::DEFAULT.map(&:new) + @formatter = Formatter.new extensions + @hooks = Hooks.new extensions + @default_extractor = blueprint.extractor || Extractor + @blueprint = blueprint + end + + def call(obj, options, instance_cache) + bp = instance_cache[blueprint] + result = blueprint.reflections[:default].fields.each_with_object({}) do |(_, field), acc| + val = extract_field(field, obj, options, instance_cache) + val = hooks.reduce(:field_value, val) { |acc| Context.new(bp, field, acc, obj, options) } + acc[field.name] = val unless hooks.any?(:exclude_field?, Context.new(bp, field, val, obj, options)) + end + + result = blueprint.reflections[:default].objects.each_with_object(result) do |(_, field), acc| + val = extract_object(field, obj, options, instance_cache) + val = hooks.reduce(:object_value, val) { |acc| Context.new(bp, field, acc, obj, options) } + acc[field.name] = val unless hooks.any?(:exclude_object?, Context.new(bp, field, val, obj, options)) + end + + blueprint.reflections[:default].collections.each_with_object(result) do |(_, field), acc| + val = extract_collection(field, obj, options, instance_cache) + val = hooks.reduce(:collection_value, val) { |acc| Context.new(bp, field, acc, obj, options) } + acc[field.name] = val unless hooks.any?(:exclude_collection?, Context.new(bp, field, val, obj, options)) + end + end + + private + + def extract_field(field, obj, options, instance_cache) + bp = instance_cache[blueprint] + extractor = instance_cache[field.extractor || default_extractor] + val = extractor.field(bp, field, obj, options) + formatter.call(Context.new(bp, field, val, obj, options)) + end + + def extract_object(field, obj, options, instance_cache) + extractor = instance_cache[field.extractor || default_extractor] + val = extractor.object(instance_cache[blueprint], field, obj, options) + # TODO support V1 blueprints + field.blueprint.serializer.call(val, options, instance_cache) + end + + def extract_collection(field, obj, options, instance_cache) + extractor = instance_cache[field.extractor || default_extractor] + val = extractor.collection(instance_cache[blueprint], field, obj, options) + # TODO support V1 blueprints + val.each.map { |v| field.blueprint.serializer.call(v, options, instance_cache) } + end + end + end +end diff --git a/spec/v2/extension_spec.rb b/spec/v2/extension_spec.rb new file mode 100644 index 00000000..9e8e91b9 --- /dev/null +++ b/spec/v2/extension_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'date' + +describe Blueprinter::V2::Extension do + subject { Class.new(described_class) } + + context 'format' do + it 'should add a block formatter' do + iso8601 = ->(x, _opts) { x.iso8601 } + subject.format(Date, &iso8601) + subject.format(Time, &iso8601) + + expect(subject.formatters[Date]).to eq iso8601 + expect(subject.formatters[Time]).to eq iso8601 + end + + it 'should add a method formatter' do + subject.format(Date, :fmt_date) + subject.format(Time, :fmt_time) + + expect(subject.formatters[Date]).to eq :fmt_date + expect(subject.formatters[Time]).to eq :fmt_time + end + end + + context 'hooks' do + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :foo, from: :foo) } + let(:object) { { foo: 'Foo' } } + let(:context) { Blueprinter::V2::Serializer::Context } + + it 'should default exclude_field? to false' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.exclude_field?(ctx)).to be false + end + + it 'should default exclude_object? to false' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.exclude_object?(ctx)).to be false + end + + it 'should default exclude_collection? to false' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.exclude_collection?(ctx)).to be false + end + + it 'should default input to the given object' do + expect(subject.new.input(blueprint.new, object, {})).to eq object + end + + it 'should default field_value to the given value' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.field_value(ctx)).to be 'Foo' + end + + it 'should default object_value to the given value' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.object_value(ctx)).to be 'Foo' + end + + it 'should default collection_value to the given value' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.collection_value(ctx)).to be 'Foo' + end + + it 'should default output to the given object' do + expect(subject.new.output(blueprint.new, object, {})).to eq object + end + end +end diff --git a/spec/v2/extractor_spec.rb b/spec/v2/extractor_spec.rb new file mode 100644 index 00000000..3e87c3f3 --- /dev/null +++ b/spec/v2/extractor_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extractor do + subject { described_class.new } + + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def upcase(str) + str.upcase + end + end + end + + context 'field' do + it "should extract using a block" do + field = Blueprinter::V2::Field.new(name: :foo, from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) + obj = { foo: 'bar' } + val = subject.field(blueprint.new, field, obj, {}) + expect(val).to eq 'BAR' + end + + it "should extract using a Hash key" do + field = Blueprinter::V2::Field.new(name: :foo, from: :foo) + obj = { foo: 'bar' } + val = subject.field(blueprint.new, field, obj, {}) + expect(val).to eq 'bar' + end + + it "should extract using a method name" do + field = Blueprinter::V2::Field.new(name: :name, from: :name) + obj = Struct.new(:name).new("Foo") + val = subject.field(blueprint.new, field, obj, {}) + expect(val).to eq 'Foo' + end + end + + context 'object' do + it "should extract using a block" do + field = Blueprinter::V2::Association.new(name: :foo, from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) + obj = { foo: 'bar' } + val = subject.object(blueprint.new, field, obj, {}) + expect(val).to eq 'BAR' + end + + it "should extract using a Hash key" do + field = Blueprinter::V2::Field.new(name: :foo, from: :foo) + obj = { foo: 'bar' } + val = subject.object(blueprint.new, field, obj, {}) + expect(val).to eq 'bar' + end + + it "should extract using a method name" do + field = Blueprinter::V2::Field.new(name: :name, from: :name) + obj = Struct.new(:name).new("Foo") + val = subject.object(blueprint.new, field, obj, {}) + expect(val).to eq 'Foo' + end + end + + context 'collection' do + it "should extract using a block" do + field = Blueprinter::V2::Association.new(name: :foo, from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) + obj = { foo: 'bar' } + val = subject.collection(blueprint.new, field, obj, {}) + expect(val).to eq 'BAR' + end + + it "should extract using a Hash key" do + field = Blueprinter::V2::Field.new(name: :foo, from: :foo) + obj = { foo: 'bar' } + val = subject.collection(blueprint.new, field, obj, {}) + expect(val).to eq 'bar' + end + + it "should extract using a method name" do + field = Blueprinter::V2::Field.new(name: :name, from: :name) + obj = Struct.new(:name).new("Foo") + val = subject.collection(blueprint.new, field, obj, {}) + expect(val).to eq 'Foo' + end + end +end diff --git a/spec/v2/formatter_spec.rb b/spec/v2/formatter_spec.rb new file mode 100644 index 00000000..0338db42 --- /dev/null +++ b/spec/v2/formatter_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'date' + +describe Blueprinter::V2::Formatter do + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :foo, from: :foo) } + let(:object) { { foo: 'Foo' } } + let(:context) { Blueprinter::V2::Serializer::Context } + + it 'should call formatters' do + ext1 = Class.new(Blueprinter::V2::Extension) do + format(Date) { |context| context.value.iso8601 } + end + ext2 = Class.new(Blueprinter::V2::Extension) do + format TrueClass, :yes + format FalseClass, :no + + def yes(_context) + "Yes" + end + + def no(_context) + "No" + end + end + + formatter = described_class.new([ext1.new, ext2.new]) + expect(formatter.call(context.new(blueprint.new, field, Date.new(2024, 10, 1), object, {}))).to eq '2024-10-01' + expect(formatter.call(context.new(blueprint.new, field, true, object, {}))).to eq "Yes" + expect(formatter.call(context.new(blueprint.new, field, false, object, {}))).to eq "No" + expect(formatter.call(context.new(blueprint.new, field, "foo", object, {}))).to eq "foo" + end +end diff --git a/spec/v2/hooks_spec.rb b/spec/v2/hooks_spec.rb new file mode 100644 index 00000000..22359498 --- /dev/null +++ b/spec/v2/hooks_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Hooks do + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :foo, from: :foo) } + let(:object) { { foo: 'Foo' } } + let(:context) { Blueprinter::V2::Serializer::Context } + + it 'should extract hooks' do + ext1 = Class.new(Blueprinter::V2::Extension) do + def input(_blueprint, obj, _opts) + obj[:bar] = 'Bar' if obj[:foo] + obj + end + + def exclude_field?(context) + context.value.nil? || !!context.options[:always_include] + end + end + + ext2 = Class.new(Blueprinter::V2::Extension) do + def exclude_field?(context) + context.value == "" || context.value == [] + end + end + + hooks = described_class.new [ext1.new, ext2.new] + expect(hooks.reduce(:input, { foo: 'Foo' }) { |acc| [blueprint.new, acc, {}] }).to eq({ foo: 'Foo', bar: 'Bar' }) + expect(hooks.reduce(:input, { zorp: 'Zorp' }) { |acc| [blueprint.new, acc, {}] }).to eq({ zorp: 'Zorp' }) + expect(hooks.any?(:exclude_field?, context.new(blueprint.new, field, :foo, object, {}))).to be false + expect(hooks.any?(:exclude_field?, context.new(blueprint.new, field, nil, object, {}))).to be true + expect(hooks.any?(:exclude_field?, context.new(blueprint.new, field, "", object, {}))).to be true + end + + it 'should work with no extensions' do + hooks = described_class.new [] + expect(hooks.reduce(:input, { foo: 'Foo' }) { |acc| [blueprint.new, acc, {}] }).to eq({ foo: 'Foo' }) + expect(hooks.any?(:exclude_field?, context.new(blueprint.new, field, :foo, object, {}))).to be false + end +end diff --git a/spec/v2/instance_cache_spec.rb b/spec/v2/instance_cache_spec.rb new file mode 100644 index 00000000..3292d6be --- /dev/null +++ b/spec/v2/instance_cache_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::InstanceCache do + subject { described_class.new } + + it "should return a new instance" do + klass = Class.new + expect(subject[klass]).to be_a klass + end + + it "should return the cached instance" do + klass = Class.new + res1 = subject[klass] + res2 = subject[klass] + expect(res2.object_id).to eq res1.object_id + end + + it "should return x if x is an instance" do + x = proc { "foo" } + expect(subject[x]).to eq x + end +end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb new file mode 100644 index 00000000..3f6bf497 --- /dev/null +++ b/spec/v2/serializer_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Serializer do + let(:category_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + end + end + + let(:part_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :num + end + end + + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + + it 'should serialize a basic blueprint' do + test = self + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + object :category, test.category_blueprint + collection :parts, test.part_blueprint + end + widget = { + name: 'Foo', + extra: 'bar', + category: { name: 'Bar', extra: 'bar' }, + parts: [{ num: 42, extra: 'bar' }] + } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + category: { name: 'Bar' }, + parts: [{ num: 42 }] + }) + end +end