diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index 08e13484..73487065 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true +require 'blueprinter/v2/extensions' require 'blueprinter/v2/base' diff --git a/lib/blueprinter/v2/association.rb b/lib/blueprinter/v2/association.rb deleted file mode 100644 index 9fc5afe4..00000000 --- a/lib/blueprinter/v2/association.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - Association = Struct.new( - :name, - :blueprint, - :collection, - :legacy_view, - :from, - :value_proc, - :options, - keyword_init: true - ) - end -end diff --git a/lib/blueprinter/v2/base.rb b/lib/blueprinter/v2/base.rb index d5ea288d..07eed96c 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,7 +14,7 @@ class Base extend Reflection class << self - # Options set on this Blueprint + # Custom options set on this Blueprint attr_accessor :options # Extensions set on this Blueprint attr_accessor :extensions @@ -77,20 +79,29 @@ def self.[](name) children ? view[children] : view end + # MyBlueprint.render(obj).to_json def self.render(obj, options = {}) - if array_like? obj + if serializer.hooks.any?(:collection?, obj) render_collection(obj, options) else render_object(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(objs, options, serializer: serializer, collection: true) + end + + # @api private + def self.serializer + eval! unless @evaled + @serializer end # Apply partials and field exclusions @@ -115,12 +126,16 @@ def self.run_eval! end excludes.each { |f| fields.delete f } - @evaled = true - end + extensions.freeze + options.freeze + fields.freeze + fields.each do |_, f| + f.options&.freeze + f.freeze + end - # @api private - def self.array_like?(obj) - # TODO + @serializer = Serializer.new(self) + @evaled = true end end end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index 5ea8ba24..8a26624e 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'blueprinter/v2/association' -require 'blueprinter/v2/field' +require 'blueprinter/v2/fields' module Blueprinter module V2 @@ -43,6 +42,13 @@ 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 association + # @param if [Proc] TODO + # @param unless [Proc] TODO + # @param default [Object | Proc] TODO + # @param default_if [Proc] TODO + # @param exclude_if_nil [Boolean] TODO + # @param exclude_if_empty [Boolean] TODO # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Field] # @@ -62,16 +68,22 @@ 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 + # @param if [Proc] TODO + # @param unless [Proc] TODO + # @param default [Object | Proc] TODO + # @param default_if [Proc] TODO + # @param exclude_if_nil [Boolean] TODO + # @param exclude_if_empty [Boolean] TODO # @yield [TODO] Generate the value from the block - # @return [Blueprinter::V2::Association] + # @return [Blueprinter::V2::ObjectField] # def object(name, blueprint, from: name, view: 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( + fields[name.to_sym] = ObjectField.new( name: name, blueprint: blueprint, - collection: false, legacy_view: view, from: from, value_proc: definition, @@ -86,16 +98,22 @@ 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 + # @param if [Proc] TODO + # @param unless [Proc] TODO + # @param default [Object | Proc] TODO + # @param default_if [Proc] TODO + # @param exclude_if_nil [Boolean] TODO + # @param exclude_if_empty [Boolean] TODO # @yield [TODO] Generate the value from the block - # @return [Blueprinter::V2::Association] + # @return [Blueprinter::V2::Collection] # - def collection(name, blueprint, from: name, view: nil, **options, &definition) + def collection(name, blueprint, from: name, view: 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( + fields[name.to_sym] = Collection.new( name: name, blueprint: blueprint, - collection: true, legacy_view: view, from: from, value_proc: definition, diff --git a/lib/blueprinter/v2/extension.rb b/lib/blueprinter/v2/extension.rb new file mode 100644 index 00000000..4ee18cde --- /dev/null +++ b/lib/blueprinter/v2/extension.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # + # Base class for extensions. + # + # Hook call order: + # - collection? (skipped with render_object/render_collection) + # - input_object | input_collection + # - each_input + # - field_value + # - formatters + # - exclude_field? + # - object_value + # - exclude_object? + # - collection_value + # - exclude_collection? + # - each_output + # - output_object | output_collection + # + 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 + + # + # Returns true if the given object should be considered a collection. + # + # @param [Object] + # @return [Boolean] + # + def collection?(_object) + false + end + + # + # Modify or replace the object passed to render/render_object. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def input_object(context) + context.object + end + + + # + # Modify or replace the collection passed to render/render_collection. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def input_collection(context) + context.object + end + + # + # Modify or replace the object result before final render (e.g. to JSON). + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def output_object(context) + context.value + end + + # + # Modify or replace the collection result before final render (e.g. to JSON). + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def output_collection(context) + context.value + end + + # + # Modify or replace the object that's passed into each Blueprint. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def each_input(context) + context.object + end + + # + # Modify or replace the output from each Blueprint. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def each_output(context) + context.value + end + + # + # Modify or replace the value used for the field. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def field_value(context) + context.value + end + + # + # Modify or replace the value used for the object. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def object_value(context) + context.value + end + + # + # Modify or replace the value used for the collection. + # + # @param context [Blueprinter::V2::Serializer::Context] + # @return [Object] + # + def collection_value(context) + context.value + end + + # + # Return true to exclude this field from the result. + # + # @param _context [Blueprinter::V2::Serializer::Context] + # @return [Boolean] + # + def exclude_field?(_context) + false + end + + # + # Return true to exclude this object from the result. + # + # @param _context [Blueprinter::V2::Serializer::Context] + # @return [Boolean] + # + def exclude_object?(_context) + false + end + + # + # Return true to exclude this collection from the result. + # + # @param _context [Blueprinter::V2::Serializer::Context] + # @return [Boolean] + # + def exclude_collection?(_context) + false + end + end + end +end diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb new file mode 100644 index 00000000..e4c37cbc --- /dev/null +++ b/lib/blueprinter/v2/extensions.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + autoload :DefaultValues, 'blueprinter/v2/extensions/default_values' + autoload :ExcludeIfEmpty, 'blueprinter/v2/extensions/exclude_if_empty' + autoload :ExcludeIfNil, 'blueprinter/v2/extensions/exclude_if_nil' + autoload :Extraction, 'blueprinter/v2/extensions/extraction' + autoload :FieldSorter, 'blueprinter/v2/extensions/field_sorter' + autoload :IfConditionals, 'blueprinter/v2/extensions/if_conditionals' + autoload :NestedBlueprints, 'blueprinter/v2/extensions/nested_blueprints' + autoload :RootElement, 'blueprinter/v2/extensions/root_element' + autoload :UnlessConditionals, 'blueprinter/v2/extensions/unless_conditionals' + 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..638a64d5 --- /dev/null +++ b/lib/blueprinter/v2/extensions/default_values.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + # An extension providing options for default values + class DefaultValues < Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def field_value(ctx) + use_default = ctx.options[:field_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:field_default_if] + return ctx.value unless ctx.value.nil? || (use_default && ctx.blueprint.instance_exec(ctx, &use_default)) + + val = ctx.options[:field_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:field_default] + val.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &val) : val + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def object_value(ctx) + use_default = ctx.options[:object_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:object_default_if] + return ctx.value unless ctx.value.nil? || (use_default && ctx.blueprint.instance_exec(ctx, &use_default)) + + val = ctx.options[:object_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:object_default] + val.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &val) : val + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def collection_value(ctx) + use_default = ctx.options[:collection_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:collection_default_if] + return ctx.value unless ctx.value.nil? || (use_default && ctx.blueprint.instance_exec(ctx, &use_default)) + + val = ctx.options[:collection_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:collection_default] + val.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &val) : val + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/exclude_if_empty.rb b/lib/blueprinter/v2/extensions/exclude_if_empty.rb new file mode 100644 index 00000000..b43839e8 --- /dev/null +++ b/lib/blueprinter/v2/extensions/exclude_if_empty.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + # An extension providing "exclude_if_empty" options + class ExcludeIfEmpty < Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_field?(ctx) + if ctx.options[:exclude_if_empty] || ctx.field.options[:exclude_if_empty] || ctx.blueprint.class.options[:exclude_if_empty] + ctx.value.nil? || (ctx.value.respond_to?(:empty?) && ctx.value.empty?) + else + false + end + 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/extensions/exclude_if_nil.rb b/lib/blueprinter/v2/extensions/exclude_if_nil.rb new file mode 100644 index 00000000..709a8c46 --- /dev/null +++ b/lib/blueprinter/v2/extensions/exclude_if_nil.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + # An extension providing "exclude_if_nil" options + 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/extensions/extraction.rb b/lib/blueprinter/v2/extensions/extraction.rb new file mode 100644 index 00000000..74d540c2 --- /dev/null +++ b/lib/blueprinter/v2/extensions/extraction.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' +require 'blueprinter/v2/extractor' + +module Blueprinter + module V2 + module Extensions + # Extracts field values from objects. Should be the FIRST extension called by the serializer. + class Extraction < Extension + def collection?(object) + case object + when Array, Set then true + else false + end + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def field_value(ctx) + extractor = get_extractor ctx + extractor.field(ctx.blueprint, ctx.field, ctx.object, ctx.options) + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def object_value(ctx) + extractor = get_extractor ctx + val = extractor.object(ctx.blueprint, ctx.field, ctx.object, ctx.options) + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def collection_value(ctx) + extractor = get_extractor ctx + extractor.collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) + end + + private + + def get_extractor(ctx) + klass = ctx.field.options[:extractor] || ctx.blueprint.class.options[:extractor] || Extractor + ctx.instances[klass] + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/field_sorter.rb b/lib/blueprinter/v2/extensions/field_sorter.rb new file mode 100644 index 00000000..73162e6a --- /dev/null +++ b/lib/blueprinter/v2/extensions/field_sorter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + class FieldSorter < Extension + def initialize(&sorter) + @sorter = sorter + end + + def each_output(ctx) + fields = ctx.blueprint.class.reflections[:default].all_fields.values + value = fields.sort(&@sorter).each_with_object({}) do |field, acc| + if ctx.value.has_key? field.name + acc[field.name] = ctx.value[field.name] + end + end + + extra = ctx.value.reject { |k, _| value.key? k } + value.merge! extra + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/if_conditionals.rb b/lib/blueprinter/v2/extensions/if_conditionals.rb new file mode 100644 index 00000000..617fe629 --- /dev/null +++ b/lib/blueprinter/v2/extensions/if_conditionals.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + # An extension providing "if" options + class IfConditionals < 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) + 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) + 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) + else + false + end + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/nested_blueprints.rb b/lib/blueprinter/v2/extensions/nested_blueprints.rb new file mode 100644 index 00000000..cb396838 --- /dev/null +++ b/lib/blueprinter/v2/extensions/nested_blueprints.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + # Serializes object/collection values using their Blueprint. Should be the FINAL extension called by the serializer. + class NestedBlueprints < Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def object_value(ctx) + return nil if ctx.value.nil? + + serialize(ctx.value, ctx.field, ctx.options, ctx.instances) + end + + # @param ctx [Blueprinter::V2::Serializer::Context] + def collection_value(ctx) + return nil if ctx.value.nil? + + ctx.value.each.map do |obj| + serialize(obj, ctx.field, ctx.options, ctx.instances) + end + end + + private + + def serialize(obj, field, options, instance_cache) + case instance_cache[field.blueprint] + when ::Blueprinter::V2::Base + field.blueprint.serializer.call(obj, options, instance_cache) + when ::Blueprinter::Base + raise NotImplementedError, "V1 is not yet supported" + else + raise "Blueprint class '#{field.blueprint}' does not inherit from a supported base class" + end + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/root_element.rb b/lib/blueprinter/v2/extensions/root_element.rb new file mode 100644 index 00000000..8542a608 --- /dev/null +++ b/lib/blueprinter/v2/extensions/root_element.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + class RootElement < Extension + def output_object(ctx) + root_name = ctx.options[:root] || ctx.blueprint.class.options[:root] + return ctx.value if root_name.nil? + + root = { root_name => ctx.value } + if (meta = ctx.options[:meta] || ctx.blueprint.class.options[:meta]) + meta = ctx.blueprint.instance_exec(ctx.value, options, &meta) if meta.is_a? Proc + root[:meta] = meta + end + + if (root_data = ctx.options[:root_data] || ctx.blueprint.class.options[:root_data]) + root_data = ctx.blueprint.instance_exec(ctx.value, ctx.options, &root_data) if root_data.is_a? Proc + root.merge! root_data + end + root + end + + def output_collection(ctx) + output_object ctx + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/unless_conditionals.rb b/lib/blueprinter/v2/extensions/unless_conditionals.rb new file mode 100644 index 00000000..ffd353ea --- /dev/null +++ b/lib/blueprinter/v2/extensions/unless_conditionals.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + module Extensions + # An extension providing "unless" options + class UnlessConditionals < Extension + # @param ctx [Blueprinter::V2::Serializer::Context] + def exclude_field?(ctx) + if (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_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_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/extractor.rb b/lib/blueprinter/v2/extractor.rb new file mode 100644 index 00000000..5402e888 --- /dev/null +++ b/lib/blueprinter/v2/extractor.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # The default extractor and base class for custom extractors + class Extractor + def field(blueprint, field, object, options) + if field.value_proc + blueprint.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, field, object, options) + field(blueprint, field, object, options) + end + + def collection(blueprint, field, object, options) + field(blueprint, field, object, options) + end + end + end +end diff --git a/lib/blueprinter/v2/field.rb b/lib/blueprinter/v2/field.rb deleted file mode 100644 index 58e8891e..00000000 --- a/lib/blueprinter/v2/field.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - Field = Struct.new( - :name, - :from, - :value_proc, - :options, - keyword_init: true - ) - end -end diff --git a/lib/blueprinter/v2/fields.rb b/lib/blueprinter/v2/fields.rb new file mode 100644 index 00000000..8ec8f943 --- /dev/null +++ b/lib/blueprinter/v2/fields.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + Field = Struct.new( + :name, + :from, + :value_proc, + :options, + keyword_init: true + ) + + ObjectField = Struct.new( + :name, + :blueprint, + :legacy_view, + :from, + :value_proc, + :options, + keyword_init: true + ) + + Collection = Struct.new( + :name, + :blueprint, + :legacy_view, + :from, + :value_proc, + :options, + keyword_init: true + ) + end +end diff --git a/lib/blueprinter/v2/formatter.rb b/lib/blueprinter/v2/formatter.rb new file mode 100644 index 00000000..872ccaf7 --- /dev/null +++ b/lib/blueprinter/v2/formatter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # An interface for formatting values against extensions + class Formatter + def initialize(extensions) + @formatters = extensions.reduce({}) do |acc, ext| + fmts = ext.class.formatters.transform_values do |fmt| + if fmt.is_a? Proc + ->(context) { ext.instance_exec(context, &fmt) } + else + ext.public_method(fmt) + end + end + fmts.merge(acc) + end + end + + # @param context [Blueprinter::V2::Serializer::Context] + def call(context) + fmt = @formatters[context.value.class] + fmt ? fmt.call(context) : context.value + end + end + end +end diff --git a/lib/blueprinter/v2/hooks.rb b/lib/blueprinter/v2/hooks.rb new file mode 100644 index 00000000..4adff725 --- /dev/null +++ b/lib/blueprinter/v2/hooks.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/extension' + +module Blueprinter + module V2 + # An interface for running extension hooks + class Hooks + def initialize(extensions) + @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 + + # + # Return true if any of "hook" returns truthy. + # + # @param hook [Symbol] Name of hook to call + # @return [Boolean] + # + def any?(hook, *args) + @hooks.fetch(hook).any? { |h| h.call(*args) } + end + + # + # Call the hooks in series, using the output of one as (part of) the input for the next. + # + # The input will always be "context", but the field "result_attr" will be updated after each call. + # + # @param hook [Symbol] Name of hook to call + # @param result_attr [Symbol] Field of context to store each result in + # @param context [Blueprinter::V2::Serializer::Context] The argument for the hooks + # @return [Object] The last hook's return value + # + def reduce(hook, result_attr, context) + @hooks.fetch(hook).reduce(context) do |ctx, h| + result = h.call(ctx) + ctx.send("#{result_attr}=", result) + ctx + end.send(result_attr) + 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/reflection.rb b/lib/blueprinter/v2/reflection.rb index 30b1ff71..6e1b1c4f 100644 --- a/lib/blueprinter/v2/reflection.rb +++ b/lib/blueprinter/v2/reflection.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'blueprinter/v2/association' -require 'blueprinter/v2/field' +require 'blueprinter/v2/fields' module Blueprinter module V2 @@ -38,10 +37,12 @@ class View attr_reader :name # @return [Hash] Fields defined on the view attr_reader :fields - # @return [Hash] Associations to single objects defined on the view + # @return [Hash] Associations to single objects defined on the view attr_reader :objects - # @return [Hash] Associations to collections defined on the view + # @return [Hash] Associations to collections defined on the view attr_reader :collections + # @return [Hash] All fields, objects, and collections in the order they were defined + attr_reader :all_fields # @param blueprint [Class] A subclass of Blueprinter::V2::Base @@ -49,9 +50,10 @@ class View # @api private def initialize(blueprint, name) @name = name + @all_fields = blueprint.fields @fields = blueprint.fields.select { |_, f| f.is_a? Field } - @objects = blueprint.fields.select { |_, f| f.is_a?(Association) && !f.collection } - @collections = blueprint.fields.select { |_, f| f.is_a?(Association) && f.collection } + @objects = blueprint.fields.select { |_, f| f.is_a? ObjectField } + @collections = blueprint.fields.select { |_, f| f.is_a? Collection } end end end diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb new file mode 100644 index 00000000..d1a03c9f --- /dev/null +++ b/lib/blueprinter/v2/render.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'json' # TODO replace with multi json +require 'blueprinter/v2/instance_cache' + +module Blueprinter + module V2 + class Render + def initialize(object, options, serializer:, collection:) + @object = object + @options = options + @serializer = serializer + @collection = collection + end + + def to_hash + instance_cache = InstanceCache.new + blueprint = instance_cache[@serializer.blueprint] + pre_hook = @collection ? :input_collection : :input_object + post_hook = @collection ? :output_collection : :output_object + + context = Serializer::Context.new(blueprint, nil, nil, @object, @options, instance_cache) + object = @serializer.hooks.reduce(pre_hook, :object, context) + + result = + if @collection + object.each.map { |obj| @serializer.call(obj, @options, instance_cache) } + else + @serializer.call(object, @options, instance_cache) + end + + context = Serializer::Context.new(blueprint, nil, result, object, @options, instance_cache) + @serializer.hooks.reduce(post_hook, :value, context) + 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..a01a7c33 --- /dev/null +++ b/lib/blueprinter/v2/serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/fields' +require 'blueprinter/v2/formatter' +require 'blueprinter/v2/hooks' + +module Blueprinter + module V2 + class Serializer + # Wraps everything we pass to formatters and most extension hooks + Context = Struct.new(:blueprint, :field, :value, :object, :options, :instances) + + # Core extensions that must run at the start of serialization + CORE_START = [ + Extensions::Extraction, + Extensions::DefaultValues, + Extensions::IfConditionals, + Extensions::UnlessConditionals, + Extensions::ExcludeIfEmpty, + Extensions::ExcludeIfNil + ].freeze + + # Core extensions that must run at the end of serialization + CORE_FINISH = [ + Extensions::NestedBlueprints, + Extensions::RootElement + ].freeze + + attr_reader :blueprint, :formatter, :hooks + + def initialize(blueprint) + extensions = CORE_START.map(&:new) + blueprint.extensions + CORE_FINISH.map(&:new) + @formatter = Formatter.new extensions + @hooks = Hooks.new extensions + @blueprint = blueprint + end + + def call(object, options, instance_cache) + bp = instance_cache[blueprint] + reflection = blueprint.reflections[:default] + obj = hooks.reduce(:each_input, :object, Context.new(bp, nil, nil, object, options, instance_cache)) + + result = reflection.all_fields.each_with_object({}) do |(_, field), acc| + case field + when Field + value = hooks.reduce(:field_value, :value, Context.new(bp, field, nil, obj, options, instance_cache)) + value = formatter.call(Context.new(bp, field, value, obj, options, instance_cache)) + acc[field.name] = value unless hooks.any?(:exclude_field?, Context.new(bp, field, value, obj, options, instance_cache)) + when ObjectField + value = hooks.reduce(:object_value, :value, Context.new(bp, field, nil, obj, options, instance_cache)) + acc[field.name] = value unless hooks.any?(:exclude_object?, Context.new(bp, field, value, obj, options, instance_cache)) + when Collection + value = hooks.reduce(:collection_value, :value, Context.new(bp, field, nil, obj, options, instance_cache)) + acc[field.name] = value unless hooks.any?(:exclude_collection?, Context.new(bp, field, value, obj, options, instance_cache)) + end + end + + hooks.reduce(:each_output, :value, Context.new(bp, nil, result, obj, 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..502c9652 --- /dev/null +++ b/spec/v2/extension_spec.rb @@ -0,0 +1,97 @@ +# 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 collections? to false' do + expect(subject.new.collection? object).to be false + end + + it 'should default input_object to the given object' do + ctx = context.new(blueprint.new, nil, nil, object, {}) + expect(subject.new.input_object(ctx)).to eq object + end + + it 'should default input_collection to the given object' do + ctx = context.new(blueprint.new, nil, nil, [object], {}) + expect(subject.new.input_object(ctx)).to eq [object] + end + + it 'should default output_object to the given value' do + ctx = context.new(blueprint.new, nil, { foo: 'Foo' }, object, {}) + expect(subject.new.output_object(ctx)).to eq({ foo: 'Foo' }) + end + + it 'should default output_collection to the given value' do + ctx = context.new(blueprint.new, nil, [{ foo: 'Foo' }], [object], {}) + expect(subject.new.output_collection(ctx)).to eq([{ foo: 'Foo' }]) + end + + it 'should default each_input to the given object' do + ctx = context.new(blueprint.new, nil, nil, object, {}) + expect(subject.new.each_input(ctx)).to eq object + end + + it 'should default each_output to the given value' do + ctx = context.new(blueprint.new, nil, { foo: 'Foo' }, object, {}) + expect(subject.new.each_output(ctx)).to eq({ foo: 'Foo' }) + 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 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 + end +end diff --git a/spec/v2/extensions/default_values_spec.rb b/spec/v2/extensions/default_values_spec.rb new file mode 100644 index 00000000..31768096 --- /dev/null +++ b/spec/v2/extensions/default_values_spec.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::DefaultValues do + subject { described_class.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def was(ctx) + "was #{ctx.value.inspect}" + end + + def is?(ctx, val) + ctx.value == val + end + end + end + let(:object) { { name: 'Foo' } } + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should pass values through by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should pass values through by with defaults given' do + blueprint.options[:field_default] = 'Bar' + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should pass values through with false default_ifs given' do + blueprint.options[:field_default] = 'Bar' + blueprint.options[:field_default_if] = ->(_) { false } + field.options[:default] = 'Bar' + field.options[:default_if] = ->(_) { false } + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar', field_default_if: ->(_) { false } }) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should pass nil through by default' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.field_value ctx).to be_nil + end + + it 'should use options field_default' do + ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar' }) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should use options field_default (Proc)' do + ctx = context.new(blueprint.new, field, nil, object, { field_default: ->(ctx) { "Bar (#{was ctx})"} }) + expect(subject.field_value ctx).to eq 'Bar (was nil)' + end + + it 'should use field options default' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should use field options default (Proc)' do + field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.field_value ctx).to eq 'Bar (was nil)' + end + + it 'should use blueprint options field_default' do + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should use blueprint options field_default (Proc)' do + blueprint.options[:field_default] = ->(ctx) { "Bar (#{was ctx})" } + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.field_value ctx).to eq 'Bar (was nil)' + end + + it 'should check with options field_default_if (default = options field_default)' do + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar', field_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with options field_default_if (default = field options default)' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with options field_default_if (default = blueprint options field_default)' do + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = options field_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = field options default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = blueprint options field_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options field_default_if (default = options field_default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options field_default_if (default = field options default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options field_default_if (default = blueprint options field_default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.field_value ctx).to eq 'Bar' + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + + it 'should pass values through by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.object_value ctx).to eq 'Foo' + end + + it 'should pass values through by with defaults given' do + blueprint.options[:object_default] = 'Bar' + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }) + expect(subject.object_value ctx).to eq 'Foo' + end + + it 'should pass values through with false default_ifs given' do + blueprint.options[:object_default] = 'Bar' + blueprint.options[:object_default_if] = ->(_) { false } + field.options[:default] = 'Bar' + field.options[:default_if] = ->(_) { false } + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar', object_default_if: ->(_) { false } }) + expect(subject.object_value ctx).to eq 'Foo' + end + + it 'should pass nil through by default' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.object_value ctx).to be_nil + end + + it 'should use options object_default' do + ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar' }) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should use options object_default (Proc)' do + ctx = context.new(blueprint.new, field, nil, object, { object_default: ->(ctx) { "Bar (#{was ctx})" } }) + expect(subject.object_value ctx).to eq 'Bar (was nil)' + end + + it 'should use field options default' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should use field options default (Proc)' do + field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.object_value ctx).to eq 'Bar (was nil)' + end + + it 'should use blueprint options object_default' do + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should use blueprint options object_default (Proc)' do + blueprint.options[:object_default] = ->(ctx) { "Bar (#{was ctx})" } + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.object_value ctx).to eq 'Bar (was nil)' + end + + it 'should check with options object_default_if (default = options object_default)' do + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar', object_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with options object_default_if (default = field options default)' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with options object_default_if (default = blueprint options object_default)' do + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = options object_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = field options default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = blueprint options object_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options object_default_if (default = options object_default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options object_default_if (default = field options default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options object_default_if (default = blueprint options object_default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + + it 'should pass values through by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.collection_value ctx).to eq 'Foo' + end + + it 'should pass values through by with defaults given' do + blueprint.options[:collection_default] = 'Bar' + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }) + expect(subject.collection_value ctx).to eq 'Foo' + end + + it 'should pass values through with false default_ifs given' do + blueprint.options[:collection_default] = 'Bar' + blueprint.options[:collection_default_if] = ->(_) { false } + field.options[:default] = 'Bar' + field.options[:default_if] = ->(_) { false } + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar', collection_default_if: ->(_) { false } }) + expect(subject.collection_value ctx).to eq 'Foo' + end + + it 'should pass nil through by default' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.collection_value ctx).to be_nil + end + + it 'should use options collection_default' do + ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar' }) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should use options collection_default (Proc)' do + ctx = context.new(blueprint.new, field, nil, object, { collection_default: ->(ctx) { "Bar (#{was ctx})" } }) + expect(subject.collection_value ctx).to eq 'Bar (was nil)' + end + + it 'should use field options default' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should use field options default (Proc)' do + field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.collection_value ctx).to eq 'Bar (was nil)' + end + + it 'should use blueprint options collection_default' do + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should use blueprint options collection_default (Proc)' do + blueprint.options[:collection_default] = ->(ctx) { "Bar (#{was ctx})" } + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.collection_value ctx).to eq 'Bar (was nil)' + end + + it 'should check with options collection_default_if (default = options collection_default)' do + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar', collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with options collection_default_if (default = field options default)' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with options collection_default_if (default = blueprint options collection_default)' do + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = options collection_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = field options default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = blueprint options collection_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options collection_default_if (default = options collection_default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options collection_default_if (default = field options default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options collection_default_if (default = blueprint options collection_default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + end +end diff --git a/spec/v2/extensions/exclude_if_empty_spec.rb b/spec/v2/extensions/exclude_if_empty_spec.rb new file mode 100644 index 00000000..b7a72e59 --- /dev/null +++ b/spec/v2/extensions/exclude_if_empty_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::ExcludeIfEmpty do + subject { described_class.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:object) { { name: 'Foo' } } + + context 'fields' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be excluded with options set if empty' do + ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be excluded with field options set if empty' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be excluded with blueprint options set if empty' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_field? ctx).to be true + end + end + + context 'objects' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be excluded with options set if empty' do + ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be excluded with field options set if empty' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be excluded with blueprint options set if empty' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_object? ctx).to be true + end + end + + context 'collections' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be excluded with options set if empty' do + ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be excluded with field options set if empty' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be excluded with blueprint options set if empty' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/exclude_if_nil_spec.rb b/spec/v2/extensions/exclude_if_nil_spec.rb new file mode 100644 index 00000000..63ec8769 --- /dev/null +++ b/spec/v2/extensions/exclude_if_nil_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::ExcludeIfNil do + subject { described_class.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:object) { { name: 'Foo' } } + + context 'fields' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + end + + context 'objects' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + end + + context 'collections' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/extraction_spec.rb b/spec/v2/extensions/extraction_spec.rb new file mode 100644 index 00000000..df602d7a --- /dev/null +++ b/spec/v2/extensions/extraction_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Extraction do + subject { described_class.new } + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:object) { { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }] } } + let(:my_extractor) do + Class.new(Blueprinter::V2::Extractor) do + def field(_blueprint, field, obj, _options) + obj[field.from].upcase + end + + def object(_blueprint, field, obj, _options) + val = obj[field.from] + val.transform_values { |v| v.upcase } + end + + def collection(_blueprint, field, obj, _options) + vals = obj[field.from] + vals.map { |val| val.transform_values { |v| v * 2 } } + end + end + end + + it 'should detect hashes as objects' do + expect(subject.collection? object).to be false + end + + it 'should detect arrays as collections' do + expect(subject.collection? [object]).to be true + end + + it 'should detect sets as collections' do + expect(subject.collection? Set.new([object])).to be true + end + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should extract a field with the default extractor' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should extract a field with the field options extractor' do + field.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'FOO' + end + + it 'should extract a field with the blueprint options extractor' do + blueprint.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'FOO' + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :category, from: :category, options: {}) } + + it 'should extract an object the default extractor' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'Bar' }) + end + + it 'should extract an object the field options extractor' do + field.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'BAR' }) + end + + it 'should extract an object the blueprint options extractor' do + blueprint.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'BAR' }) + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :parts, from: :parts, options: {}) } + + it 'should extract an object the default extractor' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq [{ num: 42 }] + end + + it 'should extract an object the field options extractor' do + field.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq [{ num: 84 }] + end + + it 'should extract an object the blueprint options extractor' do + blueprint.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq [{ num: 84 }] + end + end +end diff --git a/spec/v2/extensions/field_sorter_spec.rb b/spec/v2/extensions/field_sorter_spec.rb new file mode 100644 index 00000000..ff1bb395 --- /dev/null +++ b/spec/v2/extensions/field_sorter_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::FieldSorter do + let(:context) { Blueprinter::V2::Serializer::Context } + + 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(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :zorp + field :name + collection :parts, test.part_blueprint + field :description + object :category, test.category_blueprint + end + end + + it 'should sort fields' do + ext = described_class.new { |a, b| a.name <=> b.name } + widget_blueprint.extensions << ext + widget = { + name: 'Foo', + description: 'A description', + category: { name: 'Bar' }, + aaa: 'ZZZ', + zorp: 'Zorp', + parts: [{ num: 42 }] + } + ctx = context.new(widget_blueprint.new, nil, widget) + + result = ext.each_output ctx + expect(result.to_json).to eq({ + category: { name: 'Bar' }, + description: 'A description', + name: 'Foo', + parts: [{ num: 42 }], + zorp: 'Zorp', + aaa: 'ZZZ' + }.to_json) + end +end diff --git a/spec/v2/extensions/if_conditionalss_spec.rb b/spec/v2/extensions/if_conditionalss_spec.rb new file mode 100644 index 00000000..c0ba40d0 --- /dev/null +++ b/spec/v2/extensions/if_conditionalss_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::IfConditionals do + subject { described_class.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:object) { { name: 'Foo' } } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def foo?(val) + val == :foo + end + end + end + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_if' do + ctx = context.new(blueprint.new, field, :foo, object, { field_if: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { field_if: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check field options if' do + field.options[:if] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check blueprint options field_if' do + blueprint.options[:field_if] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be true + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check options object_if' do + ctx = context.new(blueprint.new, field, :foo, object, { object_if: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { object_if: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check field options if' do + field.options[:if] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check blueprint options object_if' do + blueprint.options[:object_if] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be true + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check options collection_if' do + ctx = context.new(blueprint.new, field, :foo, object, { collection_if: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { collection_if: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check field options if' do + field.options[:if] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check blueprint options collection_if' do + blueprint.options[:collection_if] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/nested_blueprints_spec.rb b/spec/v2/extensions/nested_blueprints_spec.rb new file mode 100644 index 00000000..206af2a3 --- /dev/null +++ b/spec/v2/extensions/nested_blueprints_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::NestedBlueprints do + subject { described_class.new } + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:widget_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + field :description do |_ctx| + 'The description' + end + end + end + + context 'V2 Blueprint' do + it 'should serialize objects' do + val = { name: 'Foo' } + field = Blueprinter::V2::ObjectField.new(name: :widget, from: :widget, blueprint: widget_blueprint, options: {}) + ctx = context.new(blueprint.new, field, val, {}, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'Foo', description: 'The description' }) + end + + it 'should return nil if the object is nil' do + field = Blueprinter::V2::ObjectField.new(name: :widget, from: :widget, blueprint: widget_blueprint, options: {}) + ctx = context.new(blueprint.new, field, nil, {}, {}, instance_cache) + expect(subject.object_value ctx).to be_nil + end + + it 'should serialize collections' do + val = [{ name: 'Foo' }, { name: 'Bar' }] + field = Blueprinter::V2::Collection.new(name: :widget, from: :widget, blueprint: widget_blueprint, options: {}) + ctx = context.new(blueprint.new, field, val, {}, {}, instance_cache) + expect(subject.collection_value ctx).to eq([ + { name: 'Foo', description: 'The description' }, + { name: 'Bar', description: 'The description' }, + ]) + end + + it 'should return nil if the collection is nil' do + field = Blueprinter::V2::Collection.new(name: :widget, from: :widget, blueprint: widget_blueprint, options: {}) + ctx = context.new(blueprint.new, field, nil, {}, {}, instance_cache) + expect(subject.collection_value ctx).to be_nil + end + + it 'should return an empty array if the collection is empty' do + field = Blueprinter::V2::Collection.new(name: :widget, from: :widget, blueprint: widget_blueprint, options: {}) + ctx = context.new(blueprint.new, field, [], {}, {}, instance_cache) + expect(subject.collection_value ctx).to eq [] + end + end + + context 'V1 Blueprint' do + it 'should serialize objects' + + it 'should serialize collections' + end +end diff --git a/spec/v2/extensions/root_element_spec.rb b/spec/v2/extensions/root_element_spec.rb new file mode 100644 index 00000000..b9ae88c1 --- /dev/null +++ b/spec/v2/extensions/root_element_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::RootElement do + subject { described_class.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + + def meta_links + { meta: { links: [] } } + end + end + end + + it 'should pass through the result by default for objects' do + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should pass through the result by default for collections' do + ctx = context.new(blueprint.new, nil, [{ name: 'Foo' }], nil, {}) + result = subject.output_collection(ctx) + expect(result).to eq([{ name: 'Foo' }]) + end + + it 'should look for a root option in the blueprint for objects' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' } }) + end + + it 'should look for a root option in the blueprint for collections' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, [{ name: 'Foo' }], nil, {}) + result = subject.output_collection(ctx) + expect(result).to eq({ data: [{ name: 'Foo' }] }) + end + + it 'should look for a root option in the options over the blueprint' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, { root: :root }) + result = subject.output_object(ctx) + expect(result).to eq({ root: { name: 'Foo' } }) + end + + it 'should look for a meta option in the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:meta] = { links: [] } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) + end + + it 'should look for a meta option in the options over the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:meta] = { links: [] } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, { root: :root, meta: { linkz: [] }}) + result = subject.output_object(ctx) + expect(result).to eq({ root: { name: 'Foo' }, meta: { linkz: [] } }) + end + + it 'should look for a root_data option in the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:root_data] = ->(_res, _opt) { meta_links } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) + end + + it 'should look for a root_data option in the options over the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:root_data] = ->(_res, _opt) { meta_links } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, { root_data: { links: [] } }) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' }, links: [] }) + end +end diff --git a/spec/v2/extensions/unless_conditionalss_spec.rb b/spec/v2/extensions/unless_conditionalss_spec.rb new file mode 100644 index 00000000..99b6bfde --- /dev/null +++ b/spec/v2/extensions/unless_conditionalss_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::UnlessConditionals do + subject { described_class.new } + let(:context) { Blueprinter::V2::Serializer::Context } + let(:object) { { name: 'Foo' } } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def foo?(val) + val == :foo + end + end + end + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_unless' do + ctx = context.new(blueprint.new, field, :foo, object, { field_unless: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { field_unless: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check field options unless' do + field.options[:unless] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check blueprint options field_unless' do + blueprint.options[:field_unless] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be false + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check options object_unless' do + ctx = context.new(blueprint.new, field, :foo, object, { object_unless: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { object_unless: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check field options unless' do + field.options[:unless] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check blueprint options object_unless' do + blueprint.options[:object_unless] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be false + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check options collection_unless' do + ctx = context.new(blueprint.new, field, :foo, object, { collection_unless: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { collection_unless: ->(ctx) { foo? ctx.value } }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check field options unless' do + field.options[:unless] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check blueprint options collection_unless' do + blueprint.options[:collection_unless] = ->(ctx) { foo? ctx.value } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + end +end diff --git a/spec/v2/extractor_spec.rb b/spec/v2/extractor_spec.rb new file mode 100644 index 00000000..75364719 --- /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(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(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(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::ObjectField.new(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(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(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::Collection.new(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(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(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/fields_spec.rb b/spec/v2/fields_spec.rb index 21f2c4c9..e67226ef 100644 --- a/spec/v2/fields_spec.rb +++ b/spec/v2/fields_spec.rb @@ -32,7 +32,7 @@ end ref = blueprint.reflections[:default] - expect(ref.objects[:category].class.name).to eq "Blueprinter::V2::Association" + expect(ref.objects[:category].class.name).to eq "Blueprinter::V2::ObjectField" expect(ref.objects[:category].name).to eq :category expect(ref.objects[:category].from).to eq :category expect(ref.objects[:category].blueprint).to eq category_blueprint diff --git a/spec/v2/formatter_spec.rb b/spec/v2/formatter_spec.rb new file mode 100644 index 00000000..ee0fb8b6 --- /dev/null +++ b/spec/v2/formatter_spec.rb @@ -0,0 +1,59 @@ +# 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 + + it 'should evaluate blocks against the extension instance' do + ext = Class.new(Blueprinter::V2::Extension) do + format(Date) { |context| str_date context.value } + + def str_date(date) + date.iso8601 + end + end + + formatter = described_class.new([ext.new]) + expect(formatter.call(context.new(blueprint.new, field, Date.new(2024, 10, 1), object, {}))).to eq '2024-10-01' + end + + it 'should prefer the first when there are duplicates' do + ext1 = Class.new(Blueprinter::V2::Extension) do + format(Date) { |context| context.value.iso8601 } + end + ext2 = Class.new(Blueprinter::V2::Extension) do + format(Date) { |context| context.value.strftime('%m/%d/%Y') } + 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' + end +end diff --git a/spec/v2/hooks_spec.rb b/spec/v2/hooks_spec.rb new file mode 100644 index 00000000..b393f802 --- /dev/null +++ b/spec/v2/hooks_spec.rb @@ -0,0 +1,45 @@ +# 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_object(context) + context.object[:bar] = 'Bar' if context.object[:foo] + context.object + 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] + ctx = context.new(blueprint.new, nil, nil, { foo: 'Foo' }, {}) + expect(hooks.reduce(:input_object, :object, ctx)).to eq({ foo: 'Foo', bar: 'Bar' }) + + ctx = context.new(blueprint.new, nil, nil, { zorp: 'Zorp' }, {}) + expect(hooks.reduce(:input_object, :object, ctx)).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 [] + ctx = context.new(blueprint.new, nil, nil, { foo: 'Foo' }, {}) + expect(hooks.reduce(:input_object, :object, ctx)).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/render_spec.rb b/spec/v2/render_spec.rb new file mode 100644 index 00000000..c09ff0d2 --- /dev/null +++ b/spec/v2/render_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Render do + let(:category_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name, from: :n + end + end + + let(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :name + field :desc, from: :description + object :category, test.category_blueprint + end + end + + it 'should render an object to a hash' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, {}, serializer: serializer, collection: false) + + expect(render.to_hash).to eq({ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }) + end + + it 'should render a collection to a hash' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widgets = [ + { name: 'Foo', description: 'About', category: { n: 'Bar' } }, + { name: 'Foo 2', description: 'About 2', category: { n: 'Bar 2' } }, + ] + render = described_class.new(widgets, {}, serializer: serializer, collection: true) + + expect(render.to_hash).to eq([ + { + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }, + { + name: 'Foo 2', + desc: 'About 2', + category: { name: 'Bar 2' } + }, + ]) + end + + it 'should render an object to JSON' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, {}, serializer: serializer, collection: false) + + expect(render.to_json).to eq({ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }.to_json) + end + + it 'should render a collection to JSON' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new([widget], {}, serializer: serializer, collection: true) + + expect(render.to_json).to eq([{ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }].to_json) + end + + it 'should call input hooks on objects' do + ext = Class.new(Blueprinter::V2::Extension) do + def input_object(ctx) + { name: ctx.object[:name] } + end + end + widget_blueprint.extensions << ext.new + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, {}, serializer: serializer, collection: false) + + expect(render.to_hash).to eq({ + name: 'Foo', + desc: nil, + category: nil + }) + end + + it 'should call input hooks on collections' do + ext = Class.new(Blueprinter::V2::Extension) do + def input_collection(ctx) + ctx.object.map { |obj| { name: obj[:name] } } + end + end + widget_blueprint.extensions << ext.new + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widgets = [{ name: 'Foo', description: 'About', category: { n: 'Bar' } }] + render = described_class.new(widgets, {}, serializer: serializer, collection: true) + + expect(render.to_hash).to eq([{ + name: 'Foo', + desc: nil, + category: nil + }]) + end + + it 'should call output hooks for objects' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, { root: :data }, serializer: serializer, collection: false) + + expect(render.to_hash).to eq({ + data: { + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + } + }) + end + + it 'should call output hooks for collections' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new([widget], { root: :data }, serializer: serializer, collection: true) + + expect(render.to_hash).to eq({ + data: [{ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }] + }) + end +end diff --git a/spec/v2/rendering_spec.rb b/spec/v2/rendering_spec.rb new file mode 100644 index 00000000..c2d0ce62 --- /dev/null +++ b/spec/v2/rendering_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +describe "Blueprinter::V2 Rendering" 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(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :name + object :cat, test.category_blueprint, from: :category + collection :parts, test.part_blueprint + end + end + + let(:widget) { { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }, { num: 43 }] } } + + it 'should auto-detect an object' do + result = widget_blueprint.render(widget, { root: :data }).to_hash + expect(result).to eq({ + data: { + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + }) + end + + it 'should auto-detect array collections' do + result = widget_blueprint.render([widget], { root: :data }).to_hash + expect(result).to eq({ + data: [{ + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + }] + }) + end + + it 'should render an object with options' do + result = widget_blueprint.render_object(widget, { root: :data }).to_hash + expect(result).to eq({ + data: { + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + }) + end + + it 'should render a collection with options' do + result = widget_blueprint.render_collection([widget], { root: :data }).to_hash + expect(result).to eq({ + data: [{ + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + }] + }) + end +end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb new file mode 100644 index 00000000..1fec5fb0 --- /dev/null +++ b/spec/v2/serializer_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'date' +require 'json' + +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(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :name + object :category, test.category_blueprint + collection :parts, test.part_blueprint + end + end + + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + + it 'should work with nil values' do + test = self + widget = { name: nil, category: nil } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: nil, + category: nil, + parts: nil + }) + end + + it 'should extract values and serialize nested Blueprints' do + test = self + widget = { + name: 'Foo', + extra: 'bar', + category: { name: 'Bar', extra: 'bar' }, + parts: [{ num: 42, extra: 'bar' }, { num: 43 }] + } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + category: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + }) + end + + it 'should enable the if conditionals extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, if: ->(ctx) { ctx.options[:n] > 42 } + end + + result = described_class.new(widget_blueprint).call( + { name: 'Foo', desc: 'Bar' }, + { n: 42 }, + instance_cache + ) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should enable the unless conditionals extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, unless: ->(ctx) { ctx.options[:n] > 42 } + end + + result = described_class.new(widget_blueprint).call( + { name: 'Foo', desc: 'Bar' }, + { n: 43 }, + instance_cache + ) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should enable the default values extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, default: 'Description!' + end + + result = described_class.new(widget_blueprint).call({ name: 'Foo' }, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + desc: 'Description!' + }) + end + + it 'should enable the exclude if empty extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, exclude_if_empty: true + end + + result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: "" }, {}, instance_cache) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should enable the exclude if nil extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, exclude_if_nil: true + end + + result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: nil }, {}, instance_cache) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should format fields' do + date_formatter = Class.new(Blueprinter::V2::Extension) do + format(Date) { |ctx| ctx.value.strftime('%a %b %e, %Y') } + end + widget_blueprint = Class.new(Blueprinter::V2::Base) do + extensions << date_formatter.new + field :name + field :created_on + end + widget = { name: 'Foo', created_on: Date.new(2024, 10, 31) } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + created_on: 'Thu Oct 31, 2024' + }) + end + + it 'should evaluate value hooks before exclusion hooks' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, default: 'Bar', if: ->(ctx) { !ctx.value.nil? } + end + widget = { name: 'Foo', desc: nil } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ name: 'Foo', desc: 'Bar' }) + end + + it 'should evaluate both ifs and unlesses' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name, if: ->(ctx) { ctx.options[:n] > 42 } + field :desc, unless: ->(ctx) { ctx.options[:n] < 43 } + field :zorp, + if: ->(ctx) { ctx.options[:n] > 40 }, + unless: ->(ctx) { ctx.options[:m] == 42 } + end + + result = described_class.new(widget_blueprint).call( + { name: 'Foo', desc: 'Bar', zorp: 'Zorp' }, + { n: 42, m: 42 }, + instance_cache + ) + expect(result).to eq({}) + end + + it 'should run each_input hooks before anything else' do + ext = Class.new(Blueprinter::V2::Extension) do + def each_input(_ctx) + { name: 'Foo' } + end + end + widget_blueprint.extensions << ext.new + + result = described_class.new(widget_blueprint).call( + { category: { name: 'Cat' }, parts: [{ num: 42 }] }, + {}, + instance_cache + ) + expect(result).to eq({ name: 'Foo', category: nil, parts: nil }) + end + + it 'should run each_output hooks after everything else' do + ext = Class.new(Blueprinter::V2::Extension) do + def each_output(_ctx) + { name: 'Foo' } + end + end + widget_blueprint.extensions << ext.new + + result = described_class.new(widget_blueprint).call( + { category: { name: 'Cat' }, parts: [{ num: 42 }] }, + {}, + instance_cache + ) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should put fields in the order they were defined' do + blueprint = Class.new(widget_blueprint) do + field :description + end + + result = described_class.new(blueprint).call( + { description: 'A widget', category: { name: 'Cat' }, parts: [{ num: 42 }], name: 'Foo' }, + {}, + instance_cache + ) + expect(result.to_json).to eq({ + name: 'Foo', + category: { name: 'Cat' }, + parts: [{ num: 42 }], + description: 'A widget' + }.to_json) + end +end