What's the "Rails Way"? ActionCable channel vs controller - ruby-on-rails

In several tutorials (even one by David Heinemeier Hansson) you are encouraged to use a method inside the your x_channel.rb file to create a new message for instance.
It would look something like this (from David's tutorial):
class RoomChannel < ApplicationCable::Channel
def subscribed
stream_from "room_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def speak
Message.create! content: data['message']
end
end
He then uses the messages_controller.rb only to set up a after_create_commit for broadcasting the message to all subscribers.
The speak method is called by JavaScript.
Now, I was thinking, why use the channel at all for that? I could also just get rid of the speak method and instead put a create method inside the messages_controller.rb with something like this:
def create
Message.create! content: data['message']
end
(obviously a little more sophisticated than that but for the sake of demonstration). In the views I would then use a plain old form and an AJAX request to call the create method.
Now, I'm wondering which of the two options would be the best or the "Rails Way" of handling this.
Is it "Once you use a channel do everything in there"? Or rather "continue using controllers as much as you can. That's what they were built for"?

Related

How to call ActionCable method from controller or model - "uninitialized constant QuizSession::App" Error

I'm trying to write a simple quiz app with Ruby on Rails with 2 users, who get to see different views:
the quiz hoster view shows the current question and a "next question"-button (which he is supposed to project onto a wall for the audience) and
the participant view shows the 4 buttons with answer options corresponding to the current question (so the audience can participate in the quiz from their smartphones).
I'm at the point, where I'm trying to use ActionCable to broadcast these 4 answer buttons to my channel, but when I try calling the method I defined in my channel I get the error "uninitialized constant QuizSession::App".
These are the steps I've taken to enable ActionCable:
1) I generated a new channel in /app/channels/quiz_data_channel.rb:
class QuizDataChannel < ApplicationCable::Channel
def subscribed
stream_from "quiz_data"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def send_data
Services::QuizDataCreation.new(user: current_user).create
end
end
# /app/assets/javascripts/channels/quiz_data.coffee:
App.quiz_data = App.cable.subscriptions.create "QuizDataChannel",
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
# Called when there's incoming data on the websocket for this channel
$('answer-buttons').append(data.partial)
send_data: ->
#perform 'send_data'
2) Then I made a new service in app/models/services/quiz_data_creation.rb:
module Services
class QuizDataCreation
def initialize(user)
self.user = user
end
def create
create_quiz_data
broadcast_creation
end
private
attr_accessor :user, :answers
def create_quiz_data #not sure if this will work
#quiz_session = user.quiz_session
self.answers = #quiz_session.quiz.questions[#quiz_session.current_question_index].answers
end
def broadcast_creation
QuizDataBroadcastJob.perform_now(answers)
end
end
end
3) Then I generated a new job at /app/jobs/quiz_data_broadcast_job.rb:
class QuizDataBroadcastJob < ApplicationJob
queue_as :default
def perform(answers)
ActionCable.server.broadcast('quiz_data', data: render_answer_options(answers))
end
private
def render_answer_options(answers)
answers.each do |answer|
ApplicationController.render(
#render student/quiz_question page and render as many answer_option partials as needed
partial: 'pages/student/answer_option',
locals: {answer: answer}
)
end
end
end
4) I mounted ActionCable in my routes.rb:
mount ActionCable.server => '/cable'
5) And finally I'm trying to broadcast data by calling the send_data function elsewhere in my application:
def send_current_question
App.quiz_data.send_data <--- this is apparently where the error gets thrown
end
What I would like to know is:
How do I solve this error?
Is the problem that I haven't established the socket connection correctly?
I have read 3 ActionCable guides and watched 2 guide videos - since most of them seem to be about chat applications (which in my mind is a 1 to 1 connection) I am now thoroughly confused as to how to get my app to work in the one-to-many broadcasting fashion I need.
I'm new to Rails, so any pointers would be appreciated! :)
WebSockets are designed such that it doesn't matter whether you are building a one-to-one, many-to-one, one-to-many or many-to-many feature. The reason is as a Publisher to a channel, you do not know who is Subscribed to it, so whether it is just 1 or 1000 users, they will all receive the message that is published.
As for why you are getting the error, well the App object is a JavaScript object (see how you are accessing it in your coffeescript files), so you cannot use it in Ruby code.
The App object is not made available in your Ruby code (backend) because it is supposed to be a method for the client (user) to communicate with the server (backend), which is why it is initialized in your coffeescript code.
So you will need to call the send_data method by attaching a click handler to your button. Let's say in your html your button looks like:
<button id="broadcast-button">Broadcast</button>
In your client side code (coffeescript), you would do the following:
$('#broadcast-button').on 'click', (e) ->
App.quiz_data.send_data()
So when the user clicks on that button, the send_data method will be called.
As for why you are getting the error, well the App object is a JavaScript object (see how you are accessing it in your coffeescript files), so you cannot use it in Ruby code.
The App object is not made available in your Ruby code (backend)
because it is supposed to be a method for the client (user) to
communicate with the server (backend), which is why it is initialized
in your coffeescript code.
As #Laith Azer pointed out, it is not possible to call a CoffeeScript method (since that represents the frontend) from a controller or model, but all my CoffeeScript method send_data really does, is call its counterpart on the backend:
#/app/assets/javascripts/channels/quiz_data.coffee:
App.quiz_data = App.cable.subscriptions.create "QuizDataChannel",
...
send_data: ->
#perform 'send_data'
So what is possible, is to call this backend method directly:
#In some controller or model:
QuizDataChannel.send_data(current_user)
For that to work we need to change the method in our ActionCable channel to a class method:
#/app/channels/quiz_data_channel.rb:
class QuizDataChannel < ApplicationCable::Channel
def self.send_data(current_user) #self. makes it a class method
new_quiz_html = Services::QuizDataCreation.new(user: current_user).create
#change "some_room" to the room name you want to send the page on
ActionCable.server.broadcast("some_room", html: new_quiz_html)
end
end
And all that is left is to receive this html page and display it:
#/app/assets/javascripts/channels/quiz_data.coffee:
App.quiz_data = App.cable.subscriptions.create "QuizDataChannel",
...
send_data: ->
#perform 'send_data'
received: (data) ->
# This will replace your body with the page html string:
$('body').html(data.html)

How to access concerns and application controller code in Rails 5 Action Cable Channels

I have some code in my concerns that I want to call from Action Cable Channel.
Say I have notification channel and I want to call my concerns to update some
values and it does call the method but it does not call the another method
written in application controller.
Say,
In my channel.rb I am using:
class NotificationsChannel < ApplicationCable::Channel
include NotificationChannel
def some_method
create_notification(current_user, quiz)
end
end
NotificationChannel Concerns code:
module NotificationChannel
extend ActiveSupport::Concern
def create_notification(is_current_user, resource)
teacher_user_id = resource.lesson_plan.teacher.user_id
Notification.create(description: "A user with name #{get_user_name(is_current_user)} has liked the #{resource.title}", user_id: teacher_user_id, url: resource_misc_path(resource, "quiz_notification_like_check"))
end
end
Now,
1st problem:
The method get_user_name(is_current_user) is not accessible in concerns and
it is written in ApplicationController.
2nd problem:
resource_misc_path(resource, "quiz_notification_like_check" is written in some helper it does not call even helper method.
P.S: I have also noticed the routes having _path and _url are also not accessible in Channel.rb
Any suitable workaround?
There are some very useful answers on this topic here: How do I get current_user in ActionCable rails-5-api app?
The bottom line is in Cable there is no session, so you can't really use your session methods.
You have to rewrite them using directly the cookies.
As #Sajan says in his answer: "The websocket server doesn't have a session, but it can read the same cookies as the main app."

Rails 5 ActionCable establish stream from URL parameters

I am almost completely new to Rails, or I am sure I would know how to answer this question myself. I am just trying to modify the basic chat app created in the basic ActionCable demo: https://medium.com/#dhh/rails-5-action-cable-demo-8bba4ccfc55e#.6lmd6tfi7
Instead of having just one chat room, I want to have multiple chat rooms, so I modified my routes.rb by adding this line:
get '/rooms/show/:topic', to: 'rooms#show'
So now I can visit different chat rooms based on different topics. The rooms controller at /app/controllers/rooms_controller.rb handles these routes with no problem:
class RoomsController < ApplicationController
def show
#topic = params[:topic]
#messages = Message.all
end
end
But this parameter is not being passed to app/channels/room_channel.rb, and I'm just not sure what modifications I need to make. My current attempt:
class RoomChannel < ApplicationCable::Channel
def subscribed
stream_from "room_channel_#{params[:topic]}"
end
only returns "room_channel_"
The problem here was that I failed to understand from where the subscribed method was being called, and thus did not know how to pass parameters to it.
By reading the actioncable documentation: https://github.com/rails/rails/tree/master/actioncable
I found out that the subscribed method is called via client-side javascript, not by the rails controller. In the case of the example chat app, this means I had to change the first line of the file /app/assets/javascripts/channels/room.coffee
App.room = App.cable.subscriptions.create "RoomChannel",
to
App.room = App.cable.subscriptions.create { channel: "RoomChannel", topic: topic},
Passing a javascript object to this method allowed me to access those parameters in the subscribed method of rooms_controller.rb.
Set the topic_id in a HTML tag, maybe in the body tag in your layout file.
<body data-topic-id="<%= #topic.id %>">
Now read it from JS like this:
document.querySelector('body').dataset.topicId
Your subscription creation line would look like this:
App.room = App.cable.subscriptions.create (channel: 'RoomChannel', topic_id: document.querySelector('body').dataset.topicId)

How to convert this ActiveRecord::Observer to a Service object?

How would one convert the following ActiveRecord::Observer to a Service object (or maybe multiple objects)?
The PushService updates all connected browsers via WebSocket of all changes. It does this by POST-ing to an external process. Since migrating from Thread.new to Sidekiq, the observer broke. A Sidekiq job started in an :after_create can run before the transaction is actually committed, so it will raise an ActiveRecord::NotFound error.
It is recommended to use an :after_commit hook, but then information needed by the PushService such as record.changes will not be available.
The interesting use case that this observer fulfills is that when a new message is created, which is a reply to another message. It will automatically run two callbacks. An :after_create for the reply-message and an :after_touch for the thread-message.
I am interested to see how this behavior can be run by using an explicit service object.
class PushObserver < ActiveRecord::Observer
observe :message
def after_create(record)
Rails.logger.info "[Observe] create #{record.inspect}"
PushService.new(:create, record).publish
end
def after_update(record)
Rails.logger.info "[Observe] update #{record.changed.inspect}"
PushService.new(:update, record).publish
end
def after_touch(record)
Rails.logger.info "[Observe] touched #{record.changes.inspect}"
PushService.new(:touch, record).publish
end
def after_destroy(record)
Rails.logger.info "[Observe] destroy #{record.inspect}"
PushService.new(:destroy, record).publish
end
end
You can set up subscribers and listeners using a gem called wisper.
So in your PushService class, you would include Wisper::Publisher There is an exact example on the Github page for using this as a service. This way, the firing off of events will be taken care of the actual objects.
So whenever PushService does something that requires updating connected browsers
def some_action
#some code
publish(:done_something, self)
end
Then the Service object can be listening for this event with the subscribe method.

event trigger system design in rails

i'm on the way of redesigning my activity feed, i already implemented the logic with redis and rails (wich works great by the way) but i'm still unsure how to create/trigger the events.
In my first approach i used observer, which had the downside of not having current_user available. and, using observer is a bad idea anyways :)
My preferred method would be to create/trigger the events in the controller, which should look sth like:
class UserController < LocationController
def invite
...
if user.save
trigger! UserInvitedEvent, {creator: current_user, ...}, :create
....
end
end
end
The trigger method should
create the UserInvitedEvent with some params. (:create can be default option)
could be deactivate (e.g. deactivate for testing)
could be executed with e.g. resque
i looked in some gems (fnordmetrics, ...) but i could not find a slick implementation for that.
I'd build something like the following:
# config/initializers/event_tracking.rb
modlue EventTracking
attr_accessor :enabled
def enable
#enabled = true
end
def disable
#enabled = false
end
module_function
def Track(event, options)
if EventTracking.enabled
event.classify.constantize.new(options)
end
end
end
include EventTracking
EventTracking.enable unless Rails.env.test?
The module_function hack let's us have the Track() function globally, and exports it to the global namespace, you (key thing is that the method is copied to the global scope, so it's effectively global, read more here: http://www.ruby-doc.org/core-1.9.3/Module.html#method-i-module_function)
Then we enable tracking for all modes except production, we call event.classify.constantize in Rails that should turn something like :user_invited_event into UserInvitedEvent, and offers the possibility of namespacing, for example Track(:'users/invited'). The semantics of this are defined by ActiveSupport's inflection module.
I think that should be a decent start to your tracking code I've been using that in a project with a lot of success until now!
With the (new) rails intrumentation and ActiveSupport::Notifications system you can completely decouple the notification and the actual feed construction.
See http://railscasts.com/episodes/249-notifications-in-rails-3?view=asciicast

Resources