Skip to content

Instantly share code, notes, and snippets.

@brandur
Last active December 20, 2015 22:09
Show Gist options
  • Save brandur/6202747 to your computer and use it in GitHub Desktop.
Save brandur/6202747 to your computer and use it in GitHub Desktop.
Engineering API-driven Applications Using Service Stubs

Engineering API-driven Applications Using Service Stubs

Developing applications against foreign services like the Heroku Platform API can unlock a powerful set of otherwise unavailable features, but can come with drawbacks: development must occur online, valid credentials are required, calls your code makes will reveal and manipulate real data, and API calls in tests have to stubbed out individually.

This article will offer a short overview on how these pain points can be reduced with a service stub like the one available for the Heroku API. First we'll introduce a simple API consuming app and demonstrate how it can be run against the production API, then we'll add an API stub to its Procfile so that it's easily runnable in development, and finally we'll re-use the same stub in its test suite.

A Simple API Consumer

We'll demonstrate this concept with a very simple API consuming application that allows a user to login, view a list of their apps, and click on on an app to get some of its extended details (think of it as a trivial version of Dashboard). Follow along by pulling the code from its Git repository.

git clone https://github.com/brandur/heroku-api-stub-example.git

The app is Sinatra-based, and at its heart are two simple routes that make API calls, do some very nominal processing, and use some basic templates to render the results. These are both located in app.rb:

get "/apps" do
  authorized!
  response = api_request { api.get(path: "/apps", expects: 200) }
  @apps = MultiJson.decode(response.body).sort_by { |a| a["name"] }
  slim :index
end

get "/apps/:id" do |id|
  authorized!
  response = api_request { api.get(path: "/apps/#{id}", expects: 200) }
  @app = MultiJson.decode(response.body)
  slim :show
end

Some Basic Authentication Plumbing

To get a valid access token for the API, the app will use Heroku OAuth through the Heroku OmniAuth strategy. This makes it necessary to add some very basic OmniAuth plumbing to the app: detecting an unauthenticated user, intercepting the OmniAuth callback, storing an access token to session, and detecting when the token has expired (by handling a 401 response from an API call).

# activate cookie middleware and the Heroku OmniAuth strategy
use Rack::Session::Cookie, secret: ENV["COOKIE_SECRET"]
use OmniAuth::Builder do
  provider :heroku, ENV["HEROKU_OAUTH_ID"], ENV["HEROKU_OAUTH_SECRET"],
    { scope: "read" }
end

...

# a helper to detect an unauthenticated user
def authorized!
  if session[:access_token]
    @access_token = session[:access_token]
  elsif production?
    # initiate the OmniAuth authentication process
    redirect("/auth/heroku")
  else
    @access_token = "my-fake-access-token"
  end
end

...

# handling the OmniAuth heroku
get "/auth/:provider/callback" do
  session[:access_token] =
    request.env["omniauth.auth"]["credentials"]["token"]
  redirect to("/apps")
end

Note above that the authorized! helper has a special route taken in development and testing. It bypasses OAuth authentication to make the app a little easier to run outside of production.

Running Against Production

Getting the app up and running against production is simple enough— install Foreman, and in .env set HEROKU_API_URL=https://api.heroku.com (which points to the stub), set RACK_ENV=production, then start it up with foreman start. After directing your browser to localhost:5000, you'll be prompted to allow the application access to your account, and be issued an access token that will allow it to list your apps.

gem install foreman
vi .env # edit HEROKU_API_URL=https://api.heroku.com and RACK_ENV=production
open http://localhost:5000/apps

The Service Stub

Opening the app's Procfile, you'll notice that as well as booting Puma to handle web requests, we also boot a copy of heroku_api_stub, an executable provided by the heroku_api_stub gem.

web:  bundle exec puma --quiet --threads 8:32 --port $PORT config.ru
stub: bundle exec heroku-api-stub --port $PORT

The service stub is a small Sinatra app itself which is built dynamically by parsing the contents of a JSON file that programmatically describes every endpoint in the Heroku API, and what data and statues that endpoints will return.

The stub has some very basic requirements, like that requests be made with version 3 of the API, that some sort of authentication is passed (it will take any credentials though, even if invalid), and that JSON is used as the transport format when posting data to it. By running Foreman, you can see it in action for yourself by issuing a simple Curl command:

foreman start
curl -i -H "Accept: application/vnd.heroku+json; version=3" --user :anything http://localhost:5100/apps/anything

By adding HEROKU_API_URL=http://localhost:5100 to .env, we tell our app above to use the stub instead of the production API. After this addition, boot the app again with foreman start, and check out localhost:5000 once again. You'll see a single app listed, and just like when running against production, you can click on it to see details.

vi .env # edit HEROKU_API_URL=http://localhost:5100 and RACK_ENV=development
open http://localhost:5000/apps

Testing Against the Service Stub

Remote services are especially inconvenient in testing environments. Activating a gem like webmock will allow HTTP calls to be stubbed out, but at the same time will force a developer to manually stub out responses to every API call made by their app. A service stub can help once again by providing default responses for all valid paths called in application code.

Pull up test/app_test.rb to see a few very basic test cases:

def test_apps_list
  get "/apps"
  assert_equal 200, last_response.status
  assert_match /example/, last_response.body
end

def test_apps_show
  # the stub will respond for any app
  get "/apps/anything"
  assert_equal 200, last_response.status
  assert_match /example/, last_response.body
end

The setup method activates the service stub with HerokuAPIStub.initialize, which enables webmock and starts intercepting requests to ENV["HEROKU_API_URL"] and redirecting them to a running stub.

Along with these basic sanity checks, we may also want to test that our app can handle various error conditions that could occur while running in production. When un unhandled error occurs while making an API call, we halt and return a 503 to our clients, indicating that service is temporarily unavailable. This is handled in [app.rb] by a general error handler:

error Excon::Errors::Error do
  halt(503)
end

The service stub allows its behavior to be modified by passing in a block containing code written in Sinatra's DSL that should be mixed into the stub's default routes. In the following test case we return a 400 status code while getting app information, then check that the app handled it as expected with a 503:

def test_bad_request_error
  HerokuAPIStub.initialize do
    get("/apps/anything") { 400 }
  end

  get "/apps/anything"
  assert_equal 503, last_response.status
end

As mentioned above, our app detects a 401 response from the API and interprets it as a hint that the user's access token has expired (the most likely case, but it could also mean that the user has changed their password, or that the session's credentials are invalid). After detecting this condition, it re-authenticates with OmniAuth as shown here:

def api_request
  yield
rescue Excon::Errors::Unauthorized
  session[:access_token] = nil
  # access token probably expired; re-authenticate
  redirect("/auth/heroku")
end

The corresponding test checks for this behavior by detecting the 302 to /auth/heroku:

def test_unauthorized_error
  HerokuAPIStub.initialize do
    get("/apps/anything") { 401 }
  end

  get "/apps/anything"
  assert_equal 302, last_response.status
  assert_match %r{/auth/heroku$}, last_response.headers["Location"]
end

Summary

We've shown a few basic techniques on how an app whose operation heavily on foreign APIs can be architected by leveraging service stubs to enable it to be easily run outside of production, and simultaneously allow reasonable test coverage.

Although this example may be trivial, the idea can be expanded to any number of foreign services. The Heroku API, which is responsible for orchestrating against a number of internal services, will launch over a dozen service stubs to ensure that users can properly interact with it during the development process.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment