In Progress
Unit 1, Lesson 1
In Progress

Black Box

Do you ever wish that you were writing software in the perfect world of programming textbooks, where each software component is a black box which can easily be plugged into other boxes?

The real world is usually messier. But here’s a secret: every piece of software can be used like a black box. You just have to know where to look for the borders. In this episode, you’ll see an example of doing exactly that.

Video transcript & code

In the preceding episode we looked at some Sinatra application code that was written without tests. As a result, when we went to perform some administrative updates from the console we discovered it was very difficult to use a subset of the code outside of its original context.

But there's are two sides to every story. No matter how tangled its internals may be, every application has a border. Somewhere the application stops, and the rest of the world begins. If an app's innards are sufficiently coupled, it may be easier to add functionality from the outside rather than to work from within.

Let's look at an example. In this app, we have a Sinatra action that acts as a webhook. The webhook receives purchase information every time someone buys a book. In the last episode, the task that gave us so much grief was that of manually adding some purchases to the system in the absence of a webhook callback.

This application's internal APIs might be unexpectedly awkward to use. But there's another API we haven't paid much attention to until now. The Sinatra action that receives webhook callbacks is necessarily well-defined and constrained, because in order to work it must conform to the expectations of the storefront service.

Instead of trying work with internal APIs, let's write a Rake task that pretends to be the storefront, sending a purchase notification callback.

We need to be able to specify the buyer email when using this task, so it takes a Rake task argument called :payer_email. Task arguments arrive as the second block argument to a Rake task.

task "send_ipn", :payer_email do |_, args|

We extract this argument out of the argument hash, raising an exception if it is missing.

payer_email = args[:payer_email] or
  fail "payer_email must be supplied"

We require some needed libraries next. We've opted to use the Faraday library for making HTTP requests.

require "faraday"
require "logger"

Next set up a logger, and log what we're doing.

logger = Logger.new($stderr)
logger.level = Logger::INFO
logger.info "Send IPN for #{payer_email}"

We need some purchase info to POST to the webhook. While IPN data normally contains many different fields, our webhook only cares about one of them: the payer email.

data = {
  "payer_email" => payer_email
}

Now we set up a Faraday connection object. We pull an application BASE_URI out of the environment, which will let us easily switch between local development servers and the production server.

We manually configure the Faraday middleware stack, mainly so that we can insert a logging middleware. The more information we log, the easier it will be to diagnose any problems we run into.

conn = Faraday.new(ENV.fetch("BASE_URI")) do |c|
  c.request :multipart
  c.request :url_encoded
  c.response :logger, logger
  c.adapter Faraday.default_adapter
end

The webhook is protected by HTTP basic authentication, so we configure the connection to authenticate itself.

conn.basic_auth("ipn", ENV.fetch("IPN_PASSWORD"))

Finally, we post our data to the webhook URL, along with our manufactured purchase data.

  response = conn.post("/ipn", data)
  unless response.status == 202
    fail "IPN not accepted"
  end
end

Here's the finished product:

task "send_ipn", :payer_email do |_, args|
  payer_email = args[:payer_email] or
    fail "payer_email must be supplied"

  require "faraday"
  require "logger"

  logger = Logger.new($stderr)
  logger.level = Logger::INFO
  logger.info "Send IPN for #{payer_email}"
  data = {
    "payer_email" => payer_email
  }
  conn = Faraday.new(ENV.fetch("BASE_URI")) do |c|
    c.request :multipart
    c.request :url_encoded
    c.response :logger, logger
    c.adapter Faraday.default_adapter
  end
  conn.basic_auth("ipn", ENV.fetch("IPN_PASSWORD"))
  response = conn.post("/ipn", data)
  unless response.status == 202
    fail "IPN not accepted"
  end
end

We can invoke this task like this, putting the task argument in square brackets after the task name:

$ bundle exec rake send_ipn["tomservo@example.org"]
I, [2014-07-21T19:05:39.477930 #18761]  INFO -- : Send IPN for tomservo@example.org
I, [2014-07-21T19:05:39.520978 #18761]  INFO -- : post http://localhost:5678/ipn
I, [2014-07-21T19:05:46.133492 #18761]  INFO -- Status: 202

This is considerably less code than we had to use when we tried to invoke an internal method from the console. And considering we are almost certainly going to be refactoring those internal APIs, this has the advantage that it will keep working even if we make major changes to our application internals.

There's another benefit we receive from this "black box" approach. We are now most of the way to a solution for running periodic tests against our live application. We can throw this Rake task into a cron job and have it regularly register a fake purchase from a special test email account. If one day we start seeing errors in the logs as a result of this cron job, we know that we've broken something. Or, it may tell us that some external service our application depends on is not working. This latter information is especially valuable, since it's the kind of thing that even high-level acceptance tests usually can't give us visibility into.

In a perfect world, our software would consist of nicely encapsulated black boxes all the way down, each one having minimal dependencies and easily used in a novel context. But even the messiest code is a black box when viewed from a high enough vantage point. When an application has tightly coupled internals, or is in a state of major design flux, it may be easier to add new features from outside rather than from within.

Happy hacking!

Responses