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 nameduser_1
anduser_2
. -
Conversation
has manymessages
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 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 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 important thing is to save the message in database if during 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.
-
First create a route in
config/routes.rb
in root as:
mount ActionCable.server => "/cable"
-
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"
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.Also set
config/cable.yml
according to your production server settings.
Run The WebSocket
Final run the rails server and the websocket will be available on the:
ws://{host+port}/cable?token={TOKEN}
Open the connection by some websocket test client and then send request:
{
"command": "subscribe",
"identifier": {
"channel": "ConversationChannel"
}
}
This command will subscribe to 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 of 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!
Top comments (3)
Hi! When you generate a Rails app --api-only, you don't get ActionCable set, don't you? How would you set it up?
I didn’t need action cable in the project. I will do in another tutorial of action cable using as api with redis. just action cable has to be pinged again and again as api connection gets closed unlike normal rails where page is continuously connected with the client.
I tried SSE with Rails -api only- and couldn't make the Redis publish/subscribe pattern to work. Yes, your solution will be nice.