How to create a Ruby Gem: The basics

In this series of articles, we will discuss all aspects of how to create a Ruby gem (gem is just a fancy word for “library” or “plugin”). In this section we will make the initial preparations, create the project structure, define the gemspec, and proceed to writing the actual gem.

All in all, this series will cover the following topics:

  • Creating the gem structure.
  • Adding a gemspec.
  • Integrating Rubocop.
  • Allowing specification of gem options.
  • Setting up a testing suite using RSpec.
  • Generating a dummy Rails application for testing.
  • Creating and testing installation tasks.
  • Creating and testing rake tasks.
  • Working and testing the third-party API.
  • Managing ZIP files.
  • Setting up TravisCI and Codecov services.
  • And more!

By the end of the series, you’ll be able to create your own Ruby gem.

We will tackle the above concepts using a “learn by example approach”. I’ll show you how to create a new gem for the Rails app. This gem will allow the exchange of translation files between the Rails app and Lokalise TMS. Basically, it will provide two main rake tasks: import and export. Running the corresponding task will either download translations from Lokalise to Rails, or upload translations from your app to Lokalise. These tasks will have additional configuration options so that the user can have full control over this process. While this functionality is not overly complex, it will allow us to discuss many specifics regarding the gem creation process.

The final result can be found at github.com/bodrovis/lokalise_rails.

Second part of the series: lokalise.com/blog/how-to-create-a-ruby-gem-testing-suite.

Third part of the series: lokalise.com/blog/how-to-create-a-ruby-gem-publishing.

Prerequisites

In this tutorial, I will assume that you have a basic knowledge of Ruby language and Rails framework. The code samples won’t be too complex, so being a Ruby expert is not required in any case.

You will also need the following software:

Creating a project skeleton

While we could utilize a helper library like Juwelier to generate a boilerplate project structure, I’ll be adding every file manually from scratch. This will allow us to discuss every aspect of the Ruby gem creation in greater detail.

Start by creating a new project directory: I’ll call mine lokalise_rails. Now let’s proceed to adding specific files to it.

Gemspec

The most important file to create is the gemspec as it will contain specification for your library. Typically, this file provides the following info:

  • Gem name, version, and description.
  • Authors of the gem.
  • Required Ruby version.
  • List of the project files.
  • List of dependencies.

Rubygems.org provides a nice summary of all fields supported in gemspec.

Within your project directory create a new file named GEM_NAME.gemspec where GEM_NAME is the name of your brand-new library. In my case, the filename is: lokalise_rails/lokalise_rails.gemspec.

Defining main specifications

Start by requiring a file with the gem version (we are going to add it later) and by providing a specification block:

require File.expand_path('lib/lokalise_rails/version', __dir__)

Gem::Specification.new do |spec|
end

Now use the spec local variable to define the gem’s specifications. Here’s an example:

require File.expand_path('lib/lokalise_rails/version', __dir__)

Gem::Specification.new do |spec|
  spec.name                  = 'lokalise_rails'
  spec.version               = LokaliseRails::VERSION
  spec.authors               = ['Ilya Bodrov']
  spec.email                 = ['email@example.com']
  spec.summary               = 'Lokalise integration for Ruby on Rails'
  spec.description           = 'This gem allows to exchange translation files between your Rails app and Lokalise TMS.'
  spec.homepage              = 'https://github.com/bodrovis/lokalise_rails'
  spec.license               = 'MIT'
  spec.platform              = Gem::Platform::RUBY
  spec.required_ruby_version = '>= 2.5.0'
end
Name

name is, well, the name of your gem. Make sure that the chosen name is not already in use (which means you should not call your gem lokalise_rails as this name is already taken by me). To check whether a gem with any given name exists, use the search box at the rubygems.org website. A good name should briefly explain the purpose of the gem, for example: jquery-rails (adds jQuery to the Rails app), database_cleaner (cleans the test database), and angular_rails_csrf (makes Rails CSRF play nicely with Angular). Never use names like Array or String for your plugin! Some gems have fancier names like puma or koala. In certain cases, that’s fine (after all, it’s better than yet_another_webserver), but in general I’d suggest sticking to something more basic. This is especially important if you are creating a niche solution.

Version

version provides the version of your gem. Utilizing semantic versioning, like so, is recommended: MAJOR.MINOR.PATCH (for example, 2.1.3). MAJOR should be incremented only when you are introducing breaking changes that are not backwards compatible. For instance, if you rename or remove a method, that’s a breaking change. Increment MINOR when you add new features in a backwards compatible manner. For example, adding a new method without modifying the existing ones is backwards compatible. Finally, increment PATCH when you make backwards compatible bug fixes. For example, when you are updating a method so that it returns a proper value under certain conditions. We are going to store the gem version under the VERSION constant defined in a separate file.

Authors

authors lists one or more authors of the gem. All authors are going to be displayed on the gem’s page at rubygems.org.

E-mail

email provides one or more contact e-mails. If you have linked Gravatar to the specified email, the corresponding avatar will be displayed on the gem’s page.

Summary

summary is a very short description of the gem’s purpose. This summary is shown when you are running  the gem list -d command on your PC.

Description

description is a detailed explanation of the gem’s purpose: it usually contains a couple of paragraphs. Note that the description can’t have any formatting and should not contain any usage examples. The description is shown on the RubyGems website.

Homepage

homepage provides the URL of the gem’s home page. Usually it points to GitHub repo but that’s not always the case.

License

license provides the name of the gem license. The most common license type for open source projects is MIT which means that anyone can do basically anything with the source code. However, the original authors must always be credited in this case. It also means that the authors provide no warranty for the project and do not take any responsibility for the potential harm caused by using the software.

The simplest way to provide a license is to specify its ID that can be found at spdx.org. Also, you may utilize the license chooser service. While you can omit this field, I would not recommend doing so: knowing the license type is very important for developers that are going to use your gem in their corporate projects.

Platform

The platform field is optional, but I usually provide it for the sake of completeness. In most cases its value is just Gem::Platform::RUBY.

Required Ruby version

required_ruby_version usually provides the minimal Ruby version required to run this gem. As lokalise_rails employs some newer language features, I’ve set the minimal version to 2.5. This is quite alright, because Ruby 2.4 is not supported by the core team anymore.

Listing project files

The next step is to list all the files that your gem includes. Use the files attribute:

require File.expand_path('lib/lokalise_rails/version', __dir__)

Gem::Specification.new do |spec|
  # ...other specs...
  spec.files = Dir['README.md', 'LICENSE',
                 'CHANGELOG.md', 'lib/**/*.rb',
                 'lib/**/*.rake',
                 'lokalise_rails.gemspec', '.github/*.md',
                 'Gemfile', 'Rakefile']
end

Note that the files attribute is mandatory and must contain files only (not directories). If any file is missing from the list, it won’t be available during the gem usage! As our gem will contain both Ruby files and Rake tasks, I’ve added both file types.

Let’s also provide extra RDoc files:

require File.expand_path('lib/lokalise_rails/version', __dir__)

Gem::Specification.new do |spec|
  # ...
  spec.extra_rdoc_files = ['README.md']
end

These files will be used by the RDoc documentation generator.

Listing dependencies

Last but not least, comes providing gem dependencies: that is, the libraries required to properly run it. There are two types of dependencies:

  • Runtime dependencies — libraries mandatory to actually use the gem. Bundler installs these dependencies automatically when your gem is present in the Gemfile and the bundle install command is called. Some gems may have no runtime dependencies at all.
  • Development dependencies — libraries that are required only when working with the gem source code and running the test suite. In other words, Bundler won’t install these dependencies when your gem is included in, say, a Rails app.

In this part of the article, we’ll add all the runtime dependencies and a couple of development ones. We’ll talk about development dependencies in greater detail when writing tests for the gem.

So, to import and export translation files we’ll require the following runtime dependencies:

  • ruby-lokalise-api — official Lokalise API client which I created a couple of years ago.
  • rubyzip — library to manipulate ZIP files. We’ll employ it when extracting translation bundles.

As for the development dependencies, I would like to add the following:

  • rubocop — a great gem to check your code style and fix formatting issues.
  • rubocop-performance — Rubocop extension to search for performance-related issues.
  • rubocop-rspec — another Rubocop extension that checks RSpec test files.

Go ahead and add your dependencies, as follows:

require File.expand_path('lib/lokalise_rails/version', __dir__)

Gem::Specification.new do |spec|
  # ...

  spec.add_dependency 'ruby-lokalise-api', '~> 3.1'
  spec.add_dependency 'rubyzip', '~> 2.3'

  spec.add_development_dependency 'rubocop', '~> 0.60'
  spec.add_development_dependency 'rubocop-performance', '~> 1.5'
  spec.add_development_dependency 'rubocop-rspec', '~> 1.37'
end

You may also utilize the add_runtime_dependency method which does the same thing as the add_dependency. I really recommend providing the dependency versions using the ~> operator. For example, ~> 3.1 means that your gem works only with dependency version 3.x but not with 4.x, or 5.x. Remember that the first number is the MAJOR version which increments only if the library has breaking changes.

Great job, the gemspec file is now ready!

Gemfile

Each project should have a Gemfile, so let’s create one:

# lokalise_rails/Gemfile

source 'http://rubygems.org'

gemspec

We are instructing Bundler to download all dependencies from the rubygems.org website. The actual dependencies list can be found in the gemspec, so there’s nothing else to add to this file.

Library folder

Next, let’s create the lib folder which is going to host all the plugin files. Here’s a sample directory structure:

  • lib
    • lokalise_rails.rb — name this file after your gem. For now, this file is empty but later we’ll add library-specific config options there.
    • lokalise_rails — name this directory after the gem. This folder will contain the “meat” of our plugin.
      • version.rb — this file will define the VERSION constant. Later you may use this constant in the code.

Add the following content to lib/lokalise_rails/version.rb:

module LokaliseRails
  VERSION = '1.0.0'
end

Name the module after your gem. If the name contains multiple words separated with dashes or underscores, each separate word must start with a capital letter (camel case).

At this point, you may open your terminal, cd, in the project directory and run:

bundle i

This command will install all the dependencies and create a Gemfile.lock file. This file makes sure that your code is run with specific dependency versions. Make sure to periodically check for outdated dependencies by running:

bundle out

This command will check all your dependencies and tell you whether newer versions are available. Update dependency versions in the gemspec and run:

bundle u

Make sure that your gem plays nicely with new dependencies before publishing it! We’ll talk about publishing your gem to rubygems.org in more detail later.

Readme

Providing a README file for your gem is very much recommended. Usually, a README sums up the gem’s purpose and explains how to use it. Providing detailed usage instructions is a really good idea because otherwise your fellow developers may have a hard time working with your gem. In many cases the README is written in Markdown format. Therefore, create a new file README.md in the project root, for instance:

# LokaliseRails

This gem provides [Lokalise](http://lokalise.com) integration for Ruby on Rails and allows to exchange translation files easily. It relies on [ruby-lokalise-api](https://lokalise.github.io/ruby-lokalise-api) to send APIv2 requests.

## Getting started

### Requirements

This gem requires Ruby 2.5+ and Rails 5.1+.

The full README for the lokalise_rails gem can be found at GitHub.

License

We have already provided a license type in the gemspec, but let’s also add the license text to a separate file, LICENSE:

MIT License

Copyright (c) 2020 Lokalise team, Ilya Bodrov

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Changelog

I can’t stress enough how important it is to provide a changelog for your project. A changelog (sometimes also called “History”) has to sum up all changes for every version of the gem. If a version has introduced breaking changes, make sure to highlight them and explain how to migrate. Create a new CHANGELOG.md file inside the project root, like so:

# Changelog

## 1.0.0 (01-Oct-20)

* Initial release

Once again: don’t forget to list changes within this file after publishing a new version!

Rubocop config

As the next step, let’s add a Rubocop config file. In it, you can specify the target Ruby version and code formatting rules. Also, you may add specific files to an ignore list or disable certain checks. Create a new .rubocop.yml file in the project root as follows:

require:
 - rubocop-performance
 - rubocop-rspec

AllCops:
  TargetRubyVersion: 2.5
  NewCops: enable

The full Rubocop config for the lokalise_rails gem can be found on GitHub. Also, make sure to check the official Rubocop documentation which lists all the available checks (called “cops”) and their options.

You may add other Rubocop extensions as needed.

Rakefile

Rakefile contains Rake tasks available for your gem. For now, we’ll define only Rubocop-related tasks:

require 'rake'
require 'rubocop/rake_task'

RuboCop::RakeTask.new do |task|
  task.requires << 'rubocop-performance'
  task.requires << 'rubocop-rspec'
end

To run Rubocop, use the following command:

rubocop

Or run it with the rake task:

rake rubocop

If you’d like to automatically fix minor formatting issues, provide the -a option. There’s also an -A flag that enables “aggressive” mode, which fixes all found issues, except for those that cannot be fixed automatically. If Rubocop can’t resolve an issue, it will at least provide a hint and you can deal with it manually.

It is not mandatory to follow all the guidelines, therefore you may disable certain cops, for example:

Style/Documentation:
  Enabled: false

GitHub-related files

Inside the .github/ directory you may provide additional files that are specific to the GitHub platform. These are:

You can find the corresponding examples by clicking the links above.

Git files

Okay, so we are nearly done with the initial skeleton of our project. The last thing to do is provide a .gitignore file and initialize a new Git repo. .gitignore should live inside the root of your project. It lists all files and folders that should not be tracked by Git. Here’s a typical .gitignore file:

*.gem
coverage/*
Gemfile.lock
*~
.bundle
.rvmrc
log/*
measurement/*
pkg/*
.DS_Store
.env
spec/dummy/tmp/*
spec/dummy/log/*.log

When you are ready, initialize a new Git repo and perform the first commit:

git init
git add .
git commit -am "Initial commit"

Next, create a new repo using your favorite code hosting website (I really love GitHub, but you may stick to GitLab or Bitbucket if you wish) and push the code there. Nice work!

Defining gem options

So, we have seen how to create a basic Ruby gem file structure: it took quite a while, but now we know the purpose of each file. Before wrapping up this part, let’s proceed to fleshing out the gem. Specifically, we are going to define the options that the gem will accept:

  • api_token — required option that will contain the Lokalise API token. This token will then be used to send the API requests.
  • project_id — required option containing the Lokalise project ID to export and import files to/from.
  • locales_path — full path to the directory with the Rails translation files. Should default to /config/locales under the Rails.root.
  • file_ext_regexp — regular expression to employ when filtering out translation files. This regexp will be applied to file extensions. By default, it should select only YAML translation files.
  • import_opts — translation file import options. These options should have sensible defaults.
  • import_safe_mode — boolean option which defaults to false. When enabled, the import rake task will check whether the target directory to which translations are downloaded is empty.
  • export_opts — translation file export options. These options should have sensible defaults.
  • skip_file_export — lambda or procedure containing additional exclusion criteria for the exported translation files. After all, the locales directory may contain dozens of translation files and the developer needs a way to pick only the required ones.

Adding option accessors

Okay, so where do we define these options? Typically, such general configurations should be placed in the lib/lokalise_rails.rb file (this file will have a different name in your case). Therefore, let’s start by defining a new module within it:

module LokaliseRails
end

You may also create a class instead of a module: it depends on whether or not you’d like it to be instantiated. The module must be named after your gem and all gem-specific code must be namespaced under this module. Never ever create gem-specific classes or constants outside of this namespace because this may lead to name clashes.

Mandatory config

Now, the questions is: how do I want to manage the gem options? These options will be accessed by a rake task, so something like LokaliseRails.api_token or LokaliseRails.project_id should do the trick. This means that we require module attributes. Define them using the class << self trick:

module LokaliseRails
  class << self
    attr_accessor :api_token, :project_id
  end
end

So, this is just a good old attr_acessor which allows us to read and write the api_token and project_id. These options are mandatory and do not have any defaults.

Optional config

Other options should have default values, which means we have to use attr_writer for them:

module LokaliseRails
  class << self
    attr_accessor :api_token, :project_id
    attr_writer :import_opts, :import_safe_mode, :export_opts, :locales_path,
                :file_ext_regexp, :skip_file_export
  end
end

We will define attribute readers ourselves. Why? Because we need to check whether the user of this gem has provided a custom value for each option. If the custom value is set, we simply use it. If it is not set, we provide a default value instead:

module LokaliseRails
  class << self
    attr_accessor :api_token, :project_id
    attr_writer :import_opts, :import_safe_mode, :export_opts, :locales_path,
                :file_ext_regexp, :skip_file_export

    def locales_path
      @locales_path || "#{Rails.root}/config/locales"
    end
  end
end

So, if the @locales_path has a value (in other words, if it is not nil), we return that value. If @locales_path is nil, we provide the default path to the translation files directory.

Provide some other reader methods, for instance:

module LokaliseRails
  class << self
    # ...

    def file_ext_regexp
     @file_ext_regexp || /\.ya?ml\z/i
   end

   def import_opts
     @import_opts || {
       format: 'yaml',
       placeholder_format: :icu,
       yaml_include_root: true,
       original_filenames: true,
       directory_prefix: '',
       indentation: '2sp'
     }
   end

   def export_opts
     @export_opts || {}
   end
  end
end

By default, translation files should have .yml or .yaml extensions. Import options also have sensible defaults (a full list of available options can be found in the Lokalise API docs). Export options are empty by default (we’ll provide the required export options elsewhere).

Finally, add two more readers:

module LokaliseRails
  class << self
    # ...

    def import_safe_mode
      @import_safe_mode.nil? ? false : @import_safe_mode
    end

    def skip_file_export
      @skip_file_export || ->(_) { false }
    end
  end
end

The @import_safe_mode option may be set to false, therefore instead of saying simply @import_safe_mode ? ... we must check if it’s nil? or not. skip_file_export by default returns a lambda which yields false, meaning that there are no additional exclusion criteria and all translation files with the proper extensions have to be exported.

Config method

While the options can now be provided by saying LokaliseRails.api_token = '123', that’s not very convenient. Instead I would like to use the following construct:

LokaliseRails.config do |c|
  c.api_token = '123'
  c.project_id = '345.abc'
end

Actually, this is very straightforward to achieve. Simply add the following class method config to the lib/lokalise_rails.rb file:

module LokaliseRails
  class << self
    attr_accessor :api_token, :project_id
    attr_writer :import_opts, :import_safe_mode, :export_opts, :locales_path,
                :file_ext_regexp, :skip_file_export

    def config # <-------------
      yield self
    end

    # ... your readers
  end
end

This method will simply yield self (the actual LokaliseRails module) to the block, and the user can adjust all the options as needed!

Trying it out

We don’t have a testing environment set up yet, but still it’s a good idea to make sure everything is working fine. Create a new demo.rb file in the project root:

require_relative 'lib/lokalise_rails'

LokaliseRails.config do |c|
  c.api_token = '123'
  c.project_id = '345.abc'
  c.export_opts = {
    convert_placeholders: true
  }
  c.skip_file_export = ->(filename) { filename.include?('skip') }
end

That’s exactly how I would like our users to provide the config options for the gem. The same approach is also utilized by such popular solutions as Devise.

Now let’s also try to read these options:

# ... setting the options ...

puts LokaliseRails.api_token
puts LokaliseRails.project_id
puts LokaliseRails.export_opts
puts LokaliseRails.import_opts

puts '=' * 10

file_to_skip = 'skip_me.yml'
other_file = 'en.yml'
[file_to_skip, other_file].each do |f|
  puts "#{f} skip? #{LokaliseRails.skip_file_export.call(f)}"
end

Run the demo.rb file:

ruby demo.rb

And here’s the output:

123
345.abc
{:convert_placeholders=>true}
{:format=>"yaml", :placeholder_format=>:icu, :yaml_include_root=>true, :original_filenames=>true, :directory_prefix=>"", :indentation=>"2sp"}
==========
skip_me.yml skip? true
en.yml skip? false

As you can see, the options can be accessed just fine, which means we’ve completed the task successfully!

Conclusion

This was only the first part of the “Create a Ruby gem” series where we have prepared the groundwork for our project and created accessors for the gem options. In the upcoming article, we will see how to set up a solid testing suite and how to create an installation task. So, see you really soon!

Proceed to the second part

Related posts

Sign up to our newsletter

Get the latest articles on all things localization and translation management delivered straight to your inbox.

Read also
Localization made easy. Why wait?
The preferred localization tool of 1500+ leading global companies