Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Blueprinter

Warning

This is a WIP for API V2!

Blueprinter is a JSON serializer for your business objects. It is designed to be simple, flexible, and performant.

Upgrading from Blueprinter V1? Read over the changes!

Installation

bundle add blueprinter

See rubydoc.info/gems/blueprinter for API documentation.

Basic Usage

class WidgetBlueprint < ApplicationBlueprint
  field :name
  association :category, CategoryBlueprint
  association :parts, [PartBlueprint]

  view :extended do
    field :description
    association :manufacturer, CompanyBlueprint
    association :vendors, [CompanyBlueprint]
  end
end

# Render the default view to JSON
WidgetBlueprint.render(widget).to_json

# Render the extended view to a Hash
WidgetBlueprint[:extended].render(widget).to_hash

Changes in V2

While Blueprinter V2 is recognizably Blueprinter, there are several breaking changes. Some of them will require code changes while others can be made backwards-compatible using bundled extensions.

# Can you spot the differences?
class WidgetBlueprint < ApplicationBlueprint
  field :name
  field(:desc) { |widget| widget.description[0..100] }
  association :category, CategoryBlueprint

  view :extended do
    field :desc, source: :description
    association :subcategory, CategoryBlueprint[:subcategory]
    association :parts, [PartBlueprint]
  end
end

Interoperability

The initial release of V2 is included alongside legacy/V1 code, allowing you to migrate your codebase slowly. V2 Blueprints can reference legacy/V1 Blueprints in associations, and vice versa.

Eventually the legacy/V1 code will be removed, necessitating a complete switch to V2.

Breaking Changes

When you migrate a Blueprint to V2, you’ll be required to make the changes in this section.

Generally speaking the Blueprint DSL is easy to upgrade. But if you’re using extensive global configuration options, be prepared to change how they work.

Breaking Rendering Changes

If you’re using Rails’ render json: you don’t have to change anything:

render json: WidgetBlueprint.render(widget)

Otherwise, it now looks like this:

# JSON string
WidgetBlueprint.render(widget).to_json

# Hash (or array of Hashes if you passed an Enumerable)
WidgetBlueprint.render(widget).to_hash

Note

The legacy render_as_hash and render_as_json methods are still present but may be removed in a future release.

Breaking DSL Changes

At a quick glance the DSL looks nearly identical, but don’t be fooled! See the docs of Blueprinter::V2::DSL for complete documentation of all new features.

Association syntax

Associations in V2 are more concise while conveying more information.

# A single object
association :category, CategoryBlueprint
association :category, CategoryBlueprint[:my_view]

# A collection (Enumerable) of objects
association :parts, [PartBlueprint]
association :parts, [PartBlueprint[:my_view]]

To dynamically reference the current view’s name, use view_name or view_path:

view :extended do
  # ...

  view :plus do
    # Looks for a view named 'plus'
    association :category, CategoryBlueprint[view_name]

    # Looks for a nested view named 'extended.plus'
    association :category, CategoryBlueprint[view_path]
  end
end

Learn more about nested views.

Second arg in field blocks

If you’re using the second argument in your field/association blocks, it’s no longer the render options. It’s now a Blueprinter::V2::Context::Field object, which contains the render options along with a lot more.

You can update your block in a single line:

field :description do |object, ctx|
  options = ctx.options
  # ...
end

Including a view

If you’re including a view in another view, replace include_view with use.

view :my_view do
  use :other_view
  # ...
end

This change may look superfluous, but it’s the result of a cool new feature: partials.

Transformers

Transforming a Blueprint’s output can be done using the around_blueprint extension hook. (See Blueprinter::Extension for full Extension API docs).

However, the LegacyTransformer extension offers compatibility with legacy/V1 transformer classes:

add Blueprinter::Extensions::LegacyTransformer.new(MyTransformer)

identifier

You can easily replicate legacy/V1’s identifier field and view in your base Blueprint:

class ApplicationBlueprint < Blueprinter::Extension
  # The id field will be added to all Blueprints and views
  field :id

  # All Blueprints will get an identifier view, and it will only ever contain id
  view :identifier do
    exclude fields: true
    field :id
  end
end

Breaking Configuration Changes

Configuration in V2 uses a completely different paradigm. There is no global configuration in V2. All configuration happens in Blueprints, views, or partials by setting options or adding extensions.

Options and extensions are inherited from parent Blueprints and views and can be overridden by their children. This allows different parts of your system to be configured differently. “Global” configuration should be placed in your application’s base Blueprint, e.g. ApplicationBlueprint.

Each legacy/V1 global configuration option is listed below, along with its V2 equivalent.

association_default / field_default

Set the :default option in your base Blueprint. It will be passed a Blueprinter::V2::Context::Field object, containing the current field, the current object, and a lot more.

set :default, ->(ctx) {
  case ctx.field.type
  when :field then "N/A"
  when :object then {}
  when :collection then []
  end
}

if / unless

Set the :if and :unless options in your base Blueprint. They will be passed a Blueprinter::V2::Context::Field object, containing the current field, the current object, and a lot more.

set :if, ->(ctx) {
  # extract the V1 args from ctx
  field_name = ctx.field.source
  object = ctx.object
  options = ctx.options
  # ...
}

custom_array_like_classes

render has sensible heuristics for detecting what’s a “collection” or not: any Enumerable except for Hash.

If that logic doesn’t work for something, use one of these methods in place of render:

# Force `arg` to be treated like a collection (must respond to `map` with an Enumerable)
array = WidgetBlueprint.render_collection(arg).to_hash
# Force `arg` to be treated like an object
hash = WidgetBlueprint.render_object(arg).to_hash

extractor_default

“Extractors” are not a discrete concept in V2, but they can be implemented using the around_field_value, around_object_value, and around_collection_value extension hooks.

The bundled LegacyExtractorOption extension can be enabled to offer backwards-compatibility with legacy/V1’s extractors:

# Add the extension
add Blueprinter::Extensions::LegacyExtractorOption.new

# Set the `extractor` option
set :extractor, MyExtractor

datetime_format

Formatting can now be applied to any class. Format dates, times, booleans, or anything else. Define them in your blueprints, views, or partials.

# You can define them with blocks
format(TrueClass) { |val| "Y" }
format(FalseClass) { |val| "N" }

# Or with method names
format Date, :iso8601
format Time, :iso8601

def iso8601(val) = val.iso8601

See the Documentation for Blueprinter::V2::DSL#format.

default_transformers

Transformations can be done using V2’s around_blueprint extension hook (See Blueprinter::Extension for full Extension API docs).

However, the bundled LegacyTransformer extension offers compatibility with legacy/V1 transformer classes:

add Blueprinter::Extensions::LegacyTransformer.new(
  MyTransformer, OtherTransformer
)

generator / method

To use a different JSON serializer you can use the Blueprinter::Extensions::MultiJson extension. (You’ll need the multi_json gem installed and configured).

# Be sure this is the FIRST extension you add
add Blueprinter::Extensions::MultiJson.new

If multi_json doesn’t support your serializer, you can create your own extension using the around_result hook:

class MyJsonExtension < Blueprinter::Extension
  def around_result(ctx)
    case ctx.format
    when :json
      result = yield ctx
      json = MySerializer.dump result
      # The `serialized` helper tells Blueprinter we've already JSONified the result
      serialized json
    else
      yield ctx
    end
  end
end
class ApplicationBlueprint < Blueprinter::V2::Base
  # Be sure this is the FIRST extension you add
  add MyJsonExtension.new
end

sort_fields_by

By default V2 serializes fields in the order they were defined. If you want a different order, use the bundled FieldOrder extension or the around_blueprint_init extension hook.

The following replicates legacy/V1’s default field order of “alphabetical with id first”:

add Blueprinter::Extensions::FieldOrder.new { |a, b|
  if a.name == :id
    -1
  elsif b.name == :id
    1
  else
    a.name <=> b.name
  end
}

extensions

Add a “global” extension by adding it to your base Blueprint:

add MyExtension.new

V2 Extension Hook API

V2 has a completely different, and much more poweful, hook system. Be sure your extension supports the V2 API!

Read Breaking Extension Changes, the Extension Guide, or the documentation for Blueprinter::Extension for more info.

Breaking Reflection Changes

Note

Most applications don’t use reflection and can skip this section.

V2’s reflection API is backwards compatible with the exception of renamed fields. This is a result of a change in the DSL.

V1 style

class MyBlueprint < Blueprinter::Base
  # A field named "desc" that pulls from "description"
  field :description, name: :desc
end

field = MyBlueprint.reflections[:default].fields[:desc]
puts field.display_name # name of serialized field
# => :desc
puts field.name         # name of field's source
# => :description

V2 style

class MyBlueprint < Blueprinter::Base
  # A field named "desc" that pulls from "description"
  field :desc, source: :description
end

field = MyBlueprint.reflections[:default].fields[:desc]
puts field.name   # name of serialized field
# => :desc
puts field.source # name of field's source
# => :description

Breaking Extension Changes

Note

Most applications don’t use extensions (yet) and can skip this section.

Legacy/V1 had only one extension hook: pre_render. V2’s closest analog is around_result, which runs once at the beginning of every call to render.

Here’s an example of an extension that supports both V1 and V2:

class MyExtension < Blueprinter::Extension
  # V1 API
  def pre_render(object, blueprint_class, view, options)
    modify(object, blueprint_class, view, options)
  end

  # V2 API
  def around_result(ctx)
    blueprint_class = ctx.blueprint.class
    view = blueprint_class.view_name
    ctx.object = modify(ctx.object, blueprint_class, view, ctx.options)
    yield ctx
  end

  private

  def modify(object, blueprint_class, view, options)
    # return a modified or new object
  end
end

See Blueprinter::Extension for the full V2 extension API, or read the Extension Guide.

Compatible Changes

When you migrate a Blueprint to V2 you can enable the LegacyOptions extension to maintain backwards-compatibility with many V1 options.

class ApplicationBlueprint < Blueprinter::V2::Base
  add Blueprinter::Extensions::LegacyOptions.new
end

Note

These extensions don’t replace V2’s native behavior. Instead, they detect and convert V1-style options. This allows your application to migrate to V2 at a gradual pace.

Rendering views

V2’s method of rendering a view is:

WidgetBlueprint[:my_view].render(widget).to_json

However, with the LegacyOptions extension you may continue using V1-style view rendering:

WidgetBlueprint.render(widget, view: :my_view).to_json

If/unless options

V2’s if and unless options have different Proc arguments than legacy/V1. If you enable the LegacyOptions extension, if/unless Procs with three arguments will continue to work like they did in V1:

field :a, if: ->(field_name, object, options) { object.active? }

V2 style

V2-style if/unless Procs will continue to work. They accept a single Blueprinter::V2::Context::Field argument:

field :a, if: ->(ctx) {
  field_name = ctx.field.source
  object = ctx.object
  options = ctx.options
  object.active?
}

default_if option

V2 has a much more flexible default_if option, but you can maintain backwards compatibility with V1 using the LegacyOptions extension.

The old default_if values will continue to work (until a future Blueprinter removes the constants).

field :name, default: "N/A", default_if: Blueprinter::EMPTY_STRING

V2 style

The native V2 option allows for Procs or symbols (method names). The logic is up to you.

They’re passed a Blueprinter::V2::Context::Field object and the extracted value.

field :name, default: "N/A", default_if: ->(ctx, val) { val.empty? }
field :name, default: "N/A", default_if: :empty_string?

def empty_string?(ctx, val) = val.empty?

Field name option

In Blueprinter Legacy/V1, when a field’s serialized name differs from the source name you represent it like this:

# V1: Here's a field called "description". It pulls from "desc".
field :desc, name: :description

The LegacyOptions extension allows this to continue working.

V2 style

Since Blueprinter is a serializer, the DSL should describe the serialized result, not the source object. V2 reads much more naturally:

# V2: Here's a field called "description". It pulls from "desc".
field :description, source: :desc

Extractor option

V2 does not have a specific “extractor” concept, but the LegacyOptions extension allows your existing extractors to continue working.

field :foo, extractor: MyExtractor

V2 style

In V2 you can override Blueprinter’s field extraction using field blocks or the around_field_value, around_object_value, and around_collection_value extension hooks.

class MyExtractorExtension < Blueprinter::Extension
  # @param ctx [Blueprinter::V2::Context::Field]
  def extract(ctx)
    # Instead of yielding to get the field value, extract and return it here
    field_source = ctx.field.source
    object = ctx.object
    # ...
  end
  
  alias around_field_value extract
  alias around_object_value extract
  alias around_collection_value extract
end

Dynamic options

Associations in legacy/V1 provided an :options option on associations. It allowed you to define a Hash or Proc that would be merged into the render options.

It was provided as a work-around when legacy/V1 began freezing options, since options could no longer be used as an arbitrary “store” in field blocks, etc.

In V2 it’s enabled by the LegacyDynamicOptions extension. It’s recommended to only add this extension to specific blueprints that need it:

class WidgetBlueprint < ApplicationBlueprint
  add Blueprinter::Extensions::LegacyDynamicOptions.new

  association :category, CategoryBlueprint, options: ->(widget) {
    # Will be merged into `ctx.options`
    { foo: widget.foo }
  }
end

V2 style

V2 provides the ctx.store Hash for Blueprints and extensions to share information.

class WidgetBlueprint < ApplicationBlueprint
  association :category, CategoryBlueprint do |widget, ctx|
    cxt.store[:foo] = widget.foo
    widget.category
  end
end

New Features

V2 introduces several new concepts to Blueprinter. Some of these may have been mentioned in previous sections, but continue reading for a deep dive.

Formatters

Blueprinter V2 has a more flexible and ergonomic approach to formatting, allowing any class to define its own formatter in a single line:

class MyBlueprint < ApplicationBlueprint
  format(Date) { |date| date.iso8601 }
  format TrueClass, :boolean_str
  format FalseClass, :boolean_str

  def boolean_str(bool)
    bool ? "Y" : "N"
  end
end

If your formatter needs more information (field details, options passed to render, etc) use the around_field_value, around_object_value, and around_collection_value extension hooks. See Blueprinter::Extension for the full API, or get started with the Extension Guide.

Views are Blueprints

A view is an anonymous subclass of its Blueprint. There is little practical or technical distinction between “blueprints” and “views”.

This has two important consequences:

  1. The DSL is recursive: views can do anything Blueprints can do.
  2. Views are Ruby classes: views can do anything Ruby classes can do.
class MyBlueprint < ApplicationBlueprint
  set :exclude_if_nil, true
  add MyExtension.new
  format Time, :iso8601
  
  fields :name, :description

  # This view is a subclass of MyBlueprint
  view :my_view do
    # Override inherited options
    set :exclude_if_nil, false

    # Add an extension
    add OtherExtension.new

    # Include a Ruby module. Only this view (and any child views) will have it.
    include MyHelpers

    # Override the formatter's method to ensure UTC time
    def iso8601(t) = t.utc.iso8601
  end

  def iso8601(t) = t.iso8601
end

You can even subclass another Blueprint’s view!

class FooBlueprint < MyBlueprint[:my_view] do
  # ...
end

The default view

The default view is simply an alias to the class:

MyBlueprint == MyBlueprint[:default]
=> true

And since views are their own Blueprints, each view has its own default view:

MyBlueprint[:my_view] == MyBlueprint[:my_view][:default]
=> true

Which brings us to the next topic: Nested Views.

Nested views

Because the V2 DSL is recursive, you can define views inside of views.

Each nested view inherits from its parent, which inherits from its parent, all the way up to ApplicationBlueprint and Blueprinter::V2::Base.

class MyBlueprint < ApplicationBlueprint
  fields :name, :description

  view :extended do
    association :category, CategoryBlueprint

    view :with_foo do
      association :foo, FooBlueprint
    end

    view :with_bar do
      association :bar, BarBlueprint
    end
  end
end

You can render nested views using dot syntax:

MyBlueprint["extended.with_foo"].render(widget).to_json

or nested Hash syntax:

MyBlueprint[:extended][:with_bar].render(widget).to_json

Partials

Partials allow you to compose your Blueprints and views from reusable pieces.

class MyBlueprint < ApplicationBlueprint
  view :view_1 do
    use :common_associations
    # ...
  end

  view :view_2 do
    use :common_associations
    # ...
  end
  
  partial :common_associations do
    association :foo, FooBlueprint
    association :bar, [BarBlueprint]
  end
end

You may only use partials defined in the current Blueprint/view (or a parent). To share code across completely separate Blueprints, use modules.

See the Blueprinter::V2::DSL docs for more info on partial and use.

More than just fields

Partials have access to the full DSL, so they can set options, add extensions, formatters, views, and even other partials.

class ApplicationBlueprint < Blueprinter::V2::Base
  partial :exclude_nil_or_blank do
    # Enable the built-in "skip if nil" option
    set :exclude_if_nil, true

    # Add an inline extension to skip blank fields
    extension do
      def around_field_value(ctx)
        val = yield ctx
        skip! if val.blank?
        val
      end
    end
  end
end

Blueprints or views that use the above partial will skip any nil or blank fields:

class MyBlueprint < ApplicationBlueprint
  use :exclude_nil_or_blank
end

Modules

Any Ruby module can be extended with the Blueprinter DSL:

module MySharedBlueprintCode
  extend Blueprinter::V2::DSL

  set :exclude_if_nil, true
  add MyExtension.new
  
  format Time, :iso8601
  format Date, :iso8601

  field :foo

  view :full do
    association :bar, BarBlueprint
  end

  def iso8601(val) = val.iso8601
end

Then included into Blueprints:

class MyBlueprint < ApplicationBlueprint
  include MySharedBlueprintCode

  # ...
end

Or views:

class MyBlueprint < ApplicationBlueprint
  # ...

  view :my_view do
    include MySharedBlueprintCode
    
    # ...
  end
end

Or partials:

class MyBlueprint < ApplicationBlueprint
  # ...

  partial :my_partial do
    include MySharedBlueprintCode
    
    # ...
  end
end

Extensions

Extensions exist in legacy/V1, but they were added late and have limited functionality. Blueprinter V2 has been designed from the ground up with extensions in mind.

Writing extensions

See the Blueprinter::Extension docs for the full API, or read through the Extension Guide.

Using extensions

Extensions can be added to Blueprints, views, and partials. They’ll be inherited by child Blueprints or views.

# Add some extensions (append)
add MyExtension.new, MyOtherExtension.new

# Prepend an extension
add MyExtension.new, prepend: true

# Remove an extension by class or block
remove MyExtension
remove { |ext| ext.is_a? MyExtension }

# Prevent inheritance of extensions
exclude extensions: true

Bundled extensions

Blueprinter V2 comes bundled with the following extensions. See the Blueprinter::Extensions module for full documentation about each one.

MultiJson

Uses the multi_json gem to serialize JSON. See Blueprinter::Extensions::MultiJson.

OpenTelemetry

Instruments the serialization process so you can see which Blueprints or extensions are slowing things down. See Blueprinter::Extensions::OpenTelemetry.

FieldOrder

Customizes the field order in the serialized output. See Blueprinter::Extensions::FieldOrder.

V1 Compatibility Extensions

These extensions offer V1-compatibility for some options. They’re covered under the Compatible Changes section.

  • Blueprinter::Extensions::LegacyOptions
  • Blueprinter::Extensions::LegacyTransformer

Extension Guide

Is there some functionality you wish Blueprinter had? Building an extension might be the best way to add it!

This guide will walk you through building some sample extensions. While reading the docs for Blueprinter::Extension is the best way to learn the entire API, this guide shows off some of the powerful things you can do.

If you need more inspiration, look over some real-world extensions like blueprinter-activerecord or Blueprinter’s bundled extensions (TODO link).

Basics

An extension will subclass Blueprinter::Extension and define one or more hook methods. (See Blueprinter::Extension for documentation about all hooks.)

class ExcludeIfBlankExtension < Blueprinter::Extension
  def around_field_value(ctx)
    # get the value by yielding to other extensions and Blueprinter's internals
    val = yield ctx

    # skip this field and halt further extensions if the value is blank
    skip! if ctx.field.options[:exclude_if_blank] && val.blank?

    # return the value for the next extension (or Blueprinter) to use
    val
  end
end

Then add the extension to a blueprint:

class MyBlueprint < Blueprinter::V2::Base
  add ExcludeIfBlankExtension.new

  field :name, exclude_if_blank: true
end

Context Objects

All extension hooks take a single argument: a context object. It contains the “context” of the current operation (Blueprint, options, field, the object, etc.) so you can react to it and, in some cases, alter it.

There are several different context object types. See the Blueprinter::V2::Context module for documentation on which hooks receive which types, and what can be done with them.

Exclude If Blank

Here’s a more fully formed version of the ExcludeIfBlank extension from the previous section.

It works just like the built-in exclude_if_nil option, but checks for blank? instead. It checks the Blueprint options first, then allows fields to override it.

class ExcludeIfBlankExtension < Blueprinter::Extension
  # @param ctx [Blueprinter::V2::Context::Field]
  def around_field_value(ctx)
    val = yield ctx

    exclude = ctx.blueprint.options[:exclude_if_blank]
    exclude = ctx.field.options[:exclude_if_blank] if ctx.field.options.key? :exclude_if_blank
    skip! if exclude && val.blank?

    val
  end
end

Add it to your ApplicationBlueprint and use it in your blueprints.

class ApplicationBlueprint < Blueprinter::V2::Base
  add ExcludeIfBlankExtension.new
end
class WidgetBlueprint < ApplicationBlueprint
  field :name
  field :description, exclude_if_blank: true
end
class CategoryBlueprint < ApplicationBlueprint
  set :exclude_if_blank, true

  field :name, exclude_if_blank: false
  field :description
end

Custom Extractor

Blueprinter V2 automatically handles extraction from Hashes (symbol and string keys) and objects (public_send). If you have a more complex case, use the around_field_value, around_object_value, or around_collection_value extension hooks.

The following example extension allows you to define extraction behavior for a given class:

class CustomExtractor < Blueprinter::Extension
  def initialize(klass, &extractor)
    @klass = klass
    @extractor = extractor
  end
  
  # @param ctx [Blueprinter::V2::Context::Field]
  def around_field_value(ctx)
    if ctx.object.is_a? @klass
      # Use custom extraction
      @extractor.call(ctx.field.source, ctx.object)
    else
      # Let Blueprinter (or another extension) handle extraction
      yield ctx
    end
  end

  # Same behavior for associations
  alias around_object_value around_field_value
  alias around_collection_value around_field_value
end

Then add it to your base Blueprint, or to a specific Blueprint:

class MyBlueprint < ApplicationBlueprint
  add CustomExtractor.new(MyClass) { |attr, object|
    # extract `attr` from `object` and return
  }
end

Blueprint Decorator

This example adds metadata to the output of a Blueprint, similar to Legacy/V1’s transformer feature.

class DecoratorExtension < Blueprinter::Extension
  def initialize(attr, &decorator)
    @attr = attr
    @decorator = decorator
  end

  # @param ctx [Blueprinter::V2::Context::Object]
  def around_blueprint(ctx)
    result = yield ctx
    result[@attr] = @decorator.call(ctx.object)
    result
  end
end

Add the extension to whatever blueprints need metadata:

class MyBlueprint < ApplicationBlueprint
  add DecoratorExtension.new(:metadata) { |object|
    # extract and return metadata from the object
  }
end

Telemetry

Blueprinter bundles the Blueprinter::Extensions::OpenTelemetry extension. But if that doesn’t work with your tooling, build your own!

class MyTelemetryExtension
  def initialize(my_tel)
    @my_tel = my_tel
  end

  # Create a span for object serialization
  # @param ctx [Blueprinter::V2::Context::Object]
  def around_serialize_object(ctx)
    @my_tel.span("blueprint.object", blueprint: ctx.blueprint.to_s) do
      yield ctx
    end
  end

  # Create a span for collection serialization
  # @param ctx [Blueprinter::V2::Context::Object]
  def around_serialize_collection(ctx)
    @my_tel.span("blueprint.collection", blueprint: ctx.blueprint.to_s) do
      yield ctx
    end
  end

  # Create a span for other extension hooks
  # @param ctx [Blueprinter::V2::Context::Hook]
  def around_hook(ctx)
    @my_tel.span("blueprint.extension", extension: ctx.extension.class.name, hook: ctx.hook) do
      yield
    end
  end

  # Prevent `around_hook` from running around this extension's own hooks
  def hidden? = true
end

Camelize Fields

This example alters the source of each field to use camel case, leaving the serialized name as-is.

class CamelizedSourceExtension < Blueprinter::Extension
  def around_blueprint_init(ctx)
    ctx.fields.each do |field|
      field.source = field.source.to_s.camelize(:lower).to_sym
    end
    yield ctx
  end
end

It’s the equivalent of doing this for every field:

field :foo_bar, source: :fooBar

YAML Serializer

This example adds YAML to Blueprinter. Why? Because we can.

class YamlSerializerExtension < Blueprinter::Extension
  def around_result(ctx)
    case ctx.format
    when :yaml
      # Get the Hash/Array result
      result = yield ctx
      
      # Convert it to YAML
      yaml = YAML.dump result
      
      # Return YAML, declaring it as already serialized
      serialized yaml
    else
      # Let Blueprinter or another extension handle other formats
      yield ctx
    end
  end
end

You can serialize to your custom format using the to method:

MyBlueprint.render(object).to(:yaml)

Extensions that serialize should be added FIRST

When you’re adding an extension that adds a serialization format, or replaces the built-it :json or :hash ones, be sure it’s listed first.

class ApplicationBlueprint < Blueprinter::V2::Base
  add YamlSerializerExtension.new, OtherExtension.new
end

Performance

Any use of extensions has some performance implications, irrespective of what the extension code is doing. This can range anywhere from “essentially zero” to “noticible”. (See Blueprinter::Extension for documentation about which hooks are the most and least expensive.)

Given the number of extension hooks available, there are frequently multiple ways to implement a given behavior. Some of these will be more efficient than others.

Example: Camel case converter

Let’s say your source objects store fields in camelCase, but you want to serialize them using snake_case. You could define every field in your Blueprints like this:

field :some_field, source: :someField

But that’s tedious and error-prone. Let’s write an extension to do it for us.

Manually extract each field

The around_field_value hook and friends are the simplest way to implement it:

class CamelCaseSource < Blueprinter::Extension
  # @param ctx [Blueprinter::V2::Context::Field]
  def around_field_value(ctx)
    attr = ctx.field.source_str.camelize
    if ctx.object.is_a? Hash
      ctx.object.key?(attr) ? ctx.object[attr] : ctx.object[attr.to_sym]
    else
      ctx.object.public_send(attr)
    end
  end

  alias around_object_value around_field_value
  alias around_collection_value around_field_value
end

But what about the performance? These hooks will run for every field on the Blueprint. If you’re adding this extension only to specific Blueprints that might be fine. But what if someone adds it to ApplicationBlueprint? Then it will run for every field in every Blueprint in your application!

Transform each Blueprint’s result

The around_blueprint hook allows you to intercept and modify the serialized hash output of each Blueprint. If we define our Blueprints in camel case:

field :someField

Then we can convert the output to snake case:

class CamelCaseSource < Blueprinter::Extension
  # @param ctx [Blueprinter::V2::Context::Object]
  def around_blueprint(ctx)
    # Get the serialized output from Blueprinter
    result = yield ctx
    # Convert it
    snake_case result
    result
  end

  def snake_case(hash)
    hash.transform_keys! { |k| k.to_s.underscore }
    hash.each_value { |v| snake_case v if v.is_a? Hash }
  end
end

This runs only once per serialized object, so it’s a big improvement. But that could still be hundreds of times, or more, in a large result. Can we do better?

Change field config before render

What if we could alter the field definitions programatically, before anything is serialized? The around_blueprint_init hook runs only once per Blueprint during a render. It’s quite cheap, and it can alter field definitions.

class CamelCaseSource < Blueprinter::Extension
  # @param ctx [Blueprinter::V2::Context::Init]
  def around_blueprint_init(ctx)
    ctx.fields.each do |field|
      field.source = field.source_str.camelize.to_sym
    end
    yield ctx
  end

This is effectively the same as setting source on each field in the Blueprint. There’s essentially zero runtime cost.

Legacy/V1 Docs

TODO copy from old README