Asynchronous Chat With Rails and ActionCable

Ilya Bodrov
👁️ 3,772 views
💬 comments

Rails 5 has introduced many new cool features and ActionCable is probably the most anticipated one. To put it simply, ActionCable is a framework for real-time configuration over web sockets. It provides both client-side (JavaScript) and server-side (Ruby) code and so you can craft sockets-related functionality like any other part of your Rails application. I really like this new addition and recommend giving it a shot.

There are a handful of introductory tutorials on the Internet explaining how to get stated with ActionCable, however students often ask me how to introduce file uploading functionality over web sockets. This topic is not really covered anywhere, so I decided to research it myself.

Table of Contents

    In this two-parted tutorial we will create a basic chat application powered by Rails 5.1 and ActionCable with the ability to upload files. We will utilize Clearance for authentication as well as Shrine and FileReader API for file uploading.

    The source code for this article is available at GitHub. The final application will look like this:

    In the first part of the tutorial we are going to create a new application, introduce basic authentication, integrate ActionCable and utilize ActiveJob for the broadcasting. Shall we proceed?

    Laying Foundations

    Start off by creating a new Rails application:

    rails new ActionCableUploader

    At the time of writing the newest version of Rails was 5.1.1, so I am going to use it for this demo. Please note that ActionCable is not included in Rails 4 and older.

    We will require a basic authentication system. To speed things up, we are not going to write it from scratch but rather use some third-party solution. The most obvious choice that comes to mind is probably Devise but let's make things a bit more interesting and use another solution called Clearance. This gem is similar to Devise, but is intended to be smaller and simpler. After all, we really do need something simple, as this article is not about authentication solutions. Clearance was created by Thoughtbot, the guys who brought us Paperclip, FactoryGirl and other great solutions.

    So, drop a new gem into the Gemfile:

    gem 'clearance', '~> 1.16'

    and then run:

    bundle install
    rails generate clearance:install

    The latter command is going to equip your application with the Clearance's code. It is going to perform the following operations:

    • Create a User model and the corresponding migration. If you already have a model with such name, it will be tweaked properly
    • Create an initializer file for Clearance. You are welcome to check it out and modify as needed
    • Insert a Clearance::Controller module into the ApplicationController
    • Make you a coffee (well, actually it won't)

    When you are ready, apply the migration:

    rails db:migrate

    That's it, the preparations are done and we can move to the next section!

    Adding Chat Page

    What I want to do now is create the chat page and restrict access to it. The corresponding controller will be called ChatsController. Add a root route now:

    # config/routes.rb
    root 'chats#index'

    Don't forget to create the controller itself:

    # controllers/chats_controller.rb
    class ChatsController < ApplicationController
      before_action :require_login
    
      def index
      end
    end

    The before_action :require_login line, as you've probably guessed, restricts access to all actions of the current controller. This action does pretty much the same as the authenticate_user! method in Devise.

    Now create a views/chats/index.html.erb view that will have only a header for now:

    <h1>Demo Chat</h1>

    Lastly, populate your application layout with the following contents to display a sign out link and flash messages (if any):

    <!-- views/layouts/application.html.erb -->
    <% if signed_in? %>
      Signed in as: <%= current_user.email %>
      <%= button_to 'Sign out', sign_out_path, method: :delete %>
    <% else %>
      <%= link_to 'Sign in', sign_in_path %>
    <% end %>
    
    <div id="flash">
      <% flash.each do |key, value| %>
        <%= tag.div value, class: "flash #{key}" %>
      <% end %>
    </div>

    Now start the server and navigate to http://localhost:3000. You should see a page similar to this one. Note that you are automatically redirected to the Sign In page as you are not yet logged in.

    Register with some sample credentials—after that you should be able to see the chat page which means everything is working just fine.

    Messages

    Now let's create a new model and the corresponding table. I'll call the model Message which is quite an unsuprising name. It will have a body and a foreign key to establish an association to the users table:

    rails g model Message user:belongs_to body:text
    rails db:migrate

    Make sure that your models have the proper associations set up, as we want each message to have an author (that is, a user):

    # models/user.rb
    has_many :messages, dependent: :destroy
    # models/message.rb
    belongs_to :user

    Brilliant. Next, of course, we'll need a form to actually send a message. To render it, I am going to use a new helper method called form_with introduced in Rails 5 which is meant to replace form_for and form_tag (though the latter methods are still supported). The form will be processed by JavaScript, so I'll use # for the URL. Place the following code into your views/chats/index.html.erb view:

    <div id="messages">
      <%= render @messages %>
    </div>
    
    <%= form_with url: '#', html: {id: 'new-message'} do |f| %>
      <%= f.label :body %>
      <%= f.text_area :body, id: 'message-body' %>
      <br>
      <%= f.submit %>
    <% end %>

    Note that the form_with does not generate any ids for the tags so I am adding them manually to further select these elements using JS.

    I've also provided the #messages block to render all the messages. This requires the views/messages/_message.html.erb partial to be present, so add it now:

    <div class="message">
      <strong><%= message.user.email %></strong> says:
      <%= message.body %>
      <br>
      <small>at <%= l message.created_at, format: :short %></small>
      <hr>
    </div>

    Lastly, load the messages inside the index action:

    # chats_controller.rb
    def index
      @messages = Message.order(created_at: :asc)
    end

    This will sort them by creation date, ascending. Your chat page will look something like this: Okay, now it is time for the ActionCable to step into the limelight.

    ActionCable: Time for Action!

    Our next task is to enable real-time conversation between the client and the server powered by the ActionCable's magic. Let's start with adding some configuration:

    # config/environments/development.rb
    config.action_cable.url = 'ws://localhost:3000/cable'
    config.action_cable.allowed_request_origins = [ 'http://localhost:3000', 'http://127.0.0.1:3000' ]

    Next, a route:

    # routes.rb
    # ...
    mount ActionCable.server => '/cable'

    Lastly, meta tags:

    <!-- views/layouts/application.html.erb -->
    <!-- ... -->
    <%= action_cable_meta_tag %>
    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
    <!-- ... -->

    Now let's take care of the client and write some CoffeeScript code. Create a new file app/assets/javascripts/channels/chat.coffee with the following contents:

    jQuery(document).on 'turbolinks:load', ->
      $messages = $('#messages')
      $new_message_form = $('#new-message')
      $new_message_body = $new_message_form.find('#message-body')
    
      if $messages.length > 0
        App.chat = App.cable.subscriptions.create {
          channel: "ChatChannel"
          },
          connected: ->
    
          disconnected: ->
    
          received: (data) ->
    
          send_message: (message) ->

    Here we are checking if the #messages block is present on the page and, if yes, set up a new subscription to the ChatChannel. This channel will be used to communicate with the server in real time. Note that there are a bunch of callbacks that you can use: connected, disconnected and received. send_message will be used to actually forward the messages to the server. This new file will be loaded automatically as javascripts/cable.coffee requires the channels folder by default.

    One thing to note, however, is that Rails 5.1 apps do not include jQuery as a dependency anymore, so you'll need to add it yourself:

    # Gemfile
    gem 'jquery-rails

    Run:

    bundle install

    and include jQuery to the javascripts/application.js file:

    //= require jquery3

    I am including the latest version of jQuery to support only the modern browsers, but you can also choose versions 1 or 2.

    Now we need to listen for the form submit event, prevent the default action and call the send_message method defined for the channel instead:

    jQuery(document).on 'turbolinks:load', ->
        # ...
        if $messages.length > 0
            # ...
            $new_message_form.submit (e) ->
              $this = $(this)
              message_body = $new_message_body.val()
              if $.trim(message_body).length > 0
                App.chat.send_message message_body
    
              e.preventDefault()
              return false

    Here I am checking if the body has at least one character and call the send_message method if it is true. Nothing complex going on here.

    Next, flesh out the send_message method. What it needs to do is receive the body of the message and forward if to the server where it will be stored to the database. Note that this method will not output anything to the page—it should happen inside the received callback.

    # ...
    send_message: (message) ->
      @perform 'send_message', message: message

    @ means this in CoffeeScript. 'send_message' argument is the name of the method to call on the server-side which we will create in a minute.

    Lastly, code the received callback to clear the textarea and render a new message:

    # ...
    received: (data) ->
      if data['message']
        $new_message_body.val('')
        $messages.append data['message']

    That's it, we have finished coding the client-side! The server-side awaits, so proceed to the next section.

    ActionCable: Server-Side

    If you have played The Witcher series, you know that the sword of destiny has two edges. So as ActionCable. Therefore, let's take of the server-side now.

    Create a new app/channels/chat_channel.rb file that will process the messages sent from the client-side:

    class ChatChannel < ApplicationCable::Channel
      def subscribed
        stream_from "chat_channel"
      end
    
      def unsubscribed
      end
    
      def send_message(data)
      end
    end

    There are two callbacks here that are run automatically: subscribed (that runs as soon as the new client subscribes to the channel using App.cable.subscriptions.create code we've written a moment ago) and unsubscribed. send_message is the method that is called by the following line of code in our chat.coffee file:

    @perform 'send_message', message: message

    Note, by the way, that the files inside the app/channels directory are not auto-reloaded (even in development environment), so you must restart the server after modifying them.

    The data local variable contains a hash so we can access the message's body quite easily to save it to the database:

    # ...
    def send_message(data)
      Message.create(body: data['message'])
    end

    There is a problem, however: we don't have access to the Clearance's current_user method from inside the channel's code, therefore it is not possible to enforce authentication and associate the created message to a user.

    To fix this problem, the current_user should be defined manually. We are going to employ the methods similar to the ones provided in the Clearance's session.rb file:

    # app/channels/application_cable/connection.rb
    
    module ApplicationCable
      class Connection < ActionCable::Connection::Base
        identified_by :current_user
    
        def connect
          self.current_user = find_current_user
          reject_unauthorized_connection unless self.current_user
        end
    
        private
    
        def find_current_user
          if remember_token.present?
            @current_user ||= user_from_remember_token(remember_token)
          end
    
          @current_user
        end
    
        def cookies
          @cookies ||= ActionDispatch::Request.new(@env).cookie_jar
        end
    
        def remember_token
          cookies[Clearance.configuration.cookie_name]
        end
    
        def user_from_remember_token(token)
          Clearance.configuration.user_model.find_by(remember_token: token)
        end
      end
    end

    The following code simply tries to find a currently logged in user by a remember token stored in the cookie (the cookie's name is taken from the Clearance configuration). The user is then assiged to the self.current_user. If, however, the user cannot be found, we reject connection effectively disallowing to communicate using the channel. The connect method is called automatically each time someone tries to subscribe to a channel, so there nothing else we need to do here.

    Now return to the ChatChannel and tweak the send_message method a bit:

    # ...
    def send_message(data)
      current_user.messages.create(body: data['message'])
    end

    At this point our ActionCable setup is finished. Later you can add other channels using the same principle. There is, however, one last thing to do (yeah, there is always "one last thing", isn't it?). After the message is stored in the database, it should be broadcasted to all users who are subscribed to the channel. The client code will then run the received callback and render the new message. So, let's do it now!

    Callback and (Very) ActiveJob

    We are going to employ a model callback to broadcast a newly created message. However, I'd like to perform this task in background, therefore using the ActiveJob for this task seems like a good idea as well.

    Here is the code for the Message model:

    # models/message.rb
    class Message < ApplicationRecord
      belongs_to :user
    
      validates :body, presence: true
    
      after_create_commit :broadcast_message
    
      private
    
      def broadcast_message
        MessageBroadcastJob.perform_later(self)
      end
    end

    First of all, I've added a very basic validation rule to ensure the body is present. Next, there is a new after_create_commit callback that runs only after the commit was performed. Inside the corresponding method we are queueing the broadcasting job while passing self as an argument. self in this case points to the created message. Using the background job here is convenient because later you may extend it and, for example, send notification emails to the users saying that there is a new message waiting for them.

    The background job itself is quite simple:

    # app/jobs/message_broadcast_job.rb
    class MessageBroadcastJob < ApplicationJob
      queue_as :default
    
      def perform(message)
        ActionCable.server.broadcast 'chat_channel', message: render_message(message)
      end
    
      private
    
      def render_message(message)
        MessagesController.render partial: 'messages/message', locals: {message: message}
      end
    end

    We queue the job with a default priority. Inside the perform action we broadcast the message rendered by the MessagesController. Note that the same partial created earlier is utilized here.

    The MessagesController does not exist, so create it now:

    # app/controllers/messages_controller.rb
    class MessagesController < ApplicationController
    end

    And that's it! Our messages are now saved and broadcasted properly, so you can boot the server, navigate to the main page of the site and try to chat with yourself. Note that after you load the page, a request to the /cable will be performed (you may observe it using Firebug or a similar tool):

    To make process a bit more interesting you may open two separate browser windows and note that messages appear in both of them nearly instantly:

    Inside the terminal you should see an output like this:

    Conclusion

    This ends the first part of the tutorial. We have crafted the real-time chatting application that can now be extended quite easily. Throughout the article you have learned how to:

    • Integrate Clearance gem
    • Code the client-side for ActionCable
    • Code the server-side for ActionCable
    • Enforce server-side authentication
    • Use ActiveJob to broadcast messages

    In the second part we will finalize this application and allow the users to upload files via ActionCable with the help of the Shrine gem and FileReader Web API.

    So, stay tuned and see you soon!

    Ilya Bodrov

    3 posts

    Ilya Bodrov is personal IT teacher, consulting developer, author and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails), Elixir and JavaScript. He enjoys coding, teaching people and learning new things. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.