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_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.
# 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.)
# 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.
# 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.
# 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.
# 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.
# 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.
# 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 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
(Please login to submit comments.)
Comments