How to create a Ruby Gem: The basics

How to create a Ruby Gem: The basics explained with examples

In this series of tutorials, 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 example.

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

    Talk to one of our localization specialists

    Book a call with one of our localization specialists and get a tailored consultation that can guide you on your localization path.

    Get a demo

    Related posts

    Learn something new every two weeks

    Get the latest in localization delivered straight to your inbox.

    Related articles
    Localization made easy. Why wait?
    The preferred localization tool of 3000+ companies