In Progress
Unit 1, Lesson 1
In Progress

Replace Parameter with Option

Video transcript & code

Method parameters are a mixed blessing. The more of a method's inputs arrive in the form of parameters, the easier it may be to reason about, because all of its collaborators are laid out explicitly. But long argument lists are also unwieldy. It's hard to remember all of the needed arguments without looking them up. As we saw in episode 229, methods with long parameter lists feel painful to use from the console.

Let's revisit the method we saw in that episode. It's a method intended to encapsulate all of the domain logic that has to happen when an application is pinged with new sale information. As a result of bundling up so much functionality into one isolated procedure, it needs a lot of extra information to be passed in in order to do its job.

Perkolator.process_ipn(
      params,
      redemption_url_template: redemption_url_template,
      email_options:           settings.email_options
      logger:                  logger)

We'd like to get rid of most or all of these parameters. We decide to start at the end of the list, with the logger parameter. We'll transition this parameter into being a configurable option instead.

First we check that our tests are currently passing. With that reassurance, we then flip over to the definition of process_ipn. It's a method defined on the Perkolator module. Note that this module uses module_function, which we introduced in episode #49, to convert all instance methods into module methods.

module Perkolator
  module_function
  # ...
  def process_ipn(params, redemption_url_template:, email_options:)
    email = params.fetch("payer_email") { "<MISSING_EMAIL>" }
    logger.info "Storing IPN for #{email}"
    DB[:ipns].insert(email_address: email, data: Sequel.pg_json(params))

    token = SecureRandom.hex
    logger.info "Storing token #{token} for #{email}"
    DB[:tokens].insert(email_address: email, value: token)

    email_template = Tilt.new("templates/welcome_email.txt.erb")
    contact_email  = "contact@rakefieldmanual.com"
    redemption_url = redemption_url_template.expand("token" => token)
    body           = email_template.render(Object.new,
                                           login_url:     redemption_url,
                                           contact_email: contact_email)
    logger.info "Sending welcome email to #{email}"
    logger.info "Redemption URL for #{email} is #{redemption_url}"
    subject = "Welcome to The Rake Field Manual"
    Pony.mail(to: email,
              from: contact_email,
              subject: subject,
              body: body,
              via: :smtp,
              via_options: email_options)
  end
  # ...
end

Our first task is to give the Perkolator module a configuration facility. To do that, we add two methods. One, configuration, returns a lazily-initialized OpenStruct. We discussed OpenStruct in episode #25. In order to use an OpenStruct, we also need to require the ostruct library.

The second method is called configure. It simply yields the configuration object.

require "ostruct"

module Perkolator
  # ...
  def configuration
    @configuration ||= OpenStruct.new
  end

  def configure
    yield configuration
  end
  # ..
end

Now we switch back to the app file and add a before block to our Sinatra application. This block will be executed before every request handler. We write Perkolator.configure, and then supply a block which receives a configuration object. Within the block we set the logger option to equal the Sinatra logger.

before do
  Perkolator.configure do |config|
    config.logger = logger
  end
end

This configuration block pattern is one that you've probably seen in other Ruby libraries. It's a powerful pattern for a number of reasons, and we'll probably explore it in more detail in a future episode.

The next thing we do is run our tests again, to make sure we haven't broken anything.

At this point we are configuring the Perkolator module with a logger before every request, but the module isn't actually doing anything with it.

To change that, we require the forwardable module and use it to define a logger method which will just delegate straight to the configuration. We use the SingleForwardable version because we are delegating module methods, not instance methods. We talked more about Forwardable way back in episode #6.

require "forwardable"

module Perkolator
  # ...
  extend SingleForwardable
  def_single_delegator :configuration, :logger
  # ...
end

Now we update the parameter list for process_ipn. We remove the named logger parameter. In its place, we leave the double star. This tells Ruby to accept but ignore any extra named parameters. We do this so that callers of the method that still supply the logger argument won't be broken.

module Perkolator
  module_function
  # ...
  def process_ipn(params, redemption_url_template:, **)
    # ...
  end
  # ...
end

Everywhere in this method that used the old logger parameter is now automatically referring to the new logger delegator method instead. This is another great example of the power of "barewords" in Ruby, which we talked about in episode #4: we can easily alter the scope of barewords without making any changes to all the lines of code that reference them.

Now we run our tests again, and see that they are still passing.

This means that we've successfully reached a point where we have switched from using the parameterized logger, to using the configured logger. All that remains is to look for method callers that still pass the now-unused argument.

Fortunately for us, there is only one call site, the one we've already seen. We go ahead and remove the now-deprecated argument.

Perkolator.process_ipn(
      params,
      redemption_url_template: redemption_url_template,
      email_options:           settings.email_options)

At this point we could stop. But we decide to remove the double star from the process_ipn signature. Leaving it in place would have made it too easy for us to accidentally pass incorrect keyword arguments to the method, since they would have been silently ignored.

module Perkolator
  module_function
  # ...
  def process_ipn(params, redemption_url_template:, email_options:)
    # ...
  end
  # ...
end

One more run of tests, and we can call this refactoring done.

…except for one little thing. We set out to make this method less painful to invoke. We've changed things so that at least logger configuration only needs to be done once instead of with every method call. But that still means we can't open up a console and start playing with this method without first configuring the module with a valid logger. Otherwise, we get a NoMethodError when the method tries to use a logger that isn't there.

Perkolator.process_ipn({}, redemption_url_template: nil, email_options: nil)
# =>
# ~> /home/avdi/dev/perkolator/lib/perkolator.rb:23:in `process_ipn': undefined method `info' for nil:NilClass (NoMethodError)
# ~>    from -:3:in `<main>'

Instead of forcing the user to remember to configure the module first, let's add a default logger that just logs to $stderr.

# ...
require "logger"

module Perkolator
  # ...
  def configuration
    @configuration ||= OpenStruct.new(logger: Logger.new($stderr))
  end
  # ...
end

With this sensible default in place, we can start playing with the method without any special setup.

That's one parameter down, and two to go. But I think this has been enough to go over in one day. Happy hacking!

Responses