Action Cable for Rails API

Normally action cable documentation comes with web page connection. Here I explain to use action cable authentication and connection using API so that mobile apps etc can also use this websocket.

Action Cable for Rails API

Rails 5 introduced us to action cable that is a web socket for rails.

ActionCable can be used for chats, synced editing app, or real-time app notifications.

When creating a new rails API or app, action cable comes builtin the app.

There are many articles on rails JS client and server action cable but not action cable the only server for rails API where the client can be any front end mobile app or web app.

Prerequisites for the Rails API

Assumptions for this article are:

  • Ruby 2.6.3
  • Rails 6.0.0 (API Only)
  • JWT user authentication implemented in the project
  • Logged-In user can be identified as current_user in the app.
  • There is a model named Conversation with two users named user_1 and user_2.
  • Conversation has many messages

Connecting Action Cable

In app/channels/connection.rb write a line:

identified_by :current_user

Make a private method of named find_verified_user it finds the current user and returns it else it returns reject_unauthorized_connection. For example:

def find_verified_user token
  session = Session.find_by(token: token)
  unless session.nil?
    return session.user
  else
    return reject_unauthorized_connection
  end
end

Lastly, We are assuming that user token is coming in request as query params, and we can write public method of connect:

def connect
  self.current_user = find_verified_user request.params[:token]
  logger.add_tags 'ActionCable', current_user.id
end

This connect method searches for the logged-in user with right token or else rejects the connection to the socket.

Subscribing the Conversation Channel

In terminal write rails g channel Conversation

Add a method subscribed, this method will get user to subscribe to the streams it is allowed to.

def subscribed
  stop_all_streams
  Conversation.where(user_1: current_user).or(Conversation.where(user_2: current_user)).find_each do |conversation|
      stream_from "conversations_#{conversation.id}"
  end
end

Also create a method named unsubscribed to unsubscribe the user to all its streams:

def unsubscribed
  stop_all_streams
end

Send a message, register it and receive by other users

Now in conversation_channel.rb create a method receive that will be called when cable receives a message. This message will then be saved to database, and then it will be broadcasted to the stream with conversation ID so that the other person can receive in realtime.

def receive(data)
  @conversation = Conversation.find(data.fetch("conversation_id"))
  if (@conversation.user_1_id == current_user.id) || (@conversation.user_2_id == current_user.id)
      message_done = @conversation.messages.build(user_id: current_user.id)
      message_done.body = data["body"].present? ? data.fetch("body") : nil
    if message_done.save
      MessageRelayJob.perform_later(message_done)
    end
  end
end

Here, The message is received and if the data is correct, then will be saved to database. Now a background service is called for sending this data back to socket in broadcast so that all the users in the subscribed streams get the message in realtime.

The meessage_relay_job.rb will be like:

class MessageRelayJob < ApplicationJob
  queue_as :default

  def perform(message)
    data = {}
    data["id"] = message.id
    data["conversation_id"] = message.conversation_id
    data["body"] = message.body
    data["user_id"] = message.user_id
    data["created_at"] = message.created_at
    data["updated_at"] = message.updated_at
    ActionCable.server.broadcast "conversations_#{message.conversation_id}", data.as_json
  end
end

Message Relay job is sent as background job is because the important thing is to save the message in database if during the job any connection issue comes the process can get halted. So, even if the process is halted, the other user can get message after refreshing the conversation and getting messages by REST or GraphQL.

Final Settings

Now that our conversation channel is ready for deployment. There are some settings to be made in the app.

  1. First create a route in config/routes.rb in root as:
    mount ActionCable.server => "/cable"
  2. As the action cable to be used by API and client is not on rails, go to config/application.rb and include:
    config.action_cable.disable_request_forgery_protection = true
    config.action_cable.url = "/cable"
  3. Install redis gem in the Gemfile as production can be only run in Redis server and also make sure server has redis installed and configured.
  4. Also set config/cable.yml according to your production server settings.

Run The WebSocket

Final run the rails server and the web-socket will be available on the:

ws://{host+port}/cable?token={TOKEN}

Open the connection by some web-socket test client and then send request:

{
	"command": "subscribe",
	"identifier": {
		"channel": "ConversationChannel"
	}
}

This command will subscribe to the conversation stream in which it is authenticated to.

To send the message write:

{
	"command": "message",
	"data": {
		"body": "Hello World",
		"conversation_id": 1,
		"action": "receive"
	},
	"identifier": {
		"channel": "ConversationChannel"
	}
}

The message will be saved and will get receive to all the subscribed user of the stream.

Unit Testing with RSpec

If you use RSpec Testing, then create a file spec/channels/connection_spec.rb

require "rails_helper"

RSpec.describe ApplicationCable::Connection, :type => :channel do
  it "rejects if user not logged in" do
    expect{ connect "/cable" }.to have_rejected_connection
    expect{ connect "/cable?token=abcd" }.to have_rejected_connection
  end
  it "successfully connects" do
    session = FactoryBot.create(:session)
    conversation = FactoryBot.create(:conversation, user_1_id: session.user_id)
    token = JsonWebToken.encode(user_id: session.user_id, token: session.token).to_s
    connect "/cable?token=#{token}"
    expect(connection.current_user).to eq session.user
  end
end

Next create a file spec/channels/conversation_channel_spec.rb

require "rails_helper"

RSpec.describe ConversationChannel, type: :channel do
  it "successfully subscribes" do
    session = FactoryBot.create(:session)
    conversation = FactoryBot.create(:conversation, user_1_id: session.user_id)
    stub_connection current_user: session.user
    subscribe
    expect(subscription).to be_confirmed
    expect(subscription.current_user).to eq session.user
  end

  it "successfully sends message" do
    session = FactoryBot.create(:session)
    conversation = FactoryBot.create(:conversation, user_1_id: session.user_id)
    stub_connection current_user: session.user
    subscribe
    last_count = Message.count
    perform :receive, { body: "lorem ipsum doler", conversation_id: conversation.id, attachment_id: nil }
    expect(Message.count).to eql last_count + 1
  end
end

The Test should run fine.

Happy Coding!