As you've probably guessed by the title of my article, I still consider Ruby on Rails as a relevant technology that offers a lot of value, especially when combined with ReactJS as it's frontend counterpart. Here's how I approach the topic.
Starting a new project using Ruby on Rails is very easy. While with a lot of other frameworks you spend some time building a basic MVC-carcass, Rails does everything for you thanks to its built-in scaffolding feature. That’s so enjoyable and refreshing; one command and you have model, migration, controller, views, helpers, and even assets. A lot of gems will add more features, for example rspec-rails will generate all the specs you need (and some you don’t), and factory_girl_rails will add an empty factory for your model.
But after some time you notice that all the time you saved with Rails Scaffold Generator you now spend removing some generated files and heavily re-writing others. Luckily, Rails gives us tools to customise what will be generated with configuring and changing templates.
Still, copying configs and templates from project to project is not a very optimised workflow. It also makes your code messy because developer tools don’t belong in the /lib folder. The nice and ruby-compatible way is to put code in your own gem, and that’s what we will do today. Actually, let’s also create our own generator instead of patching the Rails’ one. This way we’ll have full control of what is generated and how.
The example that we will build is the actual generator I use in my projects. It generates only controller, views and controller spec for Rspec. It expects database table, model and model’s factory to be in place. It is also designed specifically for Devise and CanCanCan gems. By the way, we have a great article about them.
Creating Ruby Gem with basic generator
I will not go into details about creating gems. There’s very thorough official guides for gems and bundler. So after
$ bundle gem nopio_scaffold
which will generate gem structure for us, we have our gem which does absolutely nothing. Let’s add our extremely useful gem to the test rails application. Just add
gem 'nopio_scaffold', :path => '[path to gem folder]'
to your Gemfile and we’re ready to use it. Now we just need to write some code.
Note: we have two folders: one with gem and one with our rails application. All the code we write is for the gem. All the console commands we use further we call from the rails application folder.
Rails generators are based on Thor. But of course we’ll also want to use all the nice features that Rails team built on top of it. Let’s start with creating the main class for our generator:
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
module NopioScaffold module Generators # Custom scaffolding generator class ControllerGenerator < Rails::Generators::NamedBase include Rails::Generators::ResourceHelper desc "Generates controller, controller_spec and views for the model with the given NAME." end end
Inheriting the class from Rails::Generators::NamedBase will give us all the Thor features with a lot of additional useful methods from Rails like #class_name, #singular_name etc. Including Rails::Generators::ResourceHelper will add a few more helpful methods, e.g. #controller_class_name.
If you open console in the root of your rails application folder and type
$ rails g
you’ll see the list of all the generators you have. There already should be:
NopioScaffold: nopio_scaffold:controller
Of course if you run it, it will do nothing.
Let’s also create our own helper class in the same folder. We will need it later:
[gem folder]/lib/generators/nopio_scaffold/generator_helpers.rb
module NopioScaffold module Generators # Some helpers for generating scaffolding module GeneratorHelpers end end End
Don’t forget to include it in our generator’s main class:
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
require 'generators/nopio_scaffold/generator_helpers' module NopioScaffold module Generators # Custom scaffolding generator class ControllerGenerator < Rails::Generators::NamedBase include Rails::Generators::ResourceHelper include NopioScaffold::Generators::GeneratorHelpers
Using templates
To use templates we need to tell our generator where to look for them:
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
source_root File.expand_path('../templates', __FILE__)
As you see, our first template goes to the templates folder:
[gem folder]/lib/generators/nopio_scaffold/templates/controller.rb
class <%= controller_class_name %>Controller < ApplicationController before_action :authenticate_user! load_and_authorize_resource end
You can write templates as usual erb files. The result of this one will be our controller class. The controller will be empty, and will only have Devise and CanCan methods calls. Now we need to use this template.
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
def copy_controller_and_spec_files template "controller.rb", File.join("app/controllers", "#{controller_file_name}_controller.rb") end
We don’t need to call methods in the generator class. All public methods will be called one by one on generating.
Now we can finally run our generator:
$ rails g nopio_scaffold:controller book
This command should create a controller with class BooksController in the /app/controllers folder of our test rails application. Pretty cool, right?
Making result depend on model’s attributes
Now let’s add actions to our controller:
[gem folder]/lib/generators/nopio_scaffold/templates/controller.rb
def index end def show end def new end def create if @<%= singular_name %>.save redirect_to @<%= singular_name %>, notice: '<%= human_name %> was successfully created.' else render :new end end def edit end def update if @<%= singular_name %>.update(<%= singular_name %>_params) redirect_to @<%= singular_name %>, notice: '<%= human_name %> was successfully updated.' else render :edit end end def destroy @<%= singular_name %>.destroy redirect_to <%= plural_name %>_url, notice: '<%= human_name %> was successfully destroyed.' end
As you see it’s pretty different from standard Rails scaffolding. For example #index is absolutely empty. That’s because CanCan’s #load_and_authorize_resource does a lot of work for us, and that’s one of reasons why I needed my own generator.
For the controller to be ready we also need to add a private method for parsing parameters (using params.require). But it would be really great for all params to already be there. Rails Scaffold Generator generates views based on an attributes list we send to the generator. Since our generator doesn’t accept attributes options as it expects models to already exist, we need to somehow get the fields list from that model. We have a helper module, let’s add some helper methods there:
[gem folder]/lib/generators/nopio_scaffold/generator_helpers.rb
attr_accessor :options, :attributes private def model_columns_for_attributes class_name.constantize.columns.reject do |column| column.name.to_s =~ /^(id|user_id|created_at|updated_at)$/ end end def editable_attributes attributes ||= model_columns_for_attributes.map do |column| Rails::Generators::GeneratedAttribute.new(column.name.to_s, column.type.to_s) end end
As you see we get all our attributes from the model, remove the ones that shouldn’t be edited and then generate an array of helpful objects. Now we can use this array in the controller template:
[gem folder]/lib/generators/nopio_scaffold/templates/controller.rb
private def <%= singular_name %>_params params.require(:<%= singular_name %>).permit(<%= editable_attributes.map { |a| a.name.prepend(':') }.join(', ') %>) end
This particular part of the template will give us this result in the generated controller:
private def book_params params.require(:book).permit(:name, :author, :year) end
Adding options to the generator
Sometimes for small models “show” action isn’t really needed, so we will add –skip-show option to our generator.
We can already pass some standard options to our generator, like -f, [–force] or -q, [–quiet]. We can add our own very easily:
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
class_option :skip_show, type: :boolean, default: false, desc: "Skip "show" action"
[gem folder]/lib/generators/nopio_scaffold/generator_helpers.rb
private def show_action? !options['skip_show'] end
Now we can use our new helper in the controller template:
[gem folder]/lib/generators/nopio_scaffold/templates/controller.rb
def index end <% if show_action? -%> def show end <% end -%> def new end
Take a look at “-%>”. It’s pretty normal erb syntax, but rarely used in html templates. It specifies that a new string shouldn’t be started after erb block.
Creating templates for templates
We already had a model, now we have a controller. All that’s left is to add some views and we’ll have our MVC structure. Since we don’t need to think about other people using our generator, we can add some things that are not usually in scaffolding. For example bootstrap style classes.
“index.html.erb” view
[gem folder]/lib/generators/nopio_scaffold/templates/views/index.html.erb
Please notice how erb-blocks look when they should be in the resulting view:
<%= "<% end %%>" %>
“%%>” generates %> instead of closing the block.
“_form.html.erb” view
[gem folder]/lib/generators/nopio_scaffold/templates/views/_form.html.erb
In the _form.html.erb template we take advantage of more useful things than those we have in our editable fields list. For every attribute we generate an input field with a specific name and input type. As a result we will have a form that would need very few alterations comparing to an empty form from a standard generator.
Other templates are not so interesting, but here they are:
“show.html.erb” view
[gem folder]/lib/generators/nopio_scaffold/templates/views/show.html.erb
“new.html.erb” view
[gem folder]/lib/generators/nopio_scaffold/templates/views/new.html.erb
“edit.html.erb” view
[gem folder]/lib/generators/nopio_scaffold/templates/views/edit.html.erb
And here is how we will call the generation of views from our generator class:
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
def copy_view_files directory_path = File.join("app/views", controller_file_path) empty_directory directory_path view_files.each do |file_name| template "views/#{file_name}.html.erb", File.join(directory_path, "#{file_name}.html.erb") end end
As you see it uses another helper method:
[gem folder]/lib/generators/nopio_scaffold/generator_helpers.rb
def view_files actions = %w(index new edit _form) actions << 'show' if show_action? actions end
Generating a file from several templates
Ruby code guidelines tell us to separate long files into several smaller ones. Controller spec files are not short in general, and their templates will be longer because of additional logic. So it would be better to divide this template and have specs for each action in a separate file.
[gem folder]/lib/generators/nopio_scaffold/generator_helpers.rb
def all_actions actions = %w(index new create edit update destroy) actions << 'show' if show_action? actions end def controller_methods(dir_name) all_actions.map do |action| read_template("#{dir_name}/#{action}.rb") end.join("n").strip end def read_template(relative_path) ERB.new(File.read(source_path(relative_path)), nil, '-').result(binding) end def source_path(relative_path) File.expand_path(File.join("../templates/", relative_path), __FILE__) end
Now we can put our views in spec/actions and just call the helper method from the main controller_spec template.
But first let’s create another helper. If you have worked with rspec, you will remember that it generates a controller spec with the usage of valid_attributes and invalid_attributes hashes. But of course instead of adding actual hashes, it asks us to add them ourselves with skip() method.
We have already used our attributes list several times; we should use it now as well. Here’s a method that gets a hash with one attribute (string or integer) with two different values for it:
def field_to_check_update @field_update_in_spec ||= if text_field = editable_attributes.find { |attr| attr.type == 'string' } { name: text_field.name, old_value: "'Just Text'", new_value: "'New Text'" } elsif numeric_field = editable_attributes.find { |attr| attr.type == 'integer' } { name: numeric_field.name, old_value: 1, new_value: 2 } else false end end
So finally, here’s our spec template:
[gem folder]/lib/generators/nopio_scaffold/templates/spec/controller.rb
require 'rails_helper' RSpec.describe <%= controller_class_name %>Controller, type: :controller do before do @user = create(:user) sign_in @user end let(:not_authorized_<%= singular_name %>) { create(:<%= singular_name %>, user_id: subject.current_user.id + 1) } <% if field_to_check_update -%> let(:valid_attributes) { { <%= field_to_check_update[:name] %>: <%= field_to_check_update[:old_value] %> } } <% else -%> let(:valid_attributes) { skip('Add a hash of attributes invalid for your model') } <% end -%> let(:invalid_attributes) { skip('Add a hash of attributes invalid for your model') } <%= controller_methods 'spec/actions' %> end
I won’t add all the action files, this article has enough code already. Let’s just see part of the “update” partial where we use our valid_attributes.
[gem folder]/lib/generators/nopio_scaffold/templates/spec/actions/update.rb
describe 'PUT #update' do context 'with valid params' do <% if field_to_check_update -%> let(:new_attributes) { { <%= field_to_check_update[:name] %>: <%= field_to_check_update[:new_value] %> } } <% else -%> let(:new_attributes) { skip('Add a hash of new attributes valid for your model') } <% end -%> it 'updates the requested <%= singular_name %>' do <%= singular_name %> = create(:<%= singular_name %>, user_id: subject.current_user.id) put :update, params: { id: <%= singular_name %>.to_param, <%= singular_name %>: new_attributes } <%= singular_name %>.reload <% if field_to_check_update -%> expect(assigns(:<%= singular_name %>).<%= field_to_check_update[:name] %>).to eq(<%= field_to_check_update[:new_value] %>) <% else -%> skip('Check if your field changed') <% end -%> end
it 'redirects to the <%= singular_name %>' do <%= singular_name %> = create(:<%= singular_name %>, user_id: subject.current_user.id) put :update, params: { id: <%= singular_name %>.to_param, <%= singular_name %>: valid_attributes } <% if show_action? -%> expect(response).to redirect_to(<%= singular_name %>) <% else -%> expect(response).to redirect_to(<%= plural_name %>_path) <% end -%> end end end
Changing already existing files
Almost ready! But if we run the generator right now and try to see our controller, we will get a rails routing error. Rails give us an easy way to add resources to our routes.rb with the special helper #route.
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
def add_routes routes_string = "resources :#{singular_name}" routes_string += ', except: :show' unless show_action? route routes_string end
Time to generate again… No, we still can’t see controller. We don’t get an error, we’re just being redirected to the root_url. What’s the problem? We forgot about CanCan. Even after signing in we still don’t have the ability to view and manage our model. Unfortunately we don’t have any useful hooks for adding something to our abilities.rb, but we still can add text to existing files with #inject_into_file.
[gem folder]/lib/generators/nopio_scaffold/controller_generator.rb
def add_abilities ability_string = "n can :manage, #{class_name}, user_id: user.id" inject_into_file "#{Rails.root}/app/models/ability.rb", ability_string, after: /def initialize[a-z()]+/i end
The Result
Now the coding part is finally ready. Let’s see what we’ve got:
$ rails g model books user_id:integer title:string author:string year:integer $ rake db:migrate $ rails g nopio_scaffold:controller books
These 3 commands will create a basic CRUD for Books for us model that we won’t need to change as much as we would with usual Rails Controller Scaffold.
You can find the resulting gem here.