Back

Building a Ruby CLI with Thor

Captain's log, stardate d650.y38/AB

Development Backend Ruby Thor CLI
-
Full-stack developer
Building a Ruby CLI with Thor

We have written a Ruby CLI using Thor for a client project and we share everything we've learnt in the process in this blog post!

Long story short, I have had to develop a new geocoding feature for one of our SaaS clients. For the system to work, it was necessary to have real geolocation data, so some time ago, I had written some scripts in Node.js to import data from staging into development. But since this is a Ruby project, I thought it'd be more coherent if the scripts were written in Ruby, so I decided to rewrite them.

Spoiler: The experience has been very smooth and I have to confess that the resulting code was much nicer and clean that the Node.js predecessor.

Since I wanted to write a CLI I decided to give Thor a try. I have been overall very pleased with how easy to use this library is.

Let's get into detail!

First off: What is Thor?

Good question: Thor is a toolkit for building powerful command-line interfaces. It is used in Bundler, Vagrant, Rails and others. In fact, their website is that very question: what is Thor?

1. Setup

To organize things, I've decided to put all the code inside lib/api-client, so I created a new file at bin/api with the following code:

#!/usr/bin/env ruby

require_relative '../lib/api-client/cli'

And, of course, the mandatory chmod +x bin/api to make it executable.

2. First command: Authorization

The first thing I want my CLI to do is to generate the Authorization token to be used with tools like Postman or Insomniac:

# lib/api-client/cli.rb

require 'dotenv/load'
require 'thor'
require 'digest'
require 'active_controller'

module ApiClient
  # The ApiClient Command Line Interface (CLI entry point, see `bin/api`)
  class CLI < Thor

    desc 'auth [BUSINESS_ACCESS_TOKEN]', 'create the `Authorization` header for the API'
    def auth(business_access_token = nil)
      business_access_token ||= ENV['BUSINESS_ACCESS_TOKEN']
      if business_access_token.blank?
        STDOUT.puts 'BUSINESS_ACCESS_TOKEN is required'
        STDOUT.puts 'You can write it in .env file to make it default'
      else
        STDOUT.puts "BUSINESS_ACCESS_TOKEN=#{business_access_token}"
        nonce = Time.now.to_i
        token = Digest::SHA256.hexdigest("#{business_access_token}#{nonce}")
        auth = ActionController::HttpAuthentication::Token.encode_credentials(token, nonce: nonce)
        STDOUT.puts auth
      end
    end
  end
end

ApiClient::CLI.start(ARGV)

As you can see, the code is very straightforward. Thor makes things very easy, by mapping CLI arguments into function parameters. Optional parameters are also supported, of course.

The only thing that is a little bit different from normal is that I don't want to write the BUSINESS_ACCESS_TOKEN every time, so I used dotenv library to load from .env file if is not explicitly written.

3. Subcommands and REST resources

Our client's API is REST and, for my purpose, I just needed to use one resource. But thinking about the future, I found it convenient to write the CLI with the idea of growing to other resources.

Enter Thor subcommands.

With Thor, it is very easy to support subcommands à la git-style. Basically, you write a CLI and make it a "children" of another parent CLI. The subcommand is exactly the same as a regular Thor "commands" class:

# lib/api-client/locations_cli.rb

require 'thor'

module ApiClient
  class LocationsCLI < Thor
    class_option :env, type: :string, aliases: :e, default: 'development',
                        required: true, banner: 'development',
                        desc: 'The environment to use: development or staging'
    class_option :token, type: :string, aliases: :t, banner: 'BUSINESS_ACCESS_TOKEN',
                          desc: 'The business access token'

    desc 'create BUSINESS_ID FILE', 'create locations from FILE (json format)'
    option :limit, type: :numeric, aliases: :l, banner: 'number',
                    desc: 'The maximum number of locations to create'
    def create(business_id, json_file)
      ...
    end

    desc 'update BUSINESS_ID LOCATION_ID JSON', 'update a location from JSON'
    def update(business_id, location_id, json)
      ...
    end
  end
end

Some things to notice here. Let's stop for a minute.

First, Thor makes it easy to add shared options to all commands by using the "class_option" method. On the other hand, the "option" method is used to specify command options. As you can read, because you add "help" support to the commands, all code becomes quite self-explanatory.

In this snippet, I have removed the actual code, and more importantly, the long description displayed by Thor to explain each command. Take a look at "Long Description" on the Thor website.

As I said, LocationsCLI is a normal Thor "commands" class, which could be used as a standalone CLI in itself, but you can "mount" this inside another one to become a subcommand:

# lib/api-client/cli.rb

module ApiClient
  class CLI < Thor

    desc 'auth [BUSINESS_ACCESS_TOKEN]', 'create the `Authorization` header for the API'
    def auth(business_access_token = nil)
      ...
    end

    desc 'locations SUBCOMMAND ...ARGS', 'manage locations via API'
    subcommand 'locations', ApiClient::LocationsCLI
  end
end

ApiClient::CLI.start(ARGV)

4. Some abstractions: Http

When I started to write the actual code for the ApiClient::LocationsCLI, the first thing to became evident is that I needed the "create auth token" I have written about before.

The second thing is that I will have to do some kind of repetitive task like sending a POST request with parameters, so I decided to create a class to encapsulate all Http-related stuff:

# frozen_string_literal: true

require 'net/http'
require 'uri'
require 'json'
require 'digest'
require 'action_controller'

module ApiClient
  # A minimal layer over net/http to simplify sending requests to Client API
  class Http

    attr_reader :url, :token

    API_URLS = {
      staging: 'http://projectname-staging.client.com/api/v1',
      development: 'http://localhost:3000/api/v1'
    }.freeze

    METHODS = { post: Net::HTTP::Post, put: Net::HTTP::Put }.freeze

    def initialize(environment, token = nil)
      @url = API_URLS[environment.to_sym]
      @token = token

      throw "Environment not valid: #{environment}" if @url.blank?
    end

    def authorization
      return '' if @token.blank?

      nonce = Time.now.to_i
      token = Digest::SHA256.hexdigest("#{@token}#{nonce}")
      ActionController::HttpAuthentication::Token.encode_credentials(token, nonce: nonce)
    end

    def send_request(method, url, body = nil)
      uri = URI.parse(url)
      http = Net::HTTP.new(uri.host, uri.port)
      request = METHODS[method].new(uri.request_uri, header)
      request.body = body.to_json if body.present?
      http.request(request)
    end

    def header
      {
        'Content-Type' => 'application/json',
        'Authorization' => authorization
      }
    end

  end
end

Basically, this class allows me to send a request to a url, with an optional body, and with the correct headers. I changed the auth command of the CLI to use this class.

5. Separate logic from the human interface

Last, but not least, I wanted to separate the CLI logic from the logic of interacting with the API, so I created another class using the previous ApiClient::Http class to perform the tasks I need. The class has the following shape:

# lib/api-client/api.rb

require_relative './http'

module ApiClient
  class Api

    attr_reader :http

    def initialize(environment, token = nil)
      @http = Http.new(environment, token)
    end

    def create_locations(business_id, locations)
      ...
    end

    def create_location(business_id, location)
      ...
    end

    def update_location(business_id, location_id, location)
      ...
    end

  end
end

Again, no surprises here. Now, the ApiClient::LocationsCLI class' only purpose is to bridge Thor and the API methods.

Here's a complete LocationsCLI command example with full description:

module ApiClient
  class LocationsCLI

    ...

    desc 'update BUSINESS_ID LOCATION_ID JSON', 'update a location from JSON'
    long_desc <<-LONGDESC
    `.bin/api locations update` will use the client API to update a location.

    The BUSINESS_ID and LOCATION_ID are required

    The JSON parameter is a JSON string with the params to update (remember to enclose
    the JSON between ')

    > $ ./bin/api locations update 1e55f77d cc7a921e '{ "name": "The New Name" }' -t xt4Xg
    LONGDESC
    def update(business_id, location_id, json)
      api = ApiClient::Api.new(options[:env], options[:token])
      api.update_location(business_id, location_id, JSON.parse(json))
    end
  end
end

As you can read, Thor can be quite verbose, so it reinforces the idea to keep business and CLI logic separated.

6. Summary

In the end, I have created a full-featured CLI very easily:

Custom CLI with Thor

Writing a good CLI is not easy - I know from first-hand experience! - so I was extremely happy with Thor's structure. Subcommands are a great way to keep CLI organized and I have enjoyed this process a lot.

If you have some ungrouped scripts or the need to create something like a CLI, I wholeheartedly recommend you to give Thor a try. It was also created to empower the Rails CLI, by the way! 😉

Share this post

Related Articles

Code

How we upgraded an obsolete Ruby on Rails application with lots of legacy code (part 1)

This post is the first one of a series where we break down how to correctly upgrade a Ruby on Rails application.

Read full article
Car rear

So, do you guys do only Ruby for the backend?

This is a question we are asked all too frequently from outside the company. However, we recently asked this very question ourselves. Yes, we only do Ruby and that isn't going to change anytime soon.

Read full article
Coding

Use GoLang code in Ruby

GoLang has the option to create shared libraries in C, and in this post I will show you how to do it.

Read full article