June 19, 2011

#57 Cloud Foundry - Command Line Interfaces With Thor

Now that we have an API wrapper in the form of our VMC::Client class, we will create a command line interface to use that API wrapper. Back in Post #45, we saw how Rails uses Thor to allow us to write custom generators. For our VMC command line interface (CLI), we will use Thor to allow a user to log into Cloud Foundry.

(The actual Cloud Foundry VMC gem uses the Highline gem to help with its CLI.)

Testing Setup

To get our test suite setup, we look into Thor's own spec_helper file and add a couple of things to our spec_helper file. When a Thor class is invoked, it will use the filename that it's being executed from to figure out what the name of executing command was. We can set what Thor thinks this filename is by setting the $0 variable. The next thing is to clear out existing command line arguments (ARGV) so they don't interfere with the tests. (These steps don't appear to be necessary, but are presented just in case.)

Lastly, because Thor will be interacting with $stdout, $stdin and $stderr, we'll need to handle that in our tests. To capture the output that Thor produces in the terminal so that we can make assertions on that output, we include the #capture method.

spec/spec_helper.rb
# spec_helper.rb
...
$0 = "vmc"
ARGV.clear

def capture(stream)
  begin
    stream = stream.to_s
    eval "$#{stream} = StringIO.new"
    yield
    result = eval("$#{stream}").string
  ensure
    eval("$#{stream} = #{stream.upcase}")
  end

  result
end

Testing Thor

To start a Thor script within a test, we call .start on the Thor class of interest. In our case, this will be VMC::Cli. We pass in an array of arguments that correspond to the ARGV from the command line. The :login method in our first #let statement is equivalent to the terminal command vmc login.

Our first test makes sure that an email and password are asked for as inputs. To provide that input, we set a message expectation on the #gets method of $stdin and then provide the test inputs. We then make sure the output has the necessary messages.

In the next tests, we need to know that Thor will use either #print or #puts when it displays output. Knowing that, we can set message expectations on those methods as needed. Finally, if we need to invoke Thor with command line options, we can just pass those in to .start.

spec/vmc/cli/methods/user_spec.rb
# user_spec.rb
require 'spec_helper'

describe VMC::Cli do
  describe "#login" do
    context "when credentials are not provided" do
      let(:login) { VMC::Cli.start(["login"]) }

      it "asks for an email and password" do
        $stdin.should_receive(:gets).and_return('foo@bar.com', 'sekret')
        results = capture(:stdout) { login }
        results.should =~ /Please enter your email:/
        results.should =~ /Please enter your password:/
      end

      context "displays an error if" do
        it "email is blank" do
          $stdin.should_receive(:gets).and_return('')
          $stdout.should_not_receive(:print).with("Please enter your password: ")
          results = capture(:stderr) { login }
          results.should =~ /You must enter a value for that field./
        end

        it "password is blank" do
          $stdout.should_receive(:print).with("Please enter your email: ")
          $stdout.should_receive(:print).with("Please enter your password: ")
          $stdin.should_receive(:gets).and_return('foo@bar.com', '')
          results = capture(:stderr) { login }
          results.should =~ /You must enter a value for that field./
        end
      end
    end

    context "when credentials are provided" do
      let(:login) do
        VMC::Cli.start(["login", "--email", 'foo@bar.com', "--password", 'sekret'])
        # equivalent to `vmc login --email foo@bar.com --password sekret`
      end

      it "does not ask for email or password" do
       $stdin.should_not_receive(:gets)
       results = capture(:stdout) { login }
       results.should =~ /Attempting to login./
      end
    end

Basic Thor Methods

The .desc method provides descriptive information to the user when she types vmc help on the command line. .method_option declares what command line options can be passed. These descriptions will be shown with vmc help login. The options are accessed through the #options method.

In #login, we can obtain input using #ask. Terminal messages are displayed using #say. When we want to cleanly raise errors, we can raise a Thor::Error. This will be rescued and displayed in the $stderr. In refactoring our code, we create a #get_option method. Thor assumes that when you write a public method, that method is intended to be available as a command line task. Therefore, we need to make our #get_option method either protected or private.

(Note: In production, we would need to figure out how to hide the password as it's being entered.)

vmc/cli/methods/user.rb, vmc.rb, vmc/cli.rb
# user.rb
module VMC
  class Cli

    desc "login", "login to Cloud Foundry"
    method_option :email, :type => :string, :desc => 'Your email'
    method_option :password, :type => :string, :desc => 'Your password'

    def login
      email, password = options[:email], options[:password]
      email = ask("Please enter your email:") unless email
      raise Thor::Error, "You must enter a value for that field." if email.empty?
      password = ask("Please enter your password:") unless password
      raise Thor::Error, "You must enter a value for that field." if password.empty?
      say "Attempting to login."
    end
__________________________________________________

#user.rb [refactored]
    def login
      email = options[:email] || get_option(:email)
      password = options[:password] || get_option(:password)
      say "Attempting to login."
    end

    protected

    def get_option(option)
      value = ask("Please enter your #{option}:")
      raise Thor::Error, "You must enter a value for that field." if value.empty?
      value
    end
__________________________________________________

# vmc.rb
require 'vmc/cli'
...
__________________________________________________

# cli
require 'thor'

module VMC
  class Cli < Thor
  end
end

require 'vmc/cli/methods/user'

Logging In And Saving The Auth Token

Now that we have credentials, we can use our Client to login and obtain an auth token. We will save that token for future use using a yet to be written Config class. Our idea is that the Config class will write various configuration settings to a file using a method called #update. (For the Cloud Foundry VMC gem, a token is associated with a target URL. So there can be multiple tokens.)

Since we're interfacing the Client and Config classes, we create test doubles and set message expectations on them. There's no need to test the Client code again. The Config code will be tested separately.

spec/vmc/cli/methods/user_spec.rb, lib/vmc/cli/methods/user.rb
# user_spec.rb
    context "when credentials are provided" do
     let(:login) do
       VMC::Cli.start(["login", "--email", 'foo@bar.com', "--password", 'sekret'])
     end

     context "and login will succeed" do
       it "saves the token via Config and displays a success message" do
         client = double(VMC::Client)
         client.should_receive(:login).with('foo@bar.com', 'sekret') { 'token' }
         VMC::Client.stub(:new) { client }

         config = double(VMC::Cli::Config)
         config.should_receive(:update).with(:tokens, 'token')
         VMC::Cli::Config.stub(:new) { config }

         results = capture(:stdout) { login }
         results.should =~ /Login successful./
       end
     end
__________________________________________________

# user.rb
    def login
      email = options[:email] || get_option(:email)
      password = options[:password] || get_option(:password)
      say "Attempting to login.", :yellow  # adds color to the terminal output

      client = VMC::Client.new
      token = client.login(email, password)
      say "Login successful.", :green

      config = VMC::Cli::Config.new
      config.update(:tokens, token)
    end

Refactoring

Since we'll likely need to access the Client and Config objects for other methods, we'll create separate methods for those objects. In our spec, we created two test doubles. To keep things DRY, we'll create separate methods to create these doubles.

vmc/cli.rb, vmc/cli/config.rb, spec/spec_helper.rb, spec/vmc/cli/methods/user_spec.rb
# cli.rb
module VMC
  class Cli < Thor
    protected

    def client
      @client ||= VMC::Client.new
    end

    def config
      @config ||= VMC::Cli::Config.new
    end
  end
end

require 'vmc/cli/config'
__________________________________________________

# config.rb
module VMC
  class Cli
    class Config
    end
__________________________________________________

# user.rb
    def login
      email = options[:email] || get_option(:email)
      password = options[:password] || get_option(:password)
      say "Attempting to login.", :yellow

      token = client.login(email, password)
      say "Login successful.", :green
      config.update(:tokens, token)
    end
__________________________________________________

# spec_helper.rb
def mock_client(stubs={})
  client = double(VMC::Client, stubs)
  VMC::Client.stub(:new) { client }
  client
end

def mock_config(stubs={})
  config = double(VMC::Cli::Config, stubs)
  VMC::Cli::Config.stub(:new) { config }
  config
end
__________________________________________________

# user_spec.rb
...
  def stub_login_and_update_setting
    mock_client.should_receive(:login).
                with('foo@bar.com', 'sekret') { 'token' }
    mock_config.should_receive(:update).with(:tokens, 'token')
  end

  describe "#login" do
    context "when credentials are provided" do
       context "and login will succeed" do
         it "saves the token via Config and displays a success message" do
           stub_login_and_update_setting
           results = capture(:stdout) { login }
           results.should =~ /Login successful./
         end
       end

Handling Login Error

Recall that when our Client gets a login error, it will raise a VMC::Client::TargetError. Therefore, we need to account for this.

spec/vmc/client/methods/user_spec.rb, vmc/client/methods/user.rb
# user_spec.rb
describe VMC::Cli do
  describe "#login" do
    context "when credentials are provided" do
      context "and login will fail" do
        it "displays a failed message" do
          mock_client.should_receive(:login).
                      with('foo@bar.com', 'sekret') { raise VMC::Client::TargetError  }
          results = capture(:stdout) { login }
          results.should =~ /Login failed./
        end
      end
__________________________________________________

# user.rb
    def login
     ...
      token = client.login(email, password)
      say "Login successful."
      config.update(:tokens, token)
    rescue VMC::Client::TargetError
      say "Login failed.", :red
    end

Saving The Auth Token

We turn to the Config class and spec the #update method. Cloud Foundry's VMC gem stores token information in JSON format in a ~/.vmc_token file using a #store_token method. Other information is stored separately in different files. For our VMC gem, we will store all information in a ~/.vmc YAML file.

To fake the existence of a .vmc file, we can stub File.exists?. We write to that file with File.open. This method can accept a block which will be passed the file IO object as a parameter. To simulate this in RSpec, we use the #and_yield method. For the IO object, we can just use StringIO.

Since a token is associated with a target URL (i.e. http://api.vcap.me for local development and http://api.cloudfoundry.com for production), we need to handle the saving of these tokens separately from the saving of other settings such as the email address of the user. YAML.dump is used to marshall our Ruby objects.

spec/vmc/cli/config_spec.rb, vmc/cli/config.rb, lib/vmc.rb, vmc/cli.rb
# config_spec.rb
describe VMC::Cli::Config do
  let(:path) { File.expand_path('~/.vmc') }

  describe "#update" do
    context "when a .vmc file doesn't exist" do
      it "creates the file and can add a new token" do
        io = StringIO.new
        expected_tokens = { 'http://api.vcap.me' => 'new_token' }
        File.stub(:exists?).with(path) { false }
        File.stub(:open).with(path, 'w').and_yield(io)
        YAML.should_receive(:dump).with({"tokens" => expected_tokens }, io)

        config = VMC::Cli::Config.new
        config.update(:tokens, 'new_token')
        config.tokens.should == expected_tokens
      end

      it "creates the file and can add a email" do
        io = StringIO.new
        File.stub(:exists?).with(path) { false }
        File.stub(:open).with(path, 'w').and_yield(io)
        YAML.should_receive(:dump).with({"email" => "foo@bar.com" }, io)

        config = VMC::Cli::Config.new
        config.update(:email, 'foo@bar.com')
        config.email.should == 'foo@bar.com'
      end
    end

    context "when a .vmc file exist" do
      it "can update tokens in the .vmc config file" do
        old_token = { 'http://api.vcap.me' => 'old_token' }
        new_tokens = old_token.merge({ "foo/bar" => 'new_token'})
        io = StringIO.new

        File.stub(:exists?).with(path) { true }
        YAML.stub(:load_file).with(path) { { "tokens" => old_token } }
        File.stub(:open).with(path, 'w').and_yield(io)
        YAML.should_receive(:dump).with({ "tokens" => new_tokens }, io)

        config = VMC::Cli::Config.new(:target => 'foo/bar')
        config.update(:tokens, 'new_token')
        config.tokens.should == new_tokens
      end
__________________________________________________

# config.rb
require 'yaml'

module VMC
  class Cli
    class Config
      attr_accessor :config_hash, :settings_path, :target

      def initialize(options={})
        @settings_path = File.expand_path(VMC::DEFAULT_CONFIG_PATH)
        @target = options[:target] || VMC::DEFAULT_LOCAL_TARGET
        @config_hash = load_settings || {}
      end

      def email
        config_hash["email"]
      end

      def tokens
        config_hash["tokens"] || {}
      end

      def update(attr, value)
        if attr == :tokens
          config_hash["tokens"] = tokens.merge({ target => value })
        else
          config_hash[attr.to_s] = value
        end

        File.open(settings_path, 'w') do |out|
          YAML.dump(config_hash, out)
        end
      end

      private

      def load_settings
        File.exists?(settings_path) ? YAML.load_file(settings_path) : nil
      end
    end
__________________________________________________

# vmc.rb
module VMC
  DEFAULT_CONFIG_PATH = '~/.vmc'

Setting The Target URL

The last thing we need to be able to do in order to login is to set the target URL. When we do vmc target http://api.cloudfoundry.com, this will set our target for future tasks. Once set, we can see what the target is with vmc target. We define the #target task in an admin.rb file.

With the target saved, we make sure that our Config object uses that target when it is present. While here, we refactor our specs and create a #stub_config_file method. To bring it all together, we make sure that the Config's target is given to the Client when the Client is instantiated. We fix our user_spec.rb file accordingly.

spec/vmc/cli/admin_spec.rb, vmc/cli/admin.rb, vmc/cli.rb, spec/vmc/cli/config_spec.rb, vmc/cli/config.rb, spec/vmc/cli_spec.rb, spec/vmc/cli/methods/user_spec.rb
# admin_spec.rb
describe VMC::Cli do
  describe "#target" do
    let(:set_target) { VMC::Cli.start(["target", "foobar.com"]) }

    it "updates the config file with the target url" do
      mock_config.should_receive(:update).with(:target, "foobar.com")
      results = capture(:stdout) { set_target }
      results.should =~ /Target set to foobar.com./
    end

    it "display the current target if not given an argument" do
      mock_config.should_receive(:target) { "hooha.com" }
      results = capture(:stdout) { VMC::Cli.start(["target"]) }
      results.should =~ /Current target is hooha.com./
    end
__________________________________________________

# admin.rb
module VMC
  class Cli
    desc "target [url]", "Reports current target or sets a new target"

    def target(url=nil)
      return say("Current target is #{config.target}.", :green) unless url
      config.update(:target, url)
      say "Target set to #{url}.", :green
    end
__________________________________________________

# cli.rb
...
end

require 'vmc/cli/methods/admin'
__________________________________________________

# config_spec.rb
describe VMC::Cli::Config do
  let(:config) { @config ||= VMC::Cli::Config.new }
  let(:io) { @io ||= StringIO.new }
  let(:path) { File.expand_path('~/.vmc') }

  def stub_config_file(contents=nil)
    File.stub(:exists?).with(path) { contents ? true : false }
    YAML.stub(:load_file).with(path) { contents } if contents
    File.stub(:open).with(path, 'w').and_yield(io)
  end

  describe "#update" do
    context "when a .vmc file exist" do
      it "uses the target url in the config file" do
        stub_config_file("target" => "foobar.com")
        VMC::Cli::Config.new.target.should == 'foobar.com'
      end
__________________________________________________

# config.rb
module VMC
  class Cli
    class Config
      def initialize(options={})
       ...
        @target = options[:target] || config_hash["target"] || VMC::DEFAULT_LOCAL_TARGET
      end 
__________________________________________________

# cli_spec.rb
describe VMC::Cli do
  describe "#initialize" do
    it "passes the config target into the client" do
      VMC::Cli::Config.should_receive(:new) { double('Config', :target_url => 'target') }
      VMC::Client.should_receive(:new).with(:target => "target")
      VMC::Cli.new.send(:client)
    end
__________________________________________________

# cli.rb
module VMC
  class Cli < Thor
    protected

    def client
      @client ||= VMC::Client.new(:target_url => config.target)  # not shown: Client was refactored to take an options hash on initialization
    end

    def config
      @config ||= VMC::Cli::Config.new
    end
__________________________________________________

# user_spec.rb
describe VMC::Cli do
  describe "#login" do
    def stub_login_and_update_setting
      ...
      mock_config(:target => "target").should_receive(:update).with(:tokens, 'token')
    end

Testing The Gem

To test our gem, we remove the TODOs from the .gemspec file and specify the executable file. The executable file will just call .start on our Thor class. Then we intall the gem locally and give it a test run.

Of course, our VMC gem only touches on one of the many aspects of the Cloud Foundry VMC gem. By reproducing the user login functionality, we've seen how we can use Thor to create the link between the user and our Client API wrapper, so that we can communicate with Cloud Foundry.

vmc.gemspec, bin/vmc, Terminal
# vmc.gemspec
Gem::Specification.new do |s|
  ...
  s.summary     = %q{...}
  s.description = %q{...}
  s.executables   = 'vmc'
__________________________________________________

# vmc
#!/usr/bin/env ruby

require 'vmc'

VMC::Cli.start
__________________________________________________

# Terminal
$ rake install
$ vmc target https://api.cloudfoundry.com
  => Target set to https://api.cloudfoundry.com.
$ vmc login --email foo@example.com --password bar
  => Attempting to login.
  => Login successful.
$ cat ~/.vmc
--- 
target: https://api.cloudfoundry.com
tokens:
  https://api.cloudfoundry.com: 827934super98secret7485token45

    Comments

  1. Hi... this is such a cool resource. do you ever plan to revive it? romy
    By Rom Rains Apr 07, 2012 10:16

(Please login to submit comments.)



View All Posts