Assembler

Build Status Code Climate

An assembler swarm is a bunch of nanomachines that can build almost anything.

Assembler is a library that gives you a DSL to describe a super-handy initializer pattern. You specify the parameters your object should take and Assembler gives you an initializer that takes an options hash as well as yielding a builder object to a block. It takes care of storing the parameters and gives you private accessors, too.

Contents

Usage

assemble_from

The assemble_from method is the core of Assembler. It's also aliased as assemble_with. The basic use case is to pass in required parameters followed by optional parameters (and their defaults). Take this simple example:

class IMAPConnection
  extend Assembler

  assemble_from :hostname, use_ssl: true, port: nil

  # Additional business logic here...
end

This enables you to instantiate your object with either an options hash or via a block. For example:

# These two are equivalent:
IMAPConnection.new(hostname: 'imap.example.com')
IMAPConnection.new do |aw|
  aw.hostname = 'imap.example.com'
end

# These two are equivalent:
IMAPConnection.new(hostname: 'imap.example.com', use_ssl: false)
IMAPConnection.new do |aw|
  aw.hostname = 'imap.example.com'
  aw.use_ssl = false
end

# Or you can do a combination, if you need to:
IMAPConnection.new(hostname: 'imap.example.com') do |aw|
  aw.use_ssl = false
end

Note that when you set use_ssl to false, the code respects that, rather than over-writing anything falsey with the default. If you don't want that, override it like with port, below.

You get private attr_readers for the parameters you specify, but you can always override them, if you like. You might have this lower down in your IMAPConnection class:

class IMAPConnection
  attr_reader :hostname # makes `hostname` public

  def ssl?
    !!use_ssl
  end

  def port
    @port ||= ssl? ? 993 : 143
  end
end

These various syntaxes enable some trickery when you're dealing with a world of uncertainty. Let's look at a more complicated example.

Say you want a class that lets us describe an Elastic Load Balancer for Amazon Web Services. There's a lot of complexity in what each of these arguments might be, but the key thing for this example is this: If you have subnets, you shouldn't have availability_zones and if you have availability_zones, you shouldn't have subnets. And, importantly, you shouldn't send in extraneous keys; you need to be able to differentiate callers sending nil explicitly from not sending in anything when you make whatever API calls you're going to make to Amazon.

class AmazonELB
  extend Assembler
  assemble_from(
    :name,
    load_balancer_name: nil,
    health_check: nil,
    listeners: nil,
    security_groups: nil,
    instances: nil,
    subnets: nil,
    availability_zones: nil,
  )

  # Additional, complex business logic...
end

Now, since there's a lot of complexity in what each of these arguments might be, say you've developed some best-practices about what each of them should be. And you want to make it easy to pop off slight variations on what you consider to be a "standard" ELB.

module ELBFactory
  def self.make_me_an_elb(subnet_ids=nil, availability_zones=nil, name_prefix='', instance_ids=[], security_groups=[])
    AmazonELB.new do |elb|
      elb.name = name(name_prefix)
      elb.load_balancer_name = name(name_prefix)
      elb.security_groups = security_groups
      elb.instance_ids = instance_ids

      elb.health_check = HealthCheck.new(
        target: 'HTTP:8000/',
        healthy_threshold: '3',
        unhealthy_threshold: '5',
        interval: '30',
        timeout: '5'
      )
      elb.listeners = [Listener.new(...), Listener.new(...)]

      if subnet_ids
        elb.subnets = subnet_ids
      else
        elb.availability_zones = availability_zones
      end
    end
  end

  def self.name(name_prefix)
    "#{sanitize_for_name(name_prefix)}LoadBalancer"
  end

  def self.sanitize_for_name(string)
    # ...
  end
end

Note the if/else block near the end of the initialization block. If the initialization method only took hashes, you would either have to wrap object creation in an if/else and repeat all the constructor arguments that were shared between the two cases, or else pre-construct your argument hash, which would look similar to the above, but require you to assign an intermediate variable for no semantic benefit.

assemble_from_options

If you need to do something more complicated than what's provided by assemble_from, you can specify per-argument options using assemble_from_options. Like assemble_from, it's also aliased as assemble_with_options.

Default values can be specified using the :default option, and work the same as using hash-syntax with assemble_from.

If you would like to do some type of value coercion you can specify either a symbol or a callable using the :coerce option. Symbols will be passed as messages to the input object, and anything that responds to #call will be called with the input object as an argument.

If you need to accept aliased key names you can use the :aliases option to specify a list of keys. Aliases only apply to input processing; instance variables aren't set and accessors aren't be provided.

class IMAPConnection
  extend Assembler

  # Here we want to assign an IP address so we only do DNS lookup once.
  assemble_from_options :hostname, coerce: ->(h) { Resolv.getaddress(h) }

  # Defaults must be specified explicitly; arguments with no default are required.
  assemble_from_options :use_ssl, default: false

  # We'll accept values named 'port' or 'host_port' (but we'll only assign '@port').
  # Symbols can also be passed for coercions.
  assemble_from_options :port, default: nil, coerce: :to_i, aliases: [:host_port]
end

instance = IMAPConnection.new(hostname: 'localhost') do |b|
  puts b.hostname   # => '127.0.0.0' - i.e. the accessor returns the coerced value.
  puts b.use_ssl    # => false - i.e. the accessor returns the default value if none is specified.
  b.port = '100'    # Will be coerced to the integer 100.
end

instance.host_port  # => MethodMissing error - accessors aren't defined for aliases.

Before and After Hooks

In some cases, you might need to take care of some extra things during object initialization. One simple case would be if you're inheriting from another class and need to call super to make sure it initializes correctly. Enter before_assembly and after_assembly.

They both take a block and that block gets evaluated in the scope of your instance before or after the rest of Assembler's initializer runs. This means instance variables and private methods are available to you and that self is the object being created. Nothing is yielded to the blocks.

If you don't need to pass arguments or always pass the same arguments, you could do something like this:

class Professor < Employee
  extend Assembler

  before_assembly do
    super('teaching')
  end
end

If, however, you need to react to or interact with options that are passed in, you can do something like this:

class Professor < Employee
  extend Assembler

  attr_reader :title

  assemble_with :department_name, :degree_subject
  after_assembly do
    @title = "PhD of #{degree_subject}, #{department_name}"
  end
end

If you call these methods more than once, each block will be run in the order declared. The most common case would be a child class adding more before or after functionality to that declared by a parent.

class Employee
  extend Assembler

  assemble_with :manager_id, department_name: nil

  attr_reader :manager

  after_assembly do
    @manager = Manager.find(manager_id)
  end
end

class Professor < Employee
  assemble_with :department_chair

  attr_reader :people_answerable_to

  after_assembly do
    @people_answerable_to = [manager, department_name]
  end
end

Contributing

If you'd like to contribute, please see the contribution guidelines.

Releasing

Maintainers: Please make sure to follow the release steps when it's time to cut a new release.