Rewriting a small rails & react application

2020-06-16

Table of contents

Introduction

In 2017, Life on Mars helped build http://mentor.alumniei.pt/. It’s a portal where college students can contact alumni (mentors) when they’re facing career, academic, or personal issues. We built the platform, contacted some alumni members, but never got around to publish it. There’s some talk of reviving the project, so I decided to spend some time refreshing the code base and improving things that, in retrospective, are off.

Here are some of the things that I think we should fix, even before logging in to the platform or checking out the code:

On the code side of things, the project has two components: a rails api only backend and a react frontend. These are both deployed to Heroku as separate apps. Since we’re currently serving this from free apps, the cold start time isn’t the best.

The project is not very big. You can log in as either a student or an mentor. Mentors can edit their profile, and students can view and filter mentors. There’s no communication inside the website itself. Mentors specify their preferred communication methods. There are some admin use cases as well.

Across both projects, this has 1200 lines of ruby, 3000 lines of jsx and 500 lines of scss.

I’m usually on board with this backend/frontend split, but in a single-ish person team, with a project this small, I will be better served by a rails monolith. I’m also not a big fan of requiring javascript to use the platform.

Before I do any of the improvements I mentioned, I’ll start by putting this all in a single codebase, without any javascript.

In this post, I will set up a rails application from scratch, with my personal flavor of gems and modifications. Then I’ll go through the process of adding the registration flow.

Setting up a new rails project

I’ll be using ruby 2.7.0 in this project. I’m not sure if this is going to work. I had some issues with rails spewing out a bunch of warnings before, but maybe it’s already fixed. I manage my ruby installations with asdf.

Usually, you’re told to install rails globally so that you can run rails new. I avoid that by creating an initial Gemfile with just the following:

1
2
3
source "https://rubygems.org"

gem "rails"

Rails has recently added a rails new --minimal option to disable things like spring, actiontext, and other gems. This feature is not released yet, so I created an app with a lot of options to disable things I don’t like or use.

1
2
3
4
5
6
7
8
$ bundle exec rails new \
  --skip-action-cable \
  --skip-action-mailbox \
  --skip-spring \
  --skip-turbolinks \
  --skip-webpack-install \
  --database=postgresql \
  .

I’m removing spring because I don’t trust it. Bootsnap seems to work fine, though. ActionMailbox handles incoming email messages, which I won’t need here. ActionCable won’t be needed either. I’m going with a no javascript approach at first, so I’m also skipping turbolinks and webpack.

Running this will cause rails to ask to overwrite Gemfile, which is something I want. This is enough to get the app started, but I do some changes to the Gemfile before moving on.

I remove gems I don’t need. In this case, I removed webpacker, jbuilder, and webdriver related gems.

I remove most comments. They don’t serve me any purpose after an initial scan.

I remove version restrictions for most gems. I rely on dependabot to create PRs for the upgrades instead of doing them manually, and I want to keep up with major version releases.

I sort gems in each group alphabetically. It makes the decision of where to put new gems easy. Also, rubocop-rails enforces this by default.

The next step is adding some gems that I know I will use. Here’s a list:

Most of these gems are useful in development or test environments. pundit is the only one that will run in production. I probably will need some extra gems as I add more code, but I’ll figure those out along the way.

Before continuing, I need to tweak .rubocop.yml and fix rubocop warnings. Rails does not generate a rubocop compatible project, so there’s some work to do. This is what my .rubocop.yml looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# I need to enable the extra plugins
require:
  - rubocop-performance
  - rubocop-rails

AllCops:
  # I'm excluding rails generated files to make
  # rails version upgrades easier.
  Exclude:
    - "db/**/*"
    - "bin/*"
    - "config.ru"
    - "config/**/*"
    - "Rakefile"
    - "vendor/**/*"
  # New cops that show up in new rubocop versions
  # should be enabled by default to avoid polutting
  # this config file.
  NewCops: enable

# I have become a trailing comma person
Style/TrailingCommaInArrayLiteral:
  EnforcedStyleForMultiline: comma

Style/TrailingCommaInHashLiteral:
  EnforcedStyleForMultiline: comma

Style/TrailingCommaInArguments:
  EnforcedStyleForMultiline: comma

# I don't want to be forced to write documentation for every class/module
Style/Documentation:
  Enabled: false

The next step is to configure a database. I already have dotenv installed, so I need to:

Another thing that I also need to configure is the application timezone. This application is aimed at a portuguese university, but I like having everything in UTC and dealing with conversion at display time, if needed. I’ll add this initializer:

1
2
3
# config/initializers/timezone.rb
Rails.application.config.time_zone = 'UTC'
Rails.application.config.active_record.default_timezone = :utc

Running rails with bundle exec rails server now displays the “Yay! You’re on Rails!” page. I’m ready to start adding functionality.

Rails default index page, showing Rails and Ruby versions

Adding user registration - Database

Before starting to create tables, I need to enable the uuid extension and use it as the default primary key type:

1
2
3
4
5
6
7
8
9
10
11
# db/migrate/20200608144955_enable_extension_uuid.rb
class EnableExtensionUuid < ActiveRecord::Migration[6.0]
  def change
    enable_extension 'pgcrypto'
  end
end

# config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.orm :active_record, primary_key_type: :uuid
end

The extension lets me use the uuid data type and the gen_random_uuid() function. The generator configuration makes it that bin/rails g migration generates code with uuid primary keys.

I want to keep the database close to what we had in the API only version. The main entity here is user, which represents both students and mentors. Most of the attributes are for mentor accounts. The only information we store on students are the email address and password digest. I could split this up into a users table, for login information, and a mentors table, for mentor profiles, but the tradeoff isn’t worthwhile. It introduces complexity and the only gains are that it becomes clearer which attribute is for mentors and which attributes are for every user. This is what the old users table looked like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create_table "users", id: :uuid do |t|
  t.string "email", null: false
  t.string "password_digest"
  t.boolean "blocked", default: false
  t.boolean "admin", default: false, null: false
  t.boolean "mentor", default: false, null: false
  t.boolean "active", default: false
  t.text "name"
  t.text "bio"
  t.text "picture_url"
  t.text "picture"
  t.integer "year_in"
  t.integer "year_out"
  t.text "links", default: [], array: true
  t.text "location"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["email"], name: "index_users_on_email", unique: true
end

The fields email, password_digest, and blocked are account related fields. admin and mentor are role fields. created_at and updated_at are standard rails fields, for auditing purposes. The other fields are mentor profile fields. If I decided to split this into two tables, it would look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
create_table "users", id: :uuid do |t|
  t.string "email", null: false
  t.string "password_digest"
  t.boolean "blocked", default: false
  t.boolean "admin", default: false, null: false

  t.timestamps
  t.index ["email"], name: "index_users_on_email", unique: true
end

create_table "mentors", id: :uuid do |t|
  t.references :user, type: :uuid, null: false,
               foreign_key: true, index: { unique: true }

  t.boolean "active", default: false
  t.text "name"
  t.text "bio"
  t.text "picture_url"
  t.text "picture"
  t.integer "year_in"
  t.integer "year_out"
  t.text "links", default: [], array: true
  t.text "location"
  t.timestamps
end

Maybe in a future iteration this separation will be worthwhile, but not for now. I might want to have admins review changes to mentor profiles before they go live, as a moderation step. If I do it, the split might make sense then.

I changed the migration to set the email uniqueness constraint to lower(email) instead of just email to avoid having multiple users with the same email in different cases. This is not in the standard, but most email providers treat their addresses as case insensitive.

Adding user registration - Routes

Now that I have the database set up, I’ll create the model, routes, and controllers. This is where you might use something like devise or clearance. I’m going with using rails’s has_secure_password feature.

I will have four routes related to registrations:

This is what the routes for these endpoints look like:

1
2
3
4
5
6
7
Rails.application.routes.draw do
  resources :registrations, only: %i[create new show] do
    member do
      post :confirm
    end
  end
end
1
2
3
4
5
6
$ bin/rails routes
              Prefix Verb URI Pattern                           Controller#Action
confirm_registration POST /registrations/:id/confirm(.:format)  registrations#confirm
       registrations POST /registrations(.:format)              registrations#create
    new_registration GET  /registrations/new(.:format)          registrations#new
        registration GET  /registrations/:id(.:format)          registrations#show

I will need a registration ID and a way to track which users are confirmed. The ID should not be guessable, as the purpose of this is to only allow the email account owner to confirm the registration. I can either create a registrations table, with a confirmed_at field, or add registration_id and confirmed_at to the users table.

I’m going with the second approach. Since there’s only one registration per account, I don’t think I’m losing anything. When I start working on the password recovery flow, I might have a separate table, so that we can keep track of every password recovery ever, for auditability.

Calling the new field registration_id could be misleading, because it looks like a foreign key. I am aware of this and will proceed anyway. This is the migration:

1
2
3
4
5
6
7
8
9
10
11
class AddRegistrationIdAndConfirmedAtToUsers < ActiveRecord::Migration[6.0]
  def change
    change_table :users do |t|
      t.datetime :confirmed_at
      t.uuid :registration_id,
             null: false,
             unique: true,
             default: -> { "gen_random_uuid()" }
    end
  end
end

Adding user registration - Model

The User model will have some validations, and I’m also adding some scopes and accessors that I will need soon:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class User < ApplicationRecord
  scope :confirmed, -> { where.not(confirmed_at: nil) }
  scope :confirmation_pending, -> { where(confirmed_at: nil) }
  scope :student, -> { where(mentor: false) }
  scope :mentor, -> { where(mentor: true) }

  validates :email, presence: true, uniqueness: { case_sensitive: false }
  validate :validate_feup_email, on: :create, if: -> { student? }

  after_create :reload

  has_secure_password

  def confirmed?
    !confirmed_at.nil?
  end

  def student?
    !mentor
  end

  def mentor?
    mentor
  end

  def confirm!
    update(confirmed_at: Time.current)
  end

  private

  def validate_feup_email
    return if email.split('@').last == 'fe.up.pt'

    errors.add(:email, :feup_address_required)
  end
end

I’m using a symbol :feup_address_required in errors.add to be able to translate it.

Another gotcha here is that since the registration_id column has a database default, User#create does not return a model with that column value. To ensure it always gets loaded, there’s a after_create :reload hook. This, by itself, may be enough reason to create defaults in rails instead of in postgresql until this gets solved. Here’s the link to the issue:

https://github.com/rails/rails/issues/34237

It looks like it might be a good candidate for a pull request.

This model has a lot of code. Usually I would move on to work on the controllers, but since there’s already some logic in here, I’ll add some tests first.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
require 'rails/test_help'

module ActiveSupport
  class TestCase
    parallelize(workers: :number_of_processors)

    include FactoryBot::Syntax::Methods
  end
end

# cat test/factories/users.rb
FactoryBot.define do
  factory :user do
    password { Faker::Internet.password }

    trait :student do
      mentor { false }
      email { Faker::Internet.email(domain: 'fe.up.pt') }
    end

    trait :mentor do
      mentor { true }
      email { Faker::Internet.email }
    end
  end
end

# test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase
  test 'students with fe.up.pt email addresses are allowed' do
    assert build(:user, :student, email: 'student@fe.up.pt').valid?
    assert build(:user, :student, email: 'STUDENT@FE.UP.PT').valid?
  end

  test 'students without an fe.up.pt email address are not allowed' do
    assert_not build(:user, :student, email: 'student@example.org').valid?
  end

  test 'mentors without an fe.up.pt email address are allowed' do
    assert build(:user, :mentor, email: 'mentor@example.org').valid?
  end

  test 'confirm! confirms user' do
    user = create(:user, :student)

    user.confirm!

    assert user.confirmed?
    assert_not_nil user.confirmed_at
  end

  test 'two users with the same email address in different cases are not allowed' do
    user = create(:user, :student)

    assert_not build(:user, :student, email: user.email.upcase).valid?
  end

  test 'user creation includes registration_id' do
    user = create(:user, :student)

    assert_not_nil user.registration_id
  end
end

Adding user registration - Controller

Now that the model is done, I can add the four routes code. This is where most of the action is happening. In a large application, I would probably bother with having some of this code in an app/services/ structure, but this is simple enough that creating those will only cause more confusion when reading.

For now, I won’t be sending the actual confirmation email.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class RegistrationsController < ApplicationController
  def confirm
    registrations.find_by!(registration_id: params[:id]).confirm!

    redirect_to '/'
  rescue ActiveRecord::RecordNotFound
    render :not_found, status: :not_found
  end

  def create
    @user = registrations.create(create_params)

    if @user.valid?
      render status: :created
    else
      render :new, status: :bad_request
    end
  end

  def new
    @user = registrations.new
  end

  def show
    @user = registrations.find_by!(registration_id: params[:id])
  rescue ActiveRecord::RecordNotFound
    render :not_found, status: :not_found
  end

  private

  def registrations
    User.student.confirmation_pending
  end

  def create_params
    params.require(:user).permit(:email, :password)
  end
end

The downsides of not creating a database table for registrations are showing already. I’m using User.student.confirmation_pending everywhere, so I aliased it to registrations to make the code a bit DRYer, but it returns a User relation, so that might be a bit confusing. I also had to fight with form building, particularly in show.html.erb, to make things work:

1
2
3
4
5
<h1>Confirm your account</h1>

<%= form_with url: confirm_registration_path(@user.registration_id), method: :post do |f| %>
  <%= f.submit %>
<% end %>

If I had created the extra model, the confirm route would be a PATCH instead, and the view would be a bit simpler:

1
2
3
4
5
<h1>Confirm your account</h1>

<%= form_with model: @registration, url: confirm_registration_path(@registration) do |f| %>
  <%= f.submit %>
<% end %>

Adding user registration - email setup

To send registration confirmations, I need an email provider. Recently, I’ve used both Sendgrid and Amazon SES. Sendgrid has a free tier option, while SES costs $0.10 per 1000 emails unless you’re sending emails from an EC2 instance, in which case you have 62k free emails per month. They’re both easy to set up. Since I already have a personal AWS account, I’ll go with that.

To use SES in rails, the easiest way is to use the aws-sdk-rails gem and configure rails action mailer to use it:

1
2
3
4
5
6
7
8
9
# Gemfile

gem 'aws-sdk-rails'

# config/initializers/mailer.rb

if Rails.env.test? == false
  Rails.application.config.action_mailer.delivery_method = :ses
end

This gem needs an AWS access key to work, unless you’re running this in an EC2 instance. I have an ~/.aws/credentials file set up, but it has multiple profiles without any defaults, so that I avoid using the wrong account. To explicitly set the profile, I need to set the AWS_PROFILE environment variable. The gem also needs to know which aws region it should use, so I’ll add these two variables to .env.development.local:

AWS_PROFILE=hugopeixoto
AWS_REGION=eu-central-1

The last SES setup step I need to do is to validate a few email addresses so that I can send and receive emails while testing. To use this in production, I will validate the sender domain, but for development, having a few validated addresses is enough.

I’m setting things up in eu-central-1, so I need to go to the following URL and verify some addresses:

https://eu-central-1.console.aws.amazon.com/ses/home?region=eu-central-1#verified-senders-email

I validated my two @fe.up.pt addresses and a @gmail.com one that I’ll be using as the sender.

The emails I’m going to send will have some links pointing back to the application, so the mailer needs to be aware of which domain I’m using. I configured this by adding a new environment variable:

BASE_URL=http://localhost:3000

And I used this variable in the mailer initializer:

1
2
3
4
5
6
7
# config/initializers/mailer.rb

if Rails.env.test? == false
  Rails.application.config.action_mailer.delivery_method = :ses
end

Rails.application.config.action_mailer.default_url_options = { host: ENV.fetch("BASE_URL") }

I also need to specify what address will be sending the emails, so I added another environment variable, EMAIL_SENDER_ADDRESS, to .env.development.local, and configured it globally in app/mailers/application_mailer.rb:

1
2
3
4
5
6
7
# frozen_string_literal: true

class ApplicationMailer < ActionMailer::Base
  default from: ->{ ENV.fetch("EMAIL_SENDER_ADDRESS") }
    layout 'mailer'
  end
end

Adding user registration - email sending

Now that everything’s configured, I need a mailer. Mailers are similar to controllers, where their actions represent messages.

1
2
3
4
5
6
7
$ bin/rails generate mailer registrations
    create  app/mailers/registrations_mailer.rb
    invoke  erb
    create    app/views/registrations_mailer
    invoke  test_unit
    create    test/mailers/registrations_mailer_test.rb
    create    test/mailers/previews/registrations_mailer_preview.rb

I could have created the message directly using generate mailer registrations confirmation. It creates the action stub automatically, with two placeholder templates (text and html). I’m going to override those (including the filenames), so I skipped that. This is what my mailer looks like:

1
2
3
4
5
6
7
8
9
class RegistrationsMailer < ApplicationMailer
  def confirmation
    @user = params[:user]
    @base_url = ENV.fetch("BASE_URL")
    @confirmation_url = registration_url(@user.registration_id)

    mail(to: @user.email, subject: default_i18n_subject)
  end
end

I’m passing a User object directly via params[:user].

Before, I used to serialize the id and fetching the model explicitly. I used to do this because email sending is something that you usually don’t do synchronously, but instead goes through a queueing mechanism like Sidekiq or Shoryuken. When using a queue, you probably don’t want to serialize the full User object and send it over. It may be stale by the time it is processed, you’ll be potentially storing sensitive information in the queue, and you’ll have to deal with object marshalling. Turns out my rails knowledge was super outdated, and ActiveJob uses globalid, which serializes ActiveRecord into URLs:

1
2
irb(main):002:0> User.first.to_global_id.to_s
=> "gid://mentorados/User/4c29d8db-11f3-400a-a4e7-59bbda8a71bf"

This means that I no longer have to pass ids manually.

I’m also using subject: default_i18n_subject in the #mail call. default_i18n_subject infers a translation key based on the mailer class and the method name. In this case, it is registrations_mailer.confirmation.subject. Using default_i18n_subject is the default, so I could omit the subject parameter, but I want to be sure that anyone looking at this knows what’s going on.

Now I need to write the email body template. I want these to be translatable, and I don’t want to deal with creating arbitrary translation keys for each paragraph (like body_1, body_2, etc) nor deal with interpolation shenanigans. To avoid this, I will create a template per language, with the locale in the filename. These are the contents of app/views/registrations_mailer/confirmation.en.text.erb:

1
2
3
4
5
6
7
8
9
We're sending someone registered this email address in <%= @base_url %>.

Before proceeding, we need you to confirm your email address:

<%= @confirmation_url %>

If you need any help or run into any issues:

mentor@alumniei.pt

The portuguese version goes in the file app/views/registrations_mailer/confirmation.pt.text.erb.

Rendering a different template based on the locale is handled by ActionView, so this works for mailer templates and controller templates.

Now that the mailer is done, I need to trigger the delivery of the email. I’ll change the registrations controller directly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def create
    User.transaction do
      @user = User.student.confirmation_pending.create(create_params)

      if @user.valid?
        RegistrationsMailer.with(user: @user).confirmation.deliver_now!

        render status: :created
      else
        render :new, status: :bad_request
      end
    end
  end
end

This is a basic approach for a basic project. It is sending the email messages synchronously, inside a database transaction. This is not the best approach, but given the low volume we’re expecting, it’s good enough. The database transaction is there to avoid creating a user record if the email message sending fails.

In some projects, at this point I’d consider extracting the registration logic into its own class / method / service. Something redundant, like app/services/registrations.rb with a Registrations::create method. I’m not having any of that in this project unless the controller gets really messy.

Wrapping up

I have a base rails project with my personal tweaks.

I learned about globalid, and how it interacts with ActiveJob and ActionMailer. This has been around since rails 4.2, which is probably a lesson in refreshing your knowledge and questioning your assumptions from time to time. The only reason I noticed that this was a thing was that I was reading through Action Mailer Basics guide and noticed that they’re using params[:user] instead of params[:id].

I learned a few new rails conventions for email translations: using default_i18n_subject and adding the locale to the view filename.

I ran into a limitation when using database defaults and had to work around it by adding a after_create :reload workaround. I usually have rails handle the default value generation, and I guess this is a good reason to keep doing that. Fixing this limitation may be a good candidate for a code contribution.

I’m using this application to experiment with a “back to basics” approach. After many years of working with API only microservices and javascript applications, relearning the basics of an html serving rails application feels kind of new.