How to Build Your Own Rails Generator

honeybadger_staff

Honeybadger Staff

Posted on February 9, 2023

How to Build Your Own Rails Generator

This article was originally written by William Kennedy on the Honeybadger Developer Blog.

Rails generators are one of the most prominent features of Rails. We can generate feature scaffolds, migrate databases, create mailers, and even create new background jobs. There is nothing more satisfying than running a generator with everything set up and ready to go.

Scaffolds also serve an essential function in a lot of successful gems. Many popular gems ship with an install command that sets up everything the developer needs to succeed with that library.

If you’re a long-time Rails user, you probably take generators for granted until you venture outside of Rails.

Generators are easy to view as "magic". How they work can be pretty obscure to a newcomer. This article will dig into exactly how generators work under the hood, and then we'll build one ourselves.

What is a Generator

A generator consists of the following:

  • A class that inherits from Rails::Generators::NamedBase
  • A template(optional)
  • A repeatable pattern

Kinds of Generators

Generators come in all shapes and sizes, but generally, they come in two variations—generators that take arguments and do something with them and installers that set up the plumbing.

These kinds of generators exist in the Noticed gemand within Rails itself via the various rails scaffold commands and even the rails new command, which is a Rails generator itself.

In the Noticed gem, we can do the following:

rails generate noticed:model
Enter fullscreen mode Exit fullscreen mode

This generates a new model called notifications, a migration, and some tests.

It then proceeds to give you some instructions on what to do next.

Now let’s dive into the code, which you can read here.

The critical thing here is that we inherit from Rails::Generators::NamedBase; note that we have a source_root method that points to the templates folder.

module Noticed
 module Generators
  class ModelGenerator < Rails::Generators::NamedBase
   include Rails::Generators::ResourceHelpers

   source_root File.expand_path("../templates", __FILE__)

   desc "Generates a Notification model for storing notifications."

   argument :name, type: :string, default: "Notification", banner: "Notification"
   argument :attributes, type: :array, default: [], banner: "field:type field:type"
 ...
Enter fullscreen mode Exit fullscreen mode

This class governs all behavior that goes into the noticed generator.

Note that we also have the argument method, which defaults to string. We can also pass arguments into this generator. For example, we might want the notifications model to be called something else instead of notification.

rails generate noticed:model Notice 
Enter fullscreen mode Exit fullscreen mode

Noticed has a generator targeted for taking an argument in the form of a Ruby Class.

rails generate noticed:notification CommentNotification 
Enter fullscreen mode Exit fullscreen mode

This can be seen here and is remarkably simple. Like the previous generator, it inherits from Rails::Generators::NamedBase and has the source_root method, which points to the templates folder. However, this example uses a template that can be seen here.

# To deliver this notification:
#
# <%= class_name %>.with(post: @post).deliver_later(current_user)
# <%= class_name %>.with(post: @post).deliver(current_user)

class <%= class_name %> < Noticed::Base
 # Add your delivery methods
 #
 # deliver_by :database
 # deliver_by :email, mailer: "UserMailer"
 # deliver_by :slack
 # deliver_by :custom, class: "MyDeliveryMethod"

 # Add required params
 #
 # param :post

 # Define helper methods to make rendering easier.
 #
 # def message
 #  t(".message")
 # end
 #
 # def url
 #  post_path(params[:post])
 # end
end
Enter fullscreen mode Exit fullscreen mode

Invocation Order

All public methods in the generator will be called one after the other. Private methods will not be called but are available in your public methods like regular Ruby classes.

Building A Generator - Getting Started

The best way to understand generators is, of course, to build one ourselves. This generator will be simple initially but easy to build upon in the future. We will keep our generator simple and create new StimulusJS controllers.

The first thing we do is build a new Rails app to get started.

Assuming you’re using Rails 7, run the following command:

rails new rails_generator
Enter fullscreen mode Exit fullscreen mode

Now we change into that directory:

cd rails_generator
Enter fullscreen mode Exit fullscreen mode

Once that's ready, we will use a Rails generator to create our first generator.

rails g generator stimulus
Enter fullscreen mode Exit fullscreen mode

Then we will do the following:

   create lib/generators/stimulus
   create lib/generators/stimulus/stimulus_generator.rb
   create lib/generators/stimulus/USAGE
   create lib/generators/stimulus/templates
   invoke test_unit
   create  test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode

The first file I like to edit is the lib/generators/stimulus/USAGE file. This allows us to do some documentation upfront and provide helpful hints to fellow developers who might want to use your generator.

So, let’s change that by updating the lib/generators/stimulus/USAGEfile:

Description:
  Generates a New Stimulus Controller

Example:
  bin/rails generate stimulus Thing

  This will create:
    app/javascripts/controllers/thing_controller.js

Enter fullscreen mode Exit fullscreen mode

In our terminal, we can run the following, and we’ll see the output.

rails g stimulus --help
Enter fullscreen mode Exit fullscreen mode
Usage:
 rails generate stimulus NAME [options]

Options:
 [--skip-namespace], [--no-skip-namespace]       # Skip namespace (affects only isolated engines)
 [--skip-collision-check], [--no-skip-collision-check] # Skip collision check

Runtime options:
 -f, [--force]          # Overwrite files that already exist
 -p, [--pretend], [--no-pretend] # Run but do not make any changes
 -q, [--quiet], [--no-quiet]   # Suppress status output
 -s, [--skip], [--no-skip]    # Skip files that already exist

Description:
  Generates a New Stimulus Controller

Example:
  bin/rails generate stimulus Thing

  This will create:
    app/javascripts/controllers/thing_controller.js
Enter fullscreen mode Exit fullscreen mode

This is a good start, but nothing happens if we run the generator. Let’s fix this. Let’s start by adding a test to our test/lib/generators/stimulus_generator_test.rb

# test/lib/generators/stimulus_generator_test.rb
require "test_helper"
require "generators/stimulus/stimulus_generator"

class StimulusGeneratorTest < Rails::Generators::TestCase
 tests StimulusGenerator
 destination Rails.root.join("tmp/generators")
 setup :prepare_destination

 test 'should add thing controller file' do
  run_generator ['Thing']

  assert_file 'app/javascript/controllers/thing_controller.js'
 end
end
Enter fullscreen mode Exit fullscreen mode

Now we have created a failing test. We can run it.

rails test test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode
# Running:

F

Failure:
StimulusGeneratorTest#test_should_add_thing_controller_file [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:12]:
Expected file "app/javascript/controllers/thing_controller.js" to exist, but does not


rails test test/lib/generators/stimulus_generator_test.rb:9



Finished in 0.007052s, 141.8037 runs/s, 141.8037 assertions/s.
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

Let’s fix this.

# lib/generators/stimulus/stimulus_generator.rb
class StimulusGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)

 def create_stimulus_controller
  create_file "app/javascript/controllers/#{file_path}_controller.js"
 end
end

Enter fullscreen mode Exit fullscreen mode

Now when we run our tests, it should pass.

rails test test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 41043

# Running:

.

Finished in 0.007780s, 128.5347 runs/s, 128.5347 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

Building A Generator - Generating Code

So far, what we’ve built is pretty cool but not practical. It only creates an empty file. However, we’ve got all the building blocks ready to take our generator further, so let’s start with a non-empty file.

Most stimulus controllers have the following code. The connect and initialize methods are optional.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }

 connect() {
 }
}
Enter fullscreen mode Exit fullscreen mode

Our generator does not add any of this code, and it would be helpful if it did. Let’s modify our generator to do this.

In our tests, let’s add the following:

# test/lib/generators/stimulus_generator_test.rb
class StimulusGeneratorTest < Rails::Generators::TestCase
 ...
 test 'should add stimulusjs boilerplate' do
  run_generator ['Thing']
  assert_file "app/javascript/controllers/thing_controller.js" do |content|
   assert_match /Controller/, content
   assert_match /connect()/, content
   assert_match /@hotwired/, content
  end
 end
end
Enter fullscreen mode Exit fullscreen mode

In true test-driven-development fashion, we run this test, and it fails.

rails test test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode
Run options: --seed 15750

# Running:

F

Failure:
StimulusGeneratorTest#test_should_add_stimulusjs_boilerplate [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:17]:
Expected /Controller/ to match "".


rails test test/lib/generators/stimulus_generator_test.rb:15

.

Finished in 0.009860s, 202.8398 runs/s, 405.6795 assertions/s.
2 runs, 4 assertions, 1 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

We can take advantage of our templates folder to make this test pass. Let’s create a new file that will be a template for our stimulus controller.

touch lib/generators/stimulus/templates/stimulus_controller.js.erb
Enter fullscreen mode Exit fullscreen mode

In that file, add our stimulus controller boilerplate.

// lib/generators/stimulus/templates stimulus_controller.js.erb
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }

 connect() {
 }
}
Enter fullscreen mode Exit fullscreen mode

Let’s also update our generator class.

# lib/generators/stimulus/stimulus_generator.rb

class StimulusGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)

 def create_stimulus_controller
  template 'stimulus_controller.js.erb', "app/javascript/controllers/#{file_path}_controller.js"
 end
end
Enter fullscreen mode Exit fullscreen mode

Now, let’s rerun our test.

rails test test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 61204

# Running:

..

Finished in 0.010278s, 194.5904 runs/s, 778.3615 assertions/s.
2 runs, 8 assertions, 0 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

We have saved ourselves quite a lot of time. The next time we need a new stimulus controller, we run our generator, and we’re ready to go. However, now that we have gone beyond the basics of creating a file, could we go further. Could we make our generator even better?

Build A Generator - Handling Command Line Arguments

When creating a new Rails model, we can pass in options.

rails g model Item name:string description:text
Enter fullscreen mode Exit fullscreen mode

This will create an ”items” table with name and description columns. StimulusJS controllers have several options that allow us to expand the usefulness of our controller.

Wouldn’t it be great if we could also generate markup from our generator? Let’s try it.

Go back to our tests and write our test case.

# test/lib/generators/stimulus_generator_test.rb
class StimulusGeneratorTest < Rails::Generators::TestCase
 ...
 test 'should generate actions' do
  run_generator ["toggle", "--actions", "toggle hide"]
  assert_file "app/javascript/controllers/toggle_controller.js" do |content|
   assert_match /toggle()/, content
   assert_match /hide()/, content
  end
 end
end
Enter fullscreen mode Exit fullscreen mode

Let’s run our tests.

rails test test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 2776

# Running:

.F

Failure:
StimulusGeneratorTest#test_should_generate_actions [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:26]:
Expected /toggle()/ to match "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n\n initialize() {\n }\n \n connect() {\n }\n}\n\n".


rails test test/lib/generators/stimulus_generator_test.rb:24

.

Finished in 0.013761s, 218.0074 runs/s, 799.3605 assertions/s.
3 runs, 11 assertions, 1 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

When taking arguments in our generators, we can use the class_option method. We now need to update our generator once again.

class StimulusGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)

 class_option :actions, type: :array, default: []

 def create_stimulus_controller
  template 'stimulus_controller.js.erb', "app/javascript/controllers/#{file_path}_controller.js"
 end
end
Enter fullscreen mode Exit fullscreen mode

Note that we have an empty array as the default for the argument. This means we don’t have to worry about nil errors if we don’t use the action argument.

We can access the arguments by using the options method. Here’s how I have done it in the lib/generators/stimulus/templates/stimulus_controller.js.erb:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }

 connect() {
 }
<% options[:actions].each do |action| %>

 <%= action %>(){
 }

<% end %>
}
Enter fullscreen mode Exit fullscreen mode

Now when we can run our generator with options.

rails g stimulus toggle --actions toggle hide
Enter fullscreen mode Exit fullscreen mode

This will produce the following in the file app/javascript/controllers/toggle_controller.js:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }

 connect() {
 }

 toggle(){
 }

 hide(){
 }
}
Enter fullscreen mode Exit fullscreen mode

And, of course, if we rerun our tests, we’ll see that they pass.

rails test test/lib/generators/stimulus_generator_test.rb
Enter fullscreen mode Exit fullscreen mode
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 47721

# Running:

...

Finished in 0.015036s, 199.5211 runs/s, 864.5916 assertions/s.
3 runs, 13 assertions, 0 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

We could do the same process for the other stimulus features, such as targets, values, and CSS classes.

  1. Create the test.
  2. Add the class option.
  3. Adjust our template.
  4. Ensure the test passes.

Conclusion

Generators are pretty helpful and can save hours, reduce boilerplate errors, and create a standard across your codebase as it gets larger over time.

However, there is a balance to be struck on when to automate something using a generator. Luckily, XKCD has a handy guide.

The source code for this app can be found here.

💖 💪 🙅 🚩
honeybadger_staff
Honeybadger Staff

Posted on February 9, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related