DIY – Create Your Own Rails Generator
Generators are an essential tool to improve your workflow. In a few simple steps, we'll walk you through the process of creating your very own generator.
Published on August 11, 2018
by
Filed under development
If you have ever used Ruby on Rails for app development, you’ve surely used some of the many generators that come out of the box, such as the controller generator, migration generator, model generator, etc.
Generators are an essential tool to improve your workflow and in this article I will walk you through the process of creating your very own generator.
We won’t create just any random generator but a Service Generator.
All of you are already using service objects, right? If not, you probably should... seriously!
Service objects are nothing but Plain Old Ruby Objects (PORO) that are used to execute actions.
1 (service) object == 1 responsibility
Service objects should be used to extract logic from controllers or models to keep them more readable and as slim as possible. They are great for keeping your code DRY and reusable. I won’t go into much details about them here, there are a lot of good articles on that topic.
Why wouldn’t we speed up our development and have a generator that does the boring job of creating the service skeleton, while we can focus on doing the fun stuff inside?
This is an example of how should a generated service look like:
class TestService
def initialize
end
def call
end
private
def method1
end
# .
# .
# .
def methodN
end
end
There are 2 ways to create custom generators: manually and with generators. Both will do the trick, but I will use a generator to generate a generator.
Yes, you read that correctly. Rails generators themselves have a generator. So to create a generator, we could type in the terminal:
$ bin/rails generate generator service
create lib/generators/service
create lib/generators/service/service_generator.rb
create lib/generators/service/USAGE
create lib/generators/service/templates
invoke test_unit
create test/lib/generators/service_generator_test.rb
For Rails to find generator files, without writing extra autoload paths, we should put them in the lib directory. Because Rails is all about convention over configuration.
The command above will create a basic generator file:
class ServiceGenerator < Rails::Generators::NamedBase
source_root File.expand_path('templates', __dir__)
end
The generator we are creating will inherit from Rails::Generators::NamedBase which basically means that our generator will expect at least the name argument to be sent. There are a lot more tricks and tips on creating generators so feel free to visit the related Rails guides for more info.
Before getting into the code, here is my thought process. I wanted to create a generator that accepts an optional argument (an array of method names) and an optional option (module name that will namespace the class). The service generator would then generate the correct service file with a basic template that includes the pre-populated empty methods, if given. As we should try to only have one public method, the additional methods will be private. To build the generator I used Thor, a powerful toolkit for building command-line interfaces used by all Rails generators.
Let's proceed.
$ bin/rails generate generator service
create lib/generators/service
create lib/generators/service/service_generator.rb
create lib/generators/service/USAGE
create lib/generators/service/templates
invoke test_unit
create test/lib/generators/service_generator_test.rb
We use the built-in generator to create our own generator. The service_generator.rb file is the main file where we put our logic.
class ServiceGenerator < Rails::Generators::NamedBase
source_root File.expand_path('../templates', __FILE__)
argument :methods, type: :array, default: [], banner: "method method"
class_option :module, type: :string
def create_service_file
@module_name = options[:module]
service_dir_path = "app/services"
generator_dir_path = service_dir_path + ("/#{@module_name.underscore}" if @module_name.present?).to_s
generator_path = generator_dir_path + "/#{file_name}.rb"
Dir.mkdir(service_dir_path) unless File.exist?(service_dir_path)
Dir.mkdir(generator_dir_path) unless File.exist?(generator_dir_path)
template "service.erb", generator_path
end
end
Let me walk you through the code.
source_root File.expand_path('../templates', __FILE__)
This method points to where our generator templates will be placed, and by default it points to the created directory lib/generators/service/templates.
argument :methods, type: :array, default: [], banner: "method method"
class_option :module, type: :string
Before we generate anything, we need to parse the command line arguments and options, if provided. That is done with the two methods provided above that come from Thor.
The method argument is used to parse command-line arguments into the methods variable and to create a attr_accessor for that variable while the class_option method is used to parse the command-line options and store them into the options variable.
The methods variable will be used in the template to generate empty methods while the options variable will be used to namespace the generator if the --module option is provided.
There is only one method inside our ServiceGenerator class called create_service_file where the logic is located.
@module_name = options[:module]
We store module name, if given, inside the instance variable.
The services directory should, according to the documentation, be placed inside the app directory, and that’s why the service directory path is stated as app/services, as this is our starting point.
service_dir_path = "app/services"
generator_dir_path = service_dir_path + ("/#{@module_name.underscore}" if @module_name.present?).to_s
generator_path = generator_dir_path + "/#{file_name}.rb"
We generate appropriate paths based on the module name, if present. The name of the service we want to generate is available for us to use in the class through the file_name variable that is accessible through the NamedBase class we inherit from. After all paths are generated, we can create directories if they don’t already exist. The template method is used to generate a file based on a template that we created in the path previously generated. More on this in the next step.
<% if @module_name.present? %> module <%= @module_name.camelize %> <% end %>
class <%= class_name %>
def initialize
end
def call
end
<% if methods.present? %> private <% end %>
<% for method in methods %>
def <%= method %>
end
<% end %>
end
<% if @module_name.present? %> end <% end %>
We could have used the create_file method for file creation in our service generator but I’ve used the template method because it is more flexible and it allows me to use the embedded Ruby file to dynamically change the file configuration based on user input. The file is a basic .erb file whose use case is to generate a class with initialize and call methods. If an additional option or argument is sent, then those changes are reflected in the file. Be sure to check the examples at the end.
Lastly, we want to add information to our USAGE file to make it easier for other users. Information added in the USAGE section will be visible when the --help or -h option is sent alongside the command. You can fill this file based on what type of generator you are creating. For this case, let's add the following information:
So if you type:
rails g service -h
the output will look like:
rails g service test_service
class TestService
def initialize
end
def call
end
end
rails g service test_service test_method1 test_method2
class TestService
def initialize
end
def call
end
private
def test_method1
end
def test_method2
end
end
rails g service test_service --module test_module
module TestModule
class TestService
def initialize
end
def call
end
end
end
rails g service test_service method1 method2 --module test_module
module TestModule
class TestService
def initialize
end
def call
end
private
def method1
end
def method2
end
end
end
Cheers for sticking out till the end!
I’ve created a gem based on this code. You can check it out on GitHub.
This is my first blog article and I am open for any questions, suggestions, remarks and anything else, just leave a comment below!
If you wish to get in touch with me, you can reach out to me via Twitter or LinkedIn.
Join our newsletter
Like what you see? Why not put a ring on it. Or at least your name and e-mail.
Have a project on the horizon?