Skip to content

Instantly share code, notes, and snippets.

@motine
Last active August 21, 2023 18:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save motine/f690a461d5891f5ba5893f841537f27b to your computer and use it in GitHub Desktop.
Save motine/f690a461d5891f5ba5893f841537f27b to your computer and use it in GitHub Desktop.
Rails Patterns

Proposal

Create an official repository of patterns similar to the Rails Guides, but with a focus on solving common tasks with the framework.

Proposal

Motivation

Especially for newcomers, it can be daunting to find idiomatic ways to leverage the framework to solve common problems. For example, if one googles for "how to do authentication in Rails", one gets the recommendation to use the Devise gem (and lots of other blog articles). However, as an experienced Rails developer, we know that implementing a basic login can be written with a few lines of code - without external dependencies. I believe having an official platform to look up such solutions could improve developer velocity, junior confidence, code quality and even security.

Aside from helping to establish common approaches for common problems, such a site could also help to surface the existing documentation. For example, if one tries to build a user form with nested attributes, finding a trustworthy resource is hard. However, if one knows about the documentation of ActiveRecord::NestedAttributes::ClassMethods and ActionView::Helpers::FormBuilder#fields_for the problem becomes almost trivial. On the proposed platform, we could show one use case – from model to view – and link to the aforementioned docs for more information.

I believe that such a platform could help promoting Ruby on Rails.

Goals

  • Improve the learning curve for beginners (by avoiding juniors having to swift through outdated blog articles/stack overflow comments)
  • Speed up development by establishing a well-curated, go-to reference for discovering common solutions
  • Enhance security by promoting proven, lightweight solutions (ideally free of external dependencies)
  • Improve code readability by providing recognizable patterns
  • Reduce uncertainty & arbitrariness which solution to choose by providing a trustworthy and up-to-date resource

Non-Goals:

  • It should not be a place to promote dogmatism (all solutions are just recommendations and show their trade-offs)
  • It should not promote particular gems, companies or developers
  • It should not be an endless pool of pages – rather concise and reduced to a single solution per problem

Considerations

Moderation The Rails Core Team has done a tremendous job in moderating the framework's functionality, API and documentation. Over the last years/decades, the community has written excellent guides which are promoted on www.rubyonrails.com. For such a pattern platform, a similar scrutiny by a moderation team would be necessary.

Format My first thought was to present the patterns similarly to software design patterns (Definition, Usage, Structure, Example), but we may find that tutorials, screencasts or other formats might suit better.

Dependencies The goal is to solve problems with as few external dependencies as reasonable/possible. Developers can read the patterns, understand how an implementation works and employ them in their applications. Limiting the usage of gems allows the developers to weigh the trade-offs between different approaches and if the usage of external gems is worth the dependency.

Guidelines Getting to a minimal and consistent choice of solutions will be difficult and require much thought. We could setup guidelines as to which solutions/alternatives are preferable (e.g. "no external dependencies").

Credibility I believe this platform – once established and sufficiently filled – should be accessible via the Rails website's main menu (similar to guides) to emphasize the official nature of the platform. I believe that it would be beneficial if the patterns were hosted under a subdomain of rubyonrails.org. This would maximise its credibility and establish the official nature of the collection.

Patterns Here are some more ideas on what I mean by common patterns: login/authentication, Dockerized environment, deployment via mrsk/capistrano, implement a wizard-style form, nested forms, forms without persistence (form objects), pagination/filtering/searching of records, simple full text search, testing a feature (unit test -> system tests), styling setup with tailwind.

Differentiation The line between the existing Rails Guides and the aforementioned patterns is thin. I believe that the Guides show how to use framework's (core) functionality, where the patterns could demonstrate how to tie different framework components together to solve common (more complex) problems. This is certainly a good point for discussion.

Similar Projects Ryan Bates' Rails Casts did tremendous work in teaching concepts in rails. Unfortunately, most casts are now outdated. Also his scope was much wider the this proposal.
A long time ago, there was a book called Rails Recipes. This was a compendium of nice patterns similar to what I imagine here. Unfortunately, the book is about Rails 3.

Scribbles

This would be a very basic (not really thought out) version of the patterns:

This is a little more innovative. The patterns are structured around the files changed. The learner can click on each file and will see the changes necessary to make the pattern work:

User Login

THIS IS A VERY EARLY DRAFT AND SERVES ONLY AS AN EXAMPLE

Providing a user login is one of the most implemented features in web applications. A login form is a standard feature that serves as the entry point for user authentication. It's a critical component that allows users to access personalized features and secure content within an application. Implementing it with Rails is a breeze.

Authentication verifies a user's identity, while authorization determines what that verified user is allowed to do within the system. In this pattern, we will take care of the login form and enforce authentication. Authorization will be tackled in another pattern (please see below).

Let's get the basic setup out of the way:

Basic Setup

Let's create a default Rails 7 application:

rails new session-demo
cd session-demo
rails about

You should see some information about Rails. If you see an error message, please consult the Getting Started with Rails guide.

In order to authenticate a user, we do need a user. Duh. So, let's create a user with a secure password (as seen in Registration pattern).

rails generate model user email:string password_digest:string
rails db:migrate

Add this line to the Gemfile:

# Gemfile
gem "bcrypt"

...and run:

bundle install

Add the secure password macro, which allows us to store the password of the user in a hashed manner:

# models/user.rb
class User < ApplicationRecord
  has_secure_password
end

Now we can create a test user via the rails console:

User.create!(email: 'tom@example.com', password: 'verysecret')
User.first # #<User:1234> ...
User.password_digest # => $2a$12$EdX6oJ/...

So far so good. Now, we have a basic User model in place and can start working on the login form.

Controller & Routes

For our demonstration, we need a page that is protected ("behind the login"). We will put this page into a WelcomeController. Also, we need a controller that hosts our login and logout actions:

rails g controller welcome index
rails generate controller sessions new

We can leave these controllers as they are for now and configure the routes:

# config/routes.rb
Rails.application.routes.draw do
  get 'login' => 'session#new'
  post 'login' => 'session#create'
  delete 'logout' => 'session#destroy'

  root 'welcome#index'
end

Now, we can run rails server and check the routes localhost:3000/login and localhost:3000/. They both should look pretty basic, but they should work.

The Login Form

We will not concern ourselves with styling for now, we will just put the following markup into the view:

<%# app/views/session/new.html.erb %>

<h1>Login</h1>

<%= form_with(model: @login, url: login_path) do |f| %>
  <% f.object.errors.full_messages.each do |msg| %>
    <p><%= msg %></p>
  <% end %>

  <%= f.label :email %>
  <%= f.email_field :email %>

  <%= f.label :password %>
  <%= f.password_field :password %>

  <%= f.submit "Log in" %>
<% end %>

As you can see, we create a form around the @login model instance and render two input fields. If we want this to work, we need to prepare a model:

# app/models/login.rb
class Login
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email
  attribute :password

  validates :email, :password, presence: true

  # returns the user if the user is valid, nil otherwise
  def authenticated_user
    return false unless valid?

    user = User.find_by(email:)&.authenticate(password)
    if !user
      errors.add(:password, :invalid)
      return false
    end

    return user
  end
end

This is a little more complicated. This model is a not an ActiveRecord model, so it will not be persisted to the database. It it only holds and validates the user input of the login form. If you have not seen something like this before, you can checkout the pattern Transient Form Models.

The model validates the inputs. We use the find_by method to find the user by their email address. The authenticate method is added by the has_secure_password macro added above. It compares the hashed password in the database with the password entered into the form.

Last but not least, we need to tie it all together in the controller:

# app/controllers/session_controller.rb
class SessionController < ApplicationController
  # GET /login
  def new
    @login = Login.new
  end

  # POST /login
  def create
    @login = Login.new(login_params)
    user = @login.authenticated_user
    if user
      session[:user_id] = user.id
      redirect_to root_path, notice: 'Logged in'
    else
      render :new, status: :unprocessable_entity
    end
  end

  protected

  def login_params
    params.require(:login).permit(:email, :password)
  end
end

The first step of the user is to GET the login page. We render an empty form in the new action. As soon as the user pushes the submit button, the create action is executed. There we pass strong parameters to the Login model. If the user entered valid credentials, we render the welcome#index page. If not, we re-render the new template with errors set in the @login instance.

Give it a shot at http://localhost:3000/login (see User.create! above for the correct credentials).

Logout

Now, that we can create a session, we can get our hands dirty with destroying the session. We already added the route above. We can add a link to our root page (later we would move the logout button to a layout):

<%# app/views/welcome/index.html.erb %>
<h1>Hello!</h1>

<p>Here you can <%= button_to('log out', logout_path, method: :delete) %>.</p>

The button_to helper in combination with the Rails UJS, will change the link's method to DELETE (caution: link_to does not support the same behavior). All we have to do in the controller is to clear the application's session and redirect:

# app/controllers/session_controller.rb
class SessionController < ApplicationController
  # ...

  # DELETE /logout
  def destroy
    session.clear
    redirect_to login_path, notice: 'Logged out'
  end

  # ...
end

Enforce Authentication

Our session management is complete, but our root page is still unprotected. Try it out: Logout and access the localhost:3000/. The welcome controller will happily render the page. Let's create a new concern that enforces authentication:

# controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_user
    helper_method :current_user
  end

  def authenticate_user
    redirect_to(login_path, alert: 'Please log in!') unless current_user
  end

  def current_user
    return if session[:user_id].blank?
    @current_user ||= User.find_by(id: session[:user_id])
  end
end

Since we want authentication for all pages, we can add the concern to the ApplicationController (we will deal with exceptions in a second):

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Authentication
end

All pages require a valid session. This also goes for our login action. It requires a valid session, which leads to a redirect to login, which requires a valid session, which leads to a redirect to login, which...

Let's fix this:

# app/controllers/session_controller.rb
class SessionController < ApplicationController
  skip_before_action :authenticate_user, only: [:new, :create]

  # ...

Both actions required for login (new & create) are now exempt from the authentication hook. If we logout and try to access the root page via localhost:3000, we will be redirected to the login.

Show the current user

Last but not least, we want to show information about the user. We show the email address of the signed-in user. Let's use the helper method current_user which we introduced in the concern above:

<%# app/views/welcome/index.html.erb %>

<h1>Hello <%= current_user.email %></h1>

Well done. You have successfully implemented a user login.

User Pattern Journey

The following patterns all relate to implementing user management. All patterns can be implemented independently:

  1. User - Registration
  2. User - Login (this pattern)
  3. User - Remember me
  4. User - Sign-up Confirmation
  5. User - Password Forgotten

Other Related Patterns

Variations

Instead of adding a the helper method current_user to the ApplicationController, we could also populate the Current singleton. This is especially useful if you find yourself passing around the current user a lot. Here is the code to adapt the pattern:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user
end

# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_user
  end

  def authenticate_user
    gather_user # we make sure to call gather_user in a before_action
    redirect_to(login_path, alert: 'Please log in!') unless Current.user
  end

  def gather_user
    return if session[:user_id].blank?
    Current.user ||= User.find_by(id: session[:user_id]) # this assignment is the key difference
  end
end

Alternate approaches

  • Devise is one of the most commonly used gems to facilitate authentication. It includes multiple features such as persistence, password forgotten and different login providers to name just a few. If you do not roll your own solution, it is likely that you end up here.
  • Ruby-Toolbox provides a list of more gems

Next Steps

I have no idea how the above proposal fits with other initiatives of the documentation project The first step would certainly put all goals on the table and to discuss. It would be nice to shape a clear distinction between the individual initiatives/outcomes.

As for the pattern website, we could:

  • Interview Rails teachers & talk to team lead who train (junior) teams
  • Get clarity on the vision for the platform (strategically)
  • Identifying most needed and most repeated functionalities/patterns
  • Choose a format and medium to deliver the content
  • Decide on content structure and UX
  • Draft the technical content
  • Design website/realize screencasts/...
  • Setup up a technical advisory team and validate the content with them/the community
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment