Ruby is known for having a great ecosystem of code sharing within the community. Rubygems and Bundler have done an amazing job in simplifying this process and dependency management, even influencing modern package managers for other languages.
Every developer that is new to Ruby also knows how to require
a file and/or an installed Gem. However, one of the most under estimated features of a Ruby Gem is the capacity of running as a standalone command-line executable.
Today we are going to build a simple weather CLI using plain Ruby with the help of Bundler. The full project is also available on GitHub.
First of all let’s create a new Gem project boilerplate using Bundler.
bundle gem weather --test=minitest
For more details on how to build and publish a gem to an internal gemserver checkout my presentation here.
Customise the gemspec
We need to make initial modifications to weather.gemspec
file. First, editing any fields marked with a TODO.
spec.summary = "simple weather CLI"
spec.description = "Simple weather CLI using OpenWeatherMap API"
spec.homepage = "http://idonthaveanybutyoumayhaveone.io"
Add the following line to define which file will be executed when we will invoke weather
command in the terminal. Note that the command will take the name of the “default executable”, not the Gem name.
spec.default_executable = "weather"
Then we will need to create the file exe/weather
. You may need to first create the “exe” directory. We will fill the content of this file later.
Set up the weather API account
For this tutorial we are going to use OpenWeatherMap API as they provide nice features with the free account. We need to register here to get an app ID.
I’ve obtained an app ID and set it as environment variable WEATHER_APP_ID
so that we don’t commit it to the source control system. Hour gem is going to read it from the global ENV
variable.
Time to implement!
Using Test-driven development we design our weather client. We start by designing a Weather::Gateway
that we will use to encapsulate the details of the API provider, so that if we decide to user another provider, our changes are constrained within this class.
require 'test_helper'
describe Weather do
it 'has a version number' do
refute_nil Weather::VERSION
end
end
describe Weather::Gateway do
before do
app_id = ENV['WEATHER_APP_ID']
abort "set WEATHER_APP_ID env variable" unless app_id
@gateway = Weather::Gateway.new(app_id)
end
it 'gets forecast for New York' do
samples = @gateway.forecast('New York')
assert samples.any?
samples.each do |sample|
assert_respond_to sample, :date
assert_respond_to sample, :description
assert_respond_to sample, :humidity
assert_respond_to sample, :temp_min
assert_respond_to sample, :temp_max
end
end
end
Here is a basic implementation that satisfies the test. We do it in a file called lib/weather.rb
require 'net/http'
require 'json'
require 'weather/version'
module Weather
Sample = Struct.new(:date, :temp_min, :temp_max, :humidity, :description)
class Gateway
ROOT_URL = 'http://api.openweathermap.org/data/2.5'
def initialize(app_id)
@app_id = app_id
end
def forecast(query)
uri = forecast_uri_for(query)
response = Net::HTTP.get_response(uri)
if response.code == '200'
sample_list_from_response(response)
else
raise response.body
end
end
private
def sample_list_from_response(response)
data = JSON.parse(response.body, symbolize_names: true)
data[:list].map do |sample|
sample_from_json(sample)
end
end
def sample_from_json(json)
Sample.new(
Time.at(json[:dt]).strftime('%Y-%m-%d %H:%M'),
json[:main][:temp_min],
json[:main][:temp_max],
json[:main][:humidity],
json[:weather].first[:description]
)
end
def forecast_uri_for(query)
params = {
units: 'metric',
appid: @app_id,
q: query
}
uri = URI(ROOT_URL + '/forecast')
uri.query = URI.encode_www_form(params)
uri
end
end
end
Pulling it all together
Time to edit exe/weather
to make use of the Weather::Gateway
class and print out the results to the terminal.
#!/usr/bin/env ruby
require 'weather'
app_id = ENV['WEATHER_APP_ID']
city = ARGV[0]
abort "WEATHER_APP_ID environment variable is not set" unless app_id
abort "Provide a city as argument" unless city
gateway = Weather::Gateway.new(app_id)
samples = gateway.forecast(city)
puts "Weather forecast for #{city}"
puts "DATE\tTEMP(min)\tTEMP(max)\tDESCRIPTION"
samples.each do |sample|
puts "#{sample.date}\t#{sample.temp_min}\t#{sample.temp_max}\t#{sample.description}"
end
The first line (shebang) is crucial for the executable to work. It tells Rubygem how to run this executable and lets us not to worry about cross-platform compatibility.
In fact, while on a Unix environment it’s sufficient to have the shebang and executable permission on the file to run, when installing the gem on Windows, Rubygem will automatically create a weather.bat
file that runs the exe/wather.rb
file using the currently active Ruby installation. Great, isn’t it?
Now we can finally build and install the Gem to see it working!
gem build weather.gemspec
gem install weather-0.1.0.gem
weather Rome
# Weather forecast for Rome
# DATE TEMP(min) TEMP(max) DESCRIPTION
# 2017-10-17 22:00 18.42 20.25 clear sky
# 2017-10-18 01:00 12.68 14.06 clear sky
# 2017-10-18 04:00 8.89 9.81 clear sky
# 2017-10-18 07:00 6.92 7.38 clear sky
# 2017-10-18 10:00 5.63 5.63 clear sky
# 2017-10-18 13:00 4.55 4.55 clear sky
# 2017-10-18 16:00 14.24 14.24 clear sky
# 2017-10-18 19:00 18.48 18.48 clear sky
# 2017-10-18 22:00 18.82 18.82 clear sky
# 2017-10-19 01:00 12.24 12.24 clear sky
# 2017-10-19 04:00 7.87 7.87 clear sky
# 2017-10-19 07:00 5.94 5.94 clear sky
# 2017-10-19 10:00 4.3 4.3 clear sky
# 2017-10-19 13:00 3.31 3.31 clear sky
# 2017-10-19 16:00 14.65 14.65 clear sky
# 2017-10-19 19:00 19.29 19.29 clear sky
# 2017-10-19 22:00 19.85 19.85 clear sky
# 2017-10-20 01:00 12.4 12.4 clear sky
# 2017-10-20 04:00 7.56 7.56 clear sky
# 2017-10-20 07:00 5.42 5.42 clear sky
# 2017-10-20 10:00 4.16 4.16 clear sky
# 2017-10-20 13:00 3.29 3.29 clear sky
# 2017-10-20 16:00 15.68 15.68 clear sky
# 2017-10-20 19:00 21.14 21.14 clear sky
# 2017-10-20 22:00 21.53 21.53 clear sky
# 2017-10-21 01:00 13.54 13.54 clear sky
# 2017-10-21 04:00 9.2 9.2 few clouds
# 2017-10-21 07:00 7.66 7.66 few clouds
# 2017-10-21 10:00 7.06 7.06 broken clouds
# 2017-10-21 13:00 7.31 7.31 overcast clouds
# 2017-10-21 16:00 15.92 15.92 broken clouds
# 2017-10-21 19:00 20.25 20.25 broken clouds
# 2017-10-21 22:00 21.02 21.02 clear sky
# 2017-10-22 01:00 15.7 15.7 scattered clouds
# 2017-10-22 04:00 13.66 13.66 few clouds
# 2017-10-22 07:00 13.4 13.4 scattered clouds
# 2017-10-22 10:00 14.6 14.6 scattered clouds
# 2017-10-22 13:00 15.25 15.25 overcast clouds
# 2017-10-22 16:00 18.75 18.75 broken clouds
# 2017-10-22 19:00 22.79 22.79 broken clouds
Conclusion
In the past I’ve used libraries like Ocra to compile command-line tools from Ruby into executable Windows binaries. However things started to get complex after a while as we had to support a combination of Windows and Unix 32 and 64 bits executables. Unnecessary complexity started to appear considering that our team’s primary focus was not to build command line tools.
Ocra is a good solution if the users of your command line tool are not expected to have an installation of Ruby on their environment or if asking the users to install it as dependency is unacceptable. For the rest, building an executable gem is a great, cheap and more maintainable solution.