diff --git a/.rubocop.yml b/.rubocop.yml index 7ca13ed9..8600e64c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,3 +27,6 @@ Metrics/AbcSize: Metrics/ParameterLists: Max: 10 + +Naming/MemoizedInstanceVariableName: + EnforcedStyleForLeadingUnderscores: required diff --git a/bin/console b/bin/console index d002ec54..23098ebd 100755 --- a/bin/console +++ b/bin/console @@ -5,4 +5,5 @@ require 'bundler/setup' require 'blueprinter' require 'irb' + IRB.start(__FILE__) diff --git a/lib/blueprinter.rb b/lib/blueprinter.rb index 76cbd50d..5c768c69 100644 --- a/lib/blueprinter.rb +++ b/lib/blueprinter.rb @@ -11,7 +11,7 @@ module Blueprinter class << self # @return [Configuration] def configuration - @configuration ||= Configuration.new + @_configuration ||= Configuration.new end def configure @@ -20,7 +20,7 @@ def configure # Resets global configuration. def reset_configuration! - @configuration = nil + @_configuration = nil end end end diff --git a/lib/blueprinter/base.rb b/lib/blueprinter/base.rb index b40f16ba..5ec9d6f1 100644 --- a/lib/blueprinter/base.rb +++ b/lib/blueprinter/base.rb @@ -1,448 +1,376 @@ # frozen_string_literal: true -require 'blueprinter/association' -require 'blueprinter/extractors/association_extractor' -require 'blueprinter/field' -require 'blueprinter/helpers/base_helpers' -require 'blueprinter/reflection' +require_relative 'association' +require_relative 'extractors/association_extractor' +require_relative 'field' +require_relative 'reflection' +require_relative 'rendering' +require_relative 'view_collection' module Blueprinter class Base - include BaseHelpers extend Reflection + extend Rendering - # Specify a field or method name used as an identifier. Usually, this is - # something like :id - # - # Note: identifiers are always rendered and considered their own view, - # similar to the :default view. - # - # @param method [Symbol] the method or field used as an identifier that you - # want to set for serialization. - # @param name [Symbol] to rename the identifier key in the JSON - # output. Defaults to method given. - # @param extractor [AssociationExtractor,AutoExtractor,BlockExtractor,HashExtractor,PublicSendExtractor] - # @yield [object, options] The object and the options passed to render are - # also yielded to the block. - # - # Kind of extractor to use. - # Either define your own or use Blueprinter's premade extractors. - # Defaults to AutoExtractor - # - # @example Specifying a uuid as an identifier. - # class UserBlueprint < Blueprinter::Base - # identifier :uuid - # # other code - # end - # - # @example Passing a block to be evaluated as the value. - # class UserBlueprint < Blueprinter::Base - # identifier :uuid do |user, options| - # options[:current_user].anonymize(user.uuid) - # end - # end - # - # @return [Field] A Field object - def self.identifier(method, name: method, extractor: Blueprinter.configuration.extractor_default.new, &block) - view_collection[:identifier] << Field.new( - method, - name, - extractor, - self, - block: block - ) - end - - # Specify a field or method name to be included for serialization. - # Takes a required method and an option. - # - # @param method [Symbol] the field or method name you want to include for - # serialization. - # @param options [Hash] options to overide defaults. - # @option options [AssociationExtractor,BlockExtractor,HashExtractor,PublicSendExtractor] :extractor - # Kind of extractor to use. - # Either define your own or use Blueprinter's premade extractors. The - # Default extractor is AutoExtractor - # @option options [Symbol] :name Use this to rename the method. Useful if - # if you want your JSON key named differently in the output than your - # object's field or method name. - # @option options [String,Proc] :datetime_format Format Date or DateTime object - # If the option provided is a String, the object will be formatted with given strftime - # formatting. - # If this option is a Proc, the object will be formatted by calling the provided Proc - # on the Date/DateTime object. - # @option options [Symbol,Proc] :if Specifies a method, proc or string to - # call to determine if the field should be included (e.g. - # `if: :include_first_name?, or if: Proc.new { |_field_name, user, options| options[:current_user] == user }). - # The method, proc or string should return or evaluate to a true or false value. - # @option options [Symbol,Proc] :unless Specifies a method, proc or string - # to call to determine if the field should be included (e.g. - # `unless: :include_first_name?, or unless: Proc.new { |_field_name, user, options| options[:current_user] != user }). - # The method, proc or string should return or evaluate to a true or false value. - # @yield [object, options] The object and the options passed to render are - # also yielded to the block. - # - # @example Specifying a user's first_name to be serialized. - # class UserBlueprint < Blueprinter::Base - # field :first_name - # # other code - # end - # - # @example Passing a block to be evaluated as the value. - # class UserBlueprint < Blueprinter::Base - # field :full_name do |object, options| - # "options[:title_prefix] #{object.first_name} #{object.last_name}" - # end - # # other code - # end - # - # @example Passing an if proc and unless method. - # class UserBlueprint < Blueprinter::Base - # def skip_first_name?(_field_name, user, options) - # user.first_name == options[:first_name] - # end - # - # field :first_name, unless: :skip_first_name? - # field :last_name, if: ->(_field_name, user, options) { user.first_name != options[:first_name] } - # # other code - # end - # - # @return [Field] A Field object - def self.field(method, options = {}, &block) - current_view << Field.new( - method, - options.fetch(:name) { method }, - options.fetch(:extractor) { Blueprinter.configuration.extractor_default.new }, - self, - options.merge(block: block) - ) - end - - # Specify an associated object to be included for serialization. - # Takes a required method and an option. - # - # @param method [Symbol] the association name - # @param options [Hash] options to overide defaults. - # @option options [Symbol] :blueprint Required. Use this to specify the - # blueprint to use for the associated object. - # @option options [Symbol] :name Use this to rename the association in the - # JSON output. - # @option options [Symbol] :view Specify the view to use or fall back to - # to the :default view. - # @yield [object, options] The object and the options passed to render are - # also yielded to the block. - # - # @example Specifying an association - # class UserBlueprint < Blueprinter::Base - # # code - # association :vehicles, view: :extended, blueprint: VehiclesBlueprint - # # code - # end - # - # @example Passing a block to be evaluated as the value. - # class UserBlueprint < Blueprinter::Base - # association :vehicles, blueprint: VehiclesBlueprint do |user, opts| - # user.vehicles + opts[:additional_vehicles] - # end - # end - # - # @return [Association] An object - # @raise [Blueprinter::Errors::InvalidBlueprint] if provided blueprint is not valid - def self.association(method, options = {}, &block) - raise ArgumentError, ':blueprint must be provided when defining an association' unless options[:blueprint] + class << self + # Specify a field or method name used as an identifier. Usually, this is + # something like `:id`. + # + # Note: identifiers are always rendered and considered their own view, + # similar to the :default view. + # + # @param method [Symbol] the method or field used as an identifier that you + # want to set for serialization. + # @param name [Symbol] to rename the identifier key in the JSON + # output. Defaults to method given. + # @param extractor [AssociationExtractor,AutoExtractor,BlockExtractor,HashExtractor,PublicSendExtractor] + # @yield [object, options] The object and the options passed to render are + # also yielded to the block. + # + # Kind of extractor to use. + # Either define your own or use Blueprinter's premade extractors. + # Defaults to AutoExtractor + # + # @example Specifying a uuid as an identifier. + # class UserBlueprint < Blueprinter::Base + # identifier :uuid + # # other code + # end + # + # @example Passing a block to be evaluated as the value. + # class UserBlueprint < Blueprinter::Base + # identifier :uuid do |user, options| + # options[:current_user].anonymize(user.uuid) + # end + # end + # + # @return [Field] A Field object + def identifier(method, name: method, extractor: Blueprinter.configuration.extractor_default.new, &block) + view_collection[:identifier] << Field.new( + method, + name, + extractor, + self, + block: block + ) + end - current_view << Association.new( - method: method, - name: options.fetch(:name) { method }, - extractor: options.fetch(:extractor) { AssociationExtractor.new }, - blueprint: options.fetch(:blueprint), - parent_blueprint: self, - view: options.fetch(:view, :default), - options: options.except( - :name, - :extractor, - :blueprint, - :view - ).merge(block: block) - ) - end + # Specify a field or method name to be included for serialization. + # Takes a required method and an option. + # + # @param method [Symbol] the field or method name you want to include for + # serialization. + # @param options [Hash] options to overide defaults. + # @option options [AssociationExtractor,BlockExtractor,HashExtractor,PublicSendExtractor] :extractor + # Kind of extractor to use. + # Either define your own or use Blueprinter's premade extractors. The + # Default extractor is AutoExtractor + # @option options [Symbol] :name Use this to rename the method. Useful if + # if you want your JSON key named differently in the output than your + # object's field or method name. + # @option options [String,Proc] :datetime_format Format Date or DateTime object + # If the option provided is a String, the object will be formatted with given strftime + # formatting. + # If this option is a Proc, the object will be formatted by calling the provided Proc + # on the Date/DateTime object. + # @option options [Symbol,Proc] :if Specifies a method, proc or string to + # call to determine if the field should be included (e.g. + # `if: :include_name?, or if: Proc.new { |_field_name, user, options| options[:current_user] == user }). + # The method, proc or string should return or evaluate to a true or false value. + # @option options [Symbol,Proc] :unless Specifies a method, proc or string + # to call to determine if the field should be included (e.g. + # `unless: :include_name?, or unless: Proc.new { |_field_name, user, options| options[:current_user] != user }). + # The method, proc or string should return or evaluate to a true or false value. + # @yield [object, options] The object and the options passed to render are + # also yielded to the block. + # + # @example Specifying a user's first_name to be serialized. + # class UserBlueprint < Blueprinter::Base + # field :first_name + # # other code + # end + # + # @example Passing a block to be evaluated as the value. + # class UserBlueprint < Blueprinter::Base + # field :full_name do |object, options| + # "options[:title_prefix] #{object.first_name} #{object.last_name}" + # end + # # other code + # end + # + # @example Passing an if proc and unless method. + # class UserBlueprint < Blueprinter::Base + # def skip_first_name?(_field_name, user, options) + # user.first_name == options[:first_name] + # end + # + # field :first_name, unless: :skip_first_name? + # field :last_name, if: ->(_field_name, user, options) { user.first_name != options[:first_name] } + # # other code + # end + # + # @return [Field] A Field object + def field(method, options = {}, &block) + current_view << Field.new( + method, + options.fetch(:name) { method }, + options.fetch(:extractor) { Blueprinter.configuration.extractor_default.new }, + self, + options.merge(block: block) + ) + end - # Generates a JSON formatted String. - # Takes a required object and an optional view. - # - # @param object [Object] the Object to serialize upon. - # @param options [Hash] the options hash which requires a :view. Any - # additional key value pairs will be exposed during serialization. - # @option options [Symbol] :view Defaults to :default. - # The view name that corresponds to the group of - # fields to be serialized. - # @option options [Symbol|String] :root Defaults to nil. - # Render the json/hash with a root key if provided. - # @option options [Any] :meta Defaults to nil. - # Render the json/hash with a meta attribute with provided value - # if both root and meta keys are provided in the options hash. - # - # @example Generating JSON with an extended view - # post = Post.all - # Blueprinter::Base.render post, view: :extended - # # => "[{\"id\":1,\"title\":\"Hello\"},{\"id\":2,\"title\":\"My Day\"}]" - # - # @return [String] JSON formatted String - def self.render(object, options = {}) - jsonify(prepare_for_render(object, options)) - end + # Specify an associated object to be included for serialization. + # Takes a required method and an option. + # + # @param method [Symbol] the association name + # @param options [Hash] options to overide defaults. + # @option options [Symbol] :blueprint Required. Use this to specify the + # blueprint to use for the associated object. + # @option options [Symbol] :name Use this to rename the association in the + # JSON output. + # @option options [Symbol] :view Specify the view to use or fall back to + # to the :default view. + # @yield [object, options] The object and the options passed to render are + # also yielded to the block. + # + # @example Specifying an association + # class UserBlueprint < Blueprinter::Base + # # code + # association :vehicles, view: :extended, blueprint: VehiclesBlueprint + # # code + # end + # + # @example Passing a block to be evaluated as the value. + # class UserBlueprint < Blueprinter::Base + # association :vehicles, blueprint: VehiclesBlueprint do |user, opts| + # user.vehicles + opts[:additional_vehicles] + # end + # end + # + # @return [Association] An object + # @raise [Blueprinter::Errors::InvalidBlueprint] if provided blueprint is not valid + def association(method, options = {}, &block) + raise ArgumentError, ':blueprint must be provided when defining an association' unless options[:blueprint] - # Generates a hash. - # Takes a required object and an optional view. - # - # @param object [Object] the Object to serialize upon. - # @param options [Hash] the options hash which requires a :view. Any - # additional key value pairs will be exposed during serialization. - # @option options [Symbol] :view Defaults to :default. - # The view name that corresponds to the group of - # fields to be serialized. - # @option options [Symbol|String] :root Defaults to nil. - # Render the json/hash with a root key if provided. - # @option options [Any] :meta Defaults to nil. - # Render the json/hash with a meta attribute with provided value - # if both root and meta keys are provided in the options hash. - # - # @example Generating a hash with an extended view - # post = Post.all - # Blueprinter::Base.render_as_hash post, view: :extended - # # => [{id:1, title: Hello},{id:2, title: My Day}] - # - # @return [Hash] - def self.render_as_hash(object, options = {}) - prepare_for_render(object, options) - end + current_view << Association.new( + method: method, + name: options.fetch(:name) { method }, + extractor: options.fetch(:extractor) { AssociationExtractor.new }, + blueprint: options.fetch(:blueprint), + parent_blueprint: self, + view: options.fetch(:view, :default), + options: options.except( + :name, + :extractor, + :blueprint, + :view + ).merge(block: block) + ) + end - # Generates a JSONified hash. - # Takes a required object and an optional view. - # - # @param object [Object] the Object to serialize upon. - # @param options [Hash] the options hash which requires a :view. Any - # additional key value pairs will be exposed during serialization. - # @option options [Symbol] :view Defaults to :default. - # The view name that corresponds to the group of - # fields to be serialized. - # @option options [Symbol|String] :root Defaults to nil. - # Render the json/hash with a root key if provided. - # @option options [Any] :meta Defaults to nil. - # Render the json/hash with a meta attribute with provided value - # if both root and meta keys are provided in the options hash. - # - # @example Generating a hash with an extended view - # post = Post.all - # Blueprinter::Base.render_as_json post, view: :extended - # # => [{"id" => "1", "title" => "Hello"},{"id" => "2", "title" => "My Day"}] - # - # @return [Hash] - def self.render_as_json(object, options = {}) - prepare_for_render(object, options).as_json - end + # Specify one or more field/method names to be included for serialization. + # Takes at least one field or method names. + # + # @param method [Symbol] the field or method name you want to include for + # serialization. + # + # @example Specifying a user's first_name and last_name to be serialized. + # class UserBlueprint < Blueprinter::Base + # fields :first_name, :last_name + # # other code + # end + # + # @return [Array] an array of field names + def fields(*field_names) + field_names.each do |field_name| + field(field_name) + end + end - # This is the magic method that converts complex objects into a simple hash - # ready for JSON conversion. - # - # Note: we accept view (public interface) that is in reality a view_name, - # so we rename it for clarity - # - # @api private - def self.prepare(object, view_name:, local_options:, root: nil, meta: nil) - raise BlueprinterError, "View '#{view_name}' is not defined" unless view_collection.view? view_name + # Specify one transformer to be included for serialization. + # Takes a class which extends Blueprinter::Transformer + # + # @param class name [Class] which implements the method transform to include for + # serialization. + # + # + # @example Specifying a DynamicFieldTransformer transformer for including dynamic fields to be serialized. + # class User + # def custom_columns + # dynamic_fields # which is an array of some columns + # end + # + # def custom_fields + # custom_columns.each_with_object({}) { |col,result| result[col] = send(col) } + # end + # end + # + # class UserBlueprint < Blueprinter::Base + # fields :first_name, :last_name + # transform DynamicFieldTransformer + # # other code + # end + # + # class DynamicFieldTransformer < Blueprinter::Transformer + # def transform(hash, object, options) + # hash.merge!(object.dynamic_fields) + # end + # end + # + # @return [Array] an array of transformers + def transform(transformer) + current_view.add_transformer(transformer) + end - object = Blueprinter.configuration.extensions.pre_render(object, self, view_name, local_options) - data = prepare_data(object, view_name, local_options) - prepend_root_and_meta(data, root, meta) - end + # Specify another view that should be mixed into the current view. + # + # @param view_name [Symbol] the view to mix into the current view. + # + # @example Including a normal view into an extended view. + # class UserBlueprint < Blueprinter::Base + # # other code... + # view :normal do + # fields :first_name, :last_name + # end + # view :extended do + # include_view :normal # include fields specified from above. + # field :description + # end + # #=> [:first_name, :last_name, :description] + # end + # + # @return [Array] an array of view names. + def include_view(view_name) + current_view.include_view(view_name) + end - # Specify one or more field/method names to be included for serialization. - # Takes at least one field or method names. - # - # @param method [Symbol] the field or method name you want to include for - # serialization. - # - # @example Specifying a user's first_name and last_name to be serialized. - # class UserBlueprint < Blueprinter::Base - # fields :first_name, :last_name - # # other code - # end - # - # @return [Array] an array of field names - def self.fields(*field_names) - field_names.each do |field_name| - field(field_name) + # Specify additional views that should be mixed into the current view. + # + # @param view_name [Array] the views to mix into the current view. + # + # @example Including the normal and special views into an extended view. + # class UserBlueprint < Blueprinter::Base + # # other code... + # view :normal do + # fields :first_name, :last_name + # end + # view :special do + # fields :birthday, :company + # end + # view :extended do + # include_views :normal, :special # include fields specified from above. + # field :description + # end + # #=> [:first_name, :last_name, :birthday, :company, :description] + # end + # + # @return [Array] an array of view names. + def include_views(*view_names) + current_view.include_views(view_names) end - end - # Specify one transformer to be included for serialization. - # Takes a class which extends Blueprinter::Transformer - # - # @param class name [Class] which implements the method transform to include for - # serialization. - # - # - # @example Specifying a DynamicFieldTransformer transformer for including dynamic fields to be serialized. - # class User - # def custom_columns - # self.dynamic_fields # which is an array of some columns - # end - # - # def custom_fields - # custom_columns.each_with_object({}) { |col,result| result[col] = self.send(col) } - # end - # end - # - # class UserBlueprint < Blueprinter::Base - # fields :first_name, :last_name - # transform DynamicFieldTransformer - # # other code - # end - # - # class DynamicFieldTransformer < Blueprinter::Transformer - # def transform(hash, object, options) - # hash.merge!(object.dynamic_fields) - # end - # end - # - # @return [Array] an array of transformers - def self.transform(transformer) - current_view.add_transformer(transformer) - end + # Exclude a field that was mixed into the current view. + # + # @param field_name [Symbol] the field to exclude from the current view. + # + # @example Excluding a field from being included into the current view. + # view :normal do + # fields :position, :company + # end + # view :special do + # include_view :normal + # field :birthday + # exclude :position + # end + # #=> [:company, :birthday] + # + # @return [Array] an array of field names + def exclude(field_name) + current_view.exclude_field(field_name) + end - # Specify another view that should be mixed into the current view. - # - # @param view_name [Symbol] the view to mix into the current view. - # - # @example Including a normal view into an extended view. - # class UserBlueprint < Blueprinter::Base - # # other code... - # view :normal do - # fields :first_name, :last_name - # end - # view :extended do - # include_view :normal # include fields specified from above. - # field :description - # end - # #=> [:first_name, :last_name, :description] - # end - # - # @return [Array] an array of view names. - def self.include_view(view_name) - current_view.include_view(view_name) - end + # When mixing multiple views under a single view, some fields may required to be excluded from + # current view + # + # @param [Array] the fields to exclude from the current view. + # + # @example Excluding mutiple fields from being included into the current view. + # view :normal do + # fields :name,:address,:position, + # :company, :contact + # end + # view :special do + # include_view :normal + # fields :birthday,:joining_anniversary + # excludes :position,:address + # end + # => [:name, :company, :contact, :birthday, :joining_anniversary] + # + # @return [Array] an array of field names + def excludes(*field_names) + current_view.exclude_fields(field_names) + end - # Specify additional views that should be mixed into the current view. - # - # @param view_name [Array] the views to mix into the current view. - # - # @example Including the normal and special views into an extended view. - # class UserBlueprint < Blueprinter::Base - # # other code... - # view :normal do - # fields :first_name, :last_name - # end - # view :special do - # fields :birthday, :company - # end - # view :extended do - # include_views :normal, :special # include fields specified from above. - # field :description - # end - # #=> [:first_name, :last_name, :birthday, :company, :description] - # end - # - # @return [Array] an array of view names. + # Specify a view and the fields it should have. + # It accepts a view name and a block. The block should specify the fields. + # + # @param view_name [Symbol] the view name + # @yieldreturn [#fields,#field,#include_view,#exclude] Use this block to + # specify fields, include fields from other views, or exclude fields. + # + # @example Using views + # view :extended do + # fields :position, :company + # include_view :normal + # exclude :first_name + # end + # + # @return [View] a Blueprinter::View object + def view(view_name) + self.view_scope = view_collection[view_name] + view_collection[:default].track_definition_order(view_name) + yield + self.view_scope = view_collection[:default] + end - def self.include_views(*view_names) - current_view.include_views(view_names) - end + # Check whether or not a Blueprint supports the supplied view. + # It accepts a view name. + # + # @param view_name [Symbol] the view name + # + # @example With the following Blueprint + # + # class ExampleBlueprint < Blueprinter::Base + # view :custom do + # end + # end + # + # ExampleBlueprint.view?(:custom) => true + # ExampleBlueprint.view?(:doesnt_exist) => false + # + # @return [Boolean] a boolean value indicating if the view is + # supported by this Blueprint. + def view?(view_name) + view_collection.view?(view_name) + end - # Exclude a field that was mixed into the current view. - # - # @param field_name [Symbol] the field to exclude from the current view. - # - # @example Excluding a field from being included into the current view. - # view :normal do - # fields :position, :company - # end - # view :special do - # include_view :normal - # field :birthday - # exclude :position - # end - # #=> [:company, :birthday] - # - # @return [Array] an array of field names - def self.exclude(field_name) - current_view.exclude_field(field_name) - end + def view_collection + @_view_collection ||= ViewCollection.new + end - # When mixing multiple views under a single view, some fields may required to be excluded from - # current view - # - # @param [Array] the fields to exclude from the current view. - # - # @example Excluding mutiple fields from being included into the current view. - # view :normal do - # fields :name,:address,:position, - # :company, :contact - # end - # view :special do - # include_view :normal - # fields :birthday,:joining_anniversary - # excludes :position,:address - # end - # => [:name, :company, :contact, :birthday, :joining_anniversary] - # - # @return [Array] an array of field names + private - def self.excludes(*field_names) - current_view.exclude_fields(field_names) - end + attr_accessor :view_scope - # Specify a view and the fields it should have. - # It accepts a view name and a block. The block should specify the fields. - # - # @param view_name [Symbol] the view name - # @yieldreturn [#fields,#field,#include_view,#exclude] Use this block to - # specify fields, include fields from other views, or exclude fields. - # - # @example Using views - # view :extended do - # fields :position, :company - # include_view :normal - # exclude :first_name - # end - # - # @return [View] a Blueprinter::View object - def self.view(view_name) - @current_view = view_collection[view_name] - view_collection[:default].track_definition_order(view_name) - yield - @current_view = view_collection[:default] - end + # Returns the current view during Blueprint definition based on the view_scope. + def current_view + view_scope || view_collection[:default] + end - # Check whether or not a Blueprint supports the supplied view. - # It accepts a view name. - # - # @param view_name [Symbol] the view name - # - # @example With the following Blueprint - # - # class ExampleBlueprint < Blueprinter::Base - # view :custom do - # end - # end - # - # ExampleBlueprint.view?(:custom) => true - # ExampleBlueprint.view?(:doesnt_exist) => false - # - # @return [Boolean] a boolean value indicating if the view is - # supported by this Blueprint. - def self.view?(view_name) - view_collection.view? view_name + def inherited(subclass) + subclass.send(:view_collection).inherit(view_collection) + end end end end diff --git a/lib/blueprinter/configuration.rb b/lib/blueprinter/configuration.rb index ce19178e..93735c80 100644 --- a/lib/blueprinter/configuration.rb +++ b/lib/blueprinter/configuration.rb @@ -6,8 +6,21 @@ module Blueprinter class Configuration - attr_accessor :association_default, :datetime_format, :deprecations, :field_default, :generator, :if, :method, - :sort_fields_by, :unless, :extractor_default, :default_transformers, :custom_array_like_classes + attr_accessor( + :association_default, + :custom_array_like_classes, + :datetime_format, + :default_transformers, + :deprecations, + :extractor_default, + :field_default, + :generator, + :if, + :method, + :sort_fields_by, + :unless + ) + attr_reader :extensions VALID_CALLABLES = %i[if unless].freeze @@ -24,10 +37,7 @@ def initialize @extractor_default = AutoExtractor @default_transformers = [] @custom_array_like_classes = [] - end - - def extensions - @extensions ||= Extensions.new + @extensions = Extensions.new end def extensions=(list) @@ -35,7 +45,7 @@ def extensions=(list) end def array_like_classes - @array_like_classes ||= [ + @_array_like_classes ||= [ Array, defined?(ActiveRecord::Relation) && ActiveRecord::Relation, *custom_array_like_classes diff --git a/lib/blueprinter/errors/invalid_blueprint.rb b/lib/blueprinter/errors/invalid_blueprint.rb index dbd9c373..3cb917b1 100644 --- a/lib/blueprinter/errors/invalid_blueprint.rb +++ b/lib/blueprinter/errors/invalid_blueprint.rb @@ -2,6 +2,6 @@ module Blueprinter module Errors - class InvalidBlueprint < Blueprinter::BlueprinterError; end + class InvalidBlueprint < BlueprinterError; end end end diff --git a/lib/blueprinter/errors/invalid_root.rb b/lib/blueprinter/errors/invalid_root.rb new file mode 100644 index 00000000..572e5c75 --- /dev/null +++ b/lib/blueprinter/errors/invalid_root.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'blueprinter/blueprinter_error' + +module Blueprinter + module Errors + class InvalidRoot < BlueprinterError + def initialize(message = 'root key must be a Symbol or a String') + super + end + end + end +end diff --git a/lib/blueprinter/errors/meta_requires_root.rb b/lib/blueprinter/errors/meta_requires_root.rb new file mode 100644 index 00000000..226bff95 --- /dev/null +++ b/lib/blueprinter/errors/meta_requires_root.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'blueprinter/blueprinter_error' + +module Blueprinter + module Errors + class MetaRequiresRoot < BlueprinterError + def initialize(message = 'adding metadata requires that a root key is set') + super + end + end + end +end diff --git a/lib/blueprinter/extractors/association_extractor.rb b/lib/blueprinter/extractors/association_extractor.rb index 9b8bcf36..ae37a451 100644 --- a/lib/blueprinter/extractors/association_extractor.rb +++ b/lib/blueprinter/extractors/association_extractor.rb @@ -21,7 +21,7 @@ def extract(association_name, object, local_options, options = {}) view = options[:view] || :default blueprint = association_blueprint(options[:blueprint], value) - blueprint.prepare(value, view_name: view, local_options: local_options) + blueprint.hashify(value, view_name: view, local_options: local_options) end private diff --git a/lib/blueprinter/field.rb b/lib/blueprinter/field.rb index 8783aab5..26ac1789 100644 --- a/lib/blueprinter/field.rb +++ b/lib/blueprinter/field.rb @@ -26,15 +26,11 @@ def skip?(field_name, object, local_options) private def if_callable - return @if_callable if defined?(@if_callable) - - @if_callable = callable_from(:if) + @_if_callable ||= callable_from(:if) end def unless_callable - return @unless_callable if defined?(@unless_callable) - - @unless_callable = callable_from(:unless) + @_unless_callable ||= callable_from(:unless) end def callable_from(condition) diff --git a/lib/blueprinter/helpers/base_helpers.rb b/lib/blueprinter/helpers/base_helpers.rb deleted file mode 100644 index e95b9c13..00000000 --- a/lib/blueprinter/helpers/base_helpers.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -require 'blueprinter/helpers/type_helpers' -require 'blueprinter/view_collection' - -module Blueprinter - module BaseHelpers - def self.included(base) - base.extend(SingletonMethods) - end - - module SingletonMethods - include TypeHelpers - - private - - def prepare_for_render(object, options) - view_name = options.fetch(:view, :default) || :default - root = options[:root] - meta = options[:meta] - validate_root_and_meta!(root, meta) - prepare( - object, - view_name: view_name, - local_options: options.except( - :view, - :root, - :meta - ), - root: root, - meta: meta - ) - end - - def prepare_data(object, view_name, local_options) - if array_like?(object) - object.map do |obj| - object_to_hash(obj, - view_name: view_name, - local_options: local_options) - end - else - object_to_hash(object, - view_name: view_name, - local_options: local_options) - end - end - - def prepend_root_and_meta(data, root, meta) - return data unless root - - ret = { root => data } - meta ? ret.merge!(meta: meta) : ret - end - - def inherited(subclass) - subclass.send(:view_collection).inherit(view_collection) - end - - def object_to_hash(object, view_name:, local_options:) - result_hash = view_collection.fields_for(view_name).each_with_object({}) do |field, hash| - next if field.skip?(field.name, object, local_options) - - value = field.extract(object, local_options) - - next if value.nil? && field.options[:exclude_if_nil] - - hash[field.name] = value - end - view_collection.transformers(view_name).each do |transformer| - transformer.transform(result_hash, object, local_options) - end - result_hash - end - - def validate_root_and_meta!(root, meta) - case root - when String, Symbol - # no-op - when NilClass - raise BlueprinterError, 'meta requires a root to be passed' if meta - else - raise BlueprinterError, 'root should be one of String, Symbol, NilClass' - end - end - - def jsonify(blob) - Blueprinter.configuration.jsonify(blob) - end - - def current_view - @current_view ||= view_collection[:default] - end - - def view_collection - @view_collection ||= ViewCollection.new - end - - def associations(view_name = :default) - view_collection.fields_for(view_name).select { |f| f.options[:association] } - end - end - end -end diff --git a/lib/blueprinter/reflection.rb b/lib/blueprinter/reflection.rb index 167f9fb7..a84ae917 100644 --- a/lib/blueprinter/reflection.rb +++ b/lib/blueprinter/reflection.rb @@ -23,7 +23,7 @@ module Reflection # @return [Hash] # def reflections - @reflections ||= view_collection.views.transform_values do |view| + @_reflections ||= view_collection.views.transform_values do |view| View.new(view.name, view_collection) end end @@ -45,7 +45,7 @@ def initialize(name, view_collection) # @return [Hash] # def fields - @fields ||= @view_collection.fields_for(name).each_with_object({}) do |field, obj| + @_fields ||= @view_collection.fields_for(name).each_with_object({}) do |field, obj| next if field.options[:association] obj[field.name] = Field.new(field.method, field.name, field.options) @@ -58,7 +58,7 @@ def fields # @return [Hash] # def associations - @associations ||= @view_collection.fields_for(name).each_with_object({}) do |field, obj| + @_associations ||= @view_collection.fields_for(name).each_with_object({}) do |field, obj| next unless field.options[:association] blueprint = field.options.fetch(:blueprint) diff --git a/lib/blueprinter/rendering.rb b/lib/blueprinter/rendering.rb new file mode 100644 index 00000000..e39ef294 --- /dev/null +++ b/lib/blueprinter/rendering.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'blueprinter/errors/invalid_root' +require 'blueprinter/errors/meta_requires_root' + +module Blueprinter + # Encapsulates the rendering logic for Blueprinter. + module Rendering + include TypeHelpers + + # Generates a JSON formatted String represantation of the provided object. + # + # @param object [Object] the Object to serialize. + # @param options [Hash] the options hash which requires a :view. Any + # additional key value pairs will be exposed during serialization. + # @option options [Symbol] :view Defaults to :default. + # The view name that corresponds to the group of + # fields to be serialized. + # @option options [Symbol|String] :root Defaults to nil. + # Render the json/hash with a root key if provided. + # @option options [Any] :meta Defaults to nil. + # Render the json/hash with a meta attribute with provided value + # if both root and meta keys are provided in the options hash. + # + # @example Generating JSON with an extended view + # post = Post.all + # Blueprinter::Base.render post, view: :extended + # # => "[{\"id\":1,\"title\":\"Hello\"},{\"id\":2,\"title\":\"My Day\"}]" + # + # @return [String] JSON formatted String + def render(object, options = {}) + jsonify(build_result(object: object, options: options)) + end + + # Generates a Hash representation of the provided object. + # Takes a required object and an optional view. + # + # @param object [Object] the Object to serialize upon. + # @param options [Hash] the options hash which requires a :view. Any + # additional key value pairs will be exposed during serialization. + # @option options [Symbol] :view Defaults to :default. + # The view name that corresponds to the group of + # fields to be serialized. + # @option options [Symbol|String] :root Defaults to nil. + # Render the json/hash with a root key if provided. + # @option options [Any] :meta Defaults to nil. + # Render the json/hash with a meta attribute with provided value + # if both root and meta keys are provided in the options hash. + # + # @example Generating a hash with an extended view + # post = Post.all + # Blueprinter::Base.render_as_hash post, view: :extended + # # => [{id:1, title: Hello},{id:2, title: My Day}] + # + # @return [Hash] + def render_as_hash(object, options = {}) + build_result(object: object, options: options) + end + + # Generates a JSONified hash. + # Takes a required object and an optional view. + # + # @param object [Object] the Object to serialize upon. + # @param options [Hash] the options hash which requires a :view. Any + # additional key value pairs will be exposed during serialization. + # @option options [Symbol] :view Defaults to :default. + # The view name that corresponds to the group of + # fields to be serialized. + # @option options [Symbol|String] :root Defaults to nil. + # Render the json/hash with a root key if provided. + # @option options [Any] :meta Defaults to nil. + # Render the json/hash with a meta attribute with provided value + # if both root and meta keys are provided in the options hash. + # + # @example Generating a hash with an extended view + # post = Post.all + # Blueprinter::Base.render_as_json post, view: :extended + # # => [{"id" => "1", "title" => "Hello"},{"id" => "2", "title" => "My Day"}] + # + # @return [Hash] + def render_as_json(object, options = {}) + build_result(object: object, options: options).as_json + end + + # Converts an object into a Hash representation based on provided view. + # + # @param object [Object] the Object to convert into a Hash. + # @param view_name [Symbol] the view + # @param local_options [Hash] the options hash which requires a :view. Any + # additional key value pairs will be exposed during serialization. + # @return [Hash] + def hashify(object, view_name:, local_options:) + raise BlueprinterError, "View '#{view_name}' is not defined" unless view_collection.view?(view_name) + + object = Blueprinter.configuration.extensions.pre_render(object, self, view_name, local_options) + prepare_data(object, view_name, local_options) + end + + private + + attr_reader :blueprint, :options + + def prepare_data(object, view_name, local_options) + if array_like?(object) + object.map do |obj| + object_to_hash( + obj, + view_name: view_name, + local_options: local_options + ) + end + else + object_to_hash( + object, + view_name: view_name, + local_options: local_options + ) + end + end + + def object_to_hash(object, view_name:, local_options:) + result_hash = view_collection.fields_for(view_name).each_with_object({}) do |field, hash| + next if field.skip?(field.name, object, local_options) + + value = field.extract(object, local_options) + next if value.nil? && field.options[:exclude_if_nil] + + hash[field.name] = value + end + view_collection.transformers(view_name).each do |transformer| + transformer.transform(result_hash, object, local_options) + end + result_hash + end + + def jsonify(data) + Blueprinter.configuration.jsonify(data) + end + + def apply_root_key(object:, root:) + return object unless root + return { root => object } if root.is_a?(String) || root.is_a?(Symbol) + + raise(Errors::InvalidRoot) + end + + def add_metadata(object:, metadata:, root:) + return object unless metadata + return object.merge(meta: metadata) if root + + raise(Errors::MetaRequiresRoot) + end + + def build_result(object:, options:) + view_name = options.fetch(:view, :default) || :default + + prepared_object = hashify( + object, + view_name: view_name, + local_options: options.except(:view, :root, :meta) + ) + object_with_root = apply_root_key( + object: prepared_object, + root: options[:root] + ) + add_metadata( + object: object_with_root, + metadata: options[:meta], + root: options[:root] + ) + end + end +end diff --git a/spec/integrations/shared/base_render_examples.rb b/spec/integrations/shared/base_render_examples.rb index 172a3ffa..3646c397 100644 --- a/spec/integrations/shared/base_render_examples.rb +++ b/spec/integrations/shared/base_render_examples.rb @@ -452,15 +452,15 @@ def self.if_method(_field_name, _object, _local_opts) context 'Given blueprint has :meta without :root' do let(:blueprint) { blueprint_with_block } - it('raises a BlueprinterError') { - expect{blueprint.render(obj, meta: 'meta_value')}.to raise_error(Blueprinter::BlueprinterError) + it('raises a MetaRequiresRoot error') { + expect{blueprint.render(obj, meta: 'meta_value')}.to raise_error(Blueprinter::Errors::MetaRequiresRoot) } end context 'Given blueprint has root as a non-supported object' do let(:blueprint) { blueprint_with_block } - it('raises a BlueprinterError') { - expect{blueprint.render(obj, root: {some_key: "invalid root"})}.to raise_error(Blueprinter::BlueprinterError) + it('raises a InvalidRoot error') { + expect{blueprint.render(obj, root: {some_key: "invalid root"})}.to raise_error(Blueprinter::Errors::InvalidRoot) } end