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. This gem will play a crucial role in software internationalization by facilitating seamless integration and management of translation files between your Rails app and Lokalise TMS.
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:
- Ruby 2.5 or above. Windows users may take advantage of RubyInstaller.
- RubyGems and Bundler. Install by running
gem update --system
andgem install bundler
. - Git version control system (not strictly required).
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.
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 thebundle 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 theVERSION
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
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:
- Code of conduct
- Contributing guide
- Pull request template
- Issue templates (stored in a separate folder)
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 theRails.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 tofalse
. 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, thelocales
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!