June 12, 2011
#56 Cloud Foundry - Creating An API Client
Now that we've seen how Cloud Foundry's Cloud Controller generates authentication tokens, we'll look at how we can interact with with the Cloud Controller API. Cloud Foundry's VMC gem allows a user to interact with the Cloud Controller to do things such as logging in and pushing a new application into Cloud Foundry. We use the VMC gem as a reference to guide us in how to develop an an API wrapper/interface client.
Establishing The Gem
We'll develop a gem that will allow us to "login" into Cloud Foundry. Then we wil create some interfaces to a couple of the Cloud Controller API points. We create a new gem skeleton and add the necessary gem dependencies. Since the Cloud Controller exposes a JSON-based API, we will include the mutli_json and yajl-ruby gems. multi_json "is a general-purpose swappable JSON backend library." (The other new gems will be discussed below.)
# Terminal $ bundle gem vmc __________________________________________________ # vmc.gemspec ... Gem::Specification.new do |s| ... s.add_development_dependency 'rspec', '~> 2.6' s.add_development_dependency 'webmock', '~> 1.6' s.add_development_dependency 'guard-rspec', '~> 0.4' s.add_runtime_dependency 'multi_json', '~> 1.0' s.add_runtime_dependency 'yajl-ruby', '~> 0.8' s.add_runtime_dependency 'faraday_middleware', '~> 0.6' s.add_runtime_dependency 'rash', '~> 0.3' end
Webmock
As we develop our client, we will avoid direct interaction with the Cloud Controller. We will use Webmock to block any HTTP requests and instead provide mock responses for our tests. To use Webmock, we require webmock/rspec is our spec_helper file.
When defining a stub request, we have the option of returning a simulated response from the blocked HTTP request. We keep these responses in files within the spec/fixtures directory. The #fixture and #fixture_path methods help retrieve these responses. (These files are from Cloud Foundry's VMC spec/assets folder.)
(Recall from the last post that in order to obtain an authorization token from the Cloud Controller, we have to login by sending a POST request to "/users/*email/tokens" that contains our password in the body of the request. From the VCAP README: "The pre-defined domain *.vcap.me maps to local host, so when you use this setup, the end result is that your development environment is available at http://api.vcap.me.")
# spec_helper.rb require 'vmc' require 'rspec' require 'webmock/rspec' def fixture_path File.expand_path("../fixtures", __FILE__) end def fixture(file) File.new(fixture_path + '/' + file) end def stub_login(status) fixture_file = status == :success ? "login_success.txt" : "login_fail.txt" stub_request(:post, "http://api.vcap.me/users/foo@example.com/tokens"). with(:body => {:password => 'foo'}). to_return(fixture(fixture_file)) end __________________________________________________ # login_success.txt HTTP/1.1 200 OK Server: nginx/0.7.65 Date: Thu, 03 Mar 2011 18:26:45 GMT Content-Type: application/json Connection: keep-alive Keep-Alive: timeout=20 Content-Length: 28 {"token":"valid_auth_token"} __________________________________________________ # login_fail.txt HTTP/1.1 403 Forbidden Server: nginx/0.7.65 Date: Thu, 03 Mar 2011 18:29:40 GMT Content-Type: application/json Connection: keep-alive Keep-Alive: timeout=20 Content-Length: 52 {"code":200,"description":"Operation not permitted"}
The Login Spec
Now that our stub requests for logging in are stup, we work on the #login method. Our specifications for this method are: 1) It takes the user's email and password as arguments. 2) It returns an auth token. 3) It sets the user and auth_token attributes of the client instance if logging in was successful. 4) If not successful, an exception is raised.
# client_spec.rb require "spec_helper" describe VMC::Client do describe "#login" do def login @client = subject @auth_token = @client.login('foo@example.com', 'foo') end context "when successful" do before do stub_login(:success) login end it "sets the user" do @client.user.should == 'foo@example.com' end it "sets and returns the auth_token" do @client.auth_token.should == @auth_token @auth_token.should == "valid_auth_token" end end it "raises an exception when failed" do stub_login(:failed) expect { login }.to raise_error VMC::Client::TargetError end end end
Faraday
Cloud Foundry's VMC gem uses Rest Client to make its HTTP calls. For our client, we will use Faraday. Faraday provides a "modular HTTP client library using middleware heavily inspired by Rack.". We use a Connection object to send an HTTP request and also receive the response. In building these connection objects, we can include modules that can modify the requests as they go out and responses as they come back. Faraday supports many HTTP adapters including Typhoeus and EM-Synchrony (fiber wrapped Event Machine HTTP requests.) For our purposes, we will be using the NET::HTTP adapter.
The first thing we need do is build a connection object. When using the block syntax, we can include middleware to modify requests or responses as needed. Faraday::Request::JSON will take an outgoing request and encode the body to JSON using Yajl. The other two middlewares come from the faraday_middleware gem. ParseJson will decode the incoming JSON response body into a hash. After that, Rashify converts the hash into a Hashie::Rash object. Rash "subclasses Hashie::Mash to convert all [camel case] keys in the hash to underscore."
Once we have a connection object, all we have to do to make a POST request is call #post on the connection with the path of interest and any parameters that are needed. A Faraday::Response object is returned, and we can get the body of the response.
# client.rb require 'faraday_middleware' module VMC class Client class TargetError < RuntimeError; end attr_accessor :auth_token attr_reader :user def login(username, password) connection = Faraday.new(:url => 'http://api.vcap.me') do |builder| builder.use Faraday::Request::JSON builder.use Faraday::Response::Rashify builder.use Faraday::Response::ParseJson builder.adapter :net_http end response = connection.post("/users/#{username}/tokens", :password => password).body raise TargetError if response.code == 200 @user = username @auth_token = response.token end end __________________________________________________ # vmc.rb require "vmc/version" require 'vmc/client' module VMC end
Refactoring
Our gem structure will differ from Cloud Foundry's VMC gem and will mimic the structure of the Twitter and Octokit gems which are other API wrappers. These gems also use Faraday for their HTTP requests. This structure will organize our Client class into component modules.
We keep some the Cloud Foundry VMC constants in our base VMC module. We make our Client configurable, so that we can change the target url and HTTP adapter. In our Request module, we change to passing a block to the connection action (i.e. :post) that will allow us to set some request information before the connection is made. In addition, we create a #post helper method. The #login method is placed into the Authentication module. Finally, we move custom error classes into the errors.rb file.
# vmc.rb ... module VMC DEFAULT_LOCAL_TARGET = 'http://api.vcap.me' USERS_PATH = '/users' end __________________________________________________ # client.rb require 'vmc/client/authentication' require 'vmc/client/connection' require 'vmc/client/errors' require 'vmc/client/request' module VMC class Client attr_accessor :http_adapter, :target_url def initialize @target_url = VMC::DEFAULT_LOCAL_TARGET @http_adapter = :net_http end include Authentication include Connection include Request end __________________________________________________ # connection.rb require 'faraday_middleware' module VMC class Client module Connection private def connection connection = Faraday.new(:url => target_url) do |builder| builder.use Faraday::Request::JSON builder.use Faraday::Response::Rashify builder.use Faraday::Response::ParseJson builder.adapter http_adapter end end __________________________________________________ # request.rb module VMC class Client module Request def post(path, options={}) request(:post, path, options) end private def request(action, path, options) response = connection.send(action, path) do |request| request.body = options[:body] if options[:body] end response.body end __________________________________________________ #authentication.rb module VMC class Client module Authentication attr_accessor :auth_token attr_reader :user def login(username, password) response = post("#{VMC::USERS_PATH}/#{username}/tokens", :body => { :password => password }) raise TargetError if response.code == 200 @user = username @auth_token = response.token end __________________________________________________ # errors.rb module VMC class Client class TargetError < RuntimeError; end
The Info Spec
If we look at the Cloud Controller's routes files, we see additional API points that we can access. A request to "/info" will provide some general information about Cloud Foundry. If the incoming request provides a valid authorization token within the headers, the Cloud Controller will also return some user specific data. Thus this API point provides a means to see if the user's auth_token is valid.
# info_spec.rb describe VMC::Client::Info do describe ".info" do it "obtains user and general info when a valid auth token is present" do stub_request(:get, "http://api.vcap.me/info"). with(:headers => {'AUTHORIZATION' => 'token'}). to_return(fixture('info_authenticated.txt')) client = VMC::Client.new('token') info = client.info info.support.should == "ac-support@vmware.com" info.user.should == 'foo@example.com' end it "obtains just the general info when an auth token is not present" do stub_request(:get, "http://api.vcap.me/info"). to_return(fixture('info_returned.txt')) client = VMC::Client.new info = client.info info.support.should == "ac-support@vmware.com" info.user.should be_nil end end __________________________________________________ # info_authenticated.txt HTTP/1.1 200 OK Server: nginx/0.7.65 Date: Thu, 03 Mar 2011 19:25:34 GMT Content-Type: application/json Connection: keep-alive Keep-Alive: timeout=20 Content-Length: 380 { "name": "vcap", "build": "3465a13ab528443f1afcd3c9c2861a078549b8e5", "support": "ac-support@vmware.com", "version": 0.999, "limits": { "apps": 50, "memory": 8192, "app_uris": 4, "services": 4 }, "user": "foo@example.com", "description": "VMware's Cloud Application Platform", "usage": { "apps": 1, "memory": 128, "services": 0 } } __________________________________________________ # info_returned.txt HTTP/1.1 200 OK Server: nginx/0.7.65 Date: Thu, 03 Mar 2011 19:04:04 GMT Content-Type: application/json Connection: keep-alive Keep-Alive: timeout=20 Content-Length: 189 { "name": "vcap", "build": "3465a13ab528443f1afcd3c9c2861a078549b8e5", "support": "ac-support@vmware.com", "version": 0.999, "description": "VMware's Cloud Application Platform" }
The Info Module
Our Client class can now be initialized with an auth_token. The #info method with just make a GET request to the /info path using the new #get helper method. Since other methods may be passing in the auth_token in the request headers, we deal with that in the #request method.
# client.rb require 'vmc/client/info' ... module VMC class Client ... def initialize(auth_token=nil) @auth_token = auth_token ... end include Info ... __________________________________________________ # info.rb module VMC class Client module Info def info get(VMC::INFO_PATH) end __________________________________________________ # vmc.rb module VMC ... INFO_PATH = '/info' end __________________________________________________ # request.rb module VMC class Client module Request def get(path, options={}) request(:get, path, options) end private def request(action, path, options) headers = auth_token ? { 'AUTHORIZATION' => auth_token } : {} headers.merge!(options[:headers]) if options[:headers] response = connection.send(action, path) do |request| request.body = options[:body] if options[:body] request.headers = headers unless headers.empty? end response.body end
Th App Spec
When a user attempts to create a new application (via the method VMC::Cli::Command::Apps#push), one of the first steps is to check that the app name that we are using doesn't already exists (using a method called #app_exists?.) This steps relies on the Client#app_info method.
We will reproduce the functionality of this method. There are three situations to consider. The first is when we already have a valid auth_token. In this case, we make a request to /info which lets us know if the token is valid. If it is, then we send the token in a request to /apps/[app_name] to receive information about that app. In the second situation, we don't have an existing token, so we login first. The third situation is when we aren't authenticated.
# app_spec.rb describe VMC::Client::App do describe "#app_info" do it "obtains the given app's info when a valid auth_token is present" do stub_request(:get, "http://api.vcap.me/info"). to_return(fixture('info_authenticated.txt')) stub_request(:get, "http://api.vcap.me/apps/my_app"). with(:headers => {'AUTHORIZATION' => 'token'}). to_return(fixture('app_info.txt')) client = VMC::Client.new('token', 'my_app') client.app_info.resources.memory.should == 64 end it "obtains the given app's info when logged in" do stub_login(:success) stub_request(:get, "http://api.vcap.me/apps/my_app"). with(:headers => {'AUTHORIZATION' => 'valid_auth_token'}). to_return(fixture('app_info.txt')) client = VMC::Client.new(nil, 'my_app') client.login('foo@example.com', 'foo') client.app_info.resources.memory.should == 64 end it "raises an AuthError if not logged in" do stub_request(:get, "http://api.vcap.me/apps/my_app") stub_request(:get, "http://api.vcap.me/info"). to_return(fixture('info_returned.txt')) client = VMC::Client.new(nil, 'my_app') expect { client.app_info }.to raise_error VMC::Client::AuthError end end __________________________________________________ # app_info.txt HTTP/1.1 200 OK Server: nginx/0.7.65 Date: Fri, 04 Mar 2011 02:56:21 GMT Content-Type: application/json Connection: keep-alive Keep-Alive: timeout=20 Content-Length: 243 {"resources":{"memory":64},"uris":["foo.vcap.me"],"staging":{"stack":"ruby foo.rb","model":"http://b20nine.com/unknown"},"state":"STARTED","instances":1,"name":"foo","meta":{"version":1,"created":1299207348},"services":[],"runningInstances":1}
The App Module
For our Client, we will be able to instantiate a new Client object with an app name. We will then use this to send a GET request to the Cloud Controllers app path. Our #request method will handle the authorization. We indicate when this is necessary by specifying the :require_auth => true option.
When authorization is needed, we check the login status by ensuring that either we are currently logged in or that our auth_token is valid. We put the #auth_token_valid? and #logged_in? methods in our Authentication module.
There are many more API points that our Client gem needs to be able to interface, but for now this is a good start.
# client.rb require 'vmc/client/app' ... module VMC class Client attr_accessor :app_name, ... def initialize(auth_token=nil, app_name=nil) @app_name = app_name ... end include App ... __________________________________________________ # app.rb module VMC class Client module App def app_info get("#{VMC::APPS_PATH}/#{app_name}", :require_auth => true) end __________________________________________________ # vmc.rb module VMC APPS_PATH = '/apps' __________________________________________________ # request.rb module VMC class Client module Request ... def request(action, path, options) check_login_status if options[:require_auth] ... end def check_login_status raise AuthError unless logged_in? || auth_token_valid? end __________________________________________________ # errors.rb module VMC class Client class AuthError < RuntimeError; end __________________________________________________ # authentication.rb module VMC class Client module Authentication ... def auth_token_valid? descr = info if descr return false unless descr[:user] return false unless descr[:usage] @user = descr[:user] true end end def logged_in? user end
(Please login to submit comments.)
Comments