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.
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
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.
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
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
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
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.