Long running requests using Action Cable

Viewed 130

I have created some code I would like to share with the world. Specifically with the people who want to respond to long running requests without blocking their servers.

Example usage is creating PDF's or CSV files.

First of all I created a a new Action Cable channel named long_running_requests.

app/channels/long_running_requests_channel.rb

class LongRunningRequestsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "long_running_request_#{connection.session_id}"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

As you can see, we've got a variable in the channel name connection.session_id. This is defined in the following file.

app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      # code
    end

    def session_id
      @request.session.id
    end

    protected

    def find_verified_user
      # code
    end
  end
end

This means we can give every user (logged in or not) a unique channel to push data using their session. This will become the base of our long running request response.

We create a button that will request the creation of a PDF, which might take a couple of seconds.

app/views/welcome/index.html.erb

<%= long_running_request_button(
  url: [:download_pdf, :welcome],
  request_title: (t 'pdf_download_button_title'), # 'Generate PDF'
  requested_title: (t 'pdf_generating_button_title'), # 'Generating PDF' (with a spinner)
  download_title: (t 'pdf_generated_button_title'), # 'Download PDF'
  new_tab: true)
%>

The actual code of this method comes from my ApplicationHelper.

app/helpers/application_helper.rb

def long_running_request_button(url:, request_title:, requested_title:, download_title:, new_tab: false)
  request_uid = SecureRandom.hex
  # include a random uid into the buttons. We do this so we can have many download-buttons on one page.
  request_button_id = "request_button_#{session.id}_#{request_uid}"
  requested_button_id = "requested_button_#{session.id}_#{request_uid}"
  download_button_id = "download_button_#{session.id}_#{request_uid}"

  # add params to the given URL
  url += [{ request_button_id: request_button_id, requested_button_id: requested_button_id, download_button_id: download_button_id, content_disposition: (new_tab ? 'inline' : 'attachment') }]

  # generate the request button with a title and some javascript
  # will be shown from the start
  request_button = link_to url, remote: true, onclick: "longRequestButtonClicked('#{request_button_id}', '#{requested_button_id}')", class: 'button small blue', id: request_button_id do
    # the title
    a = content_tag :span do
      request_title
    end
    a
  end

  # generate the requestED button with a spinner and a title, will do nothing else but show the user we're doing something
  # will be shown only when request_button has been clicked
  requested_button = link_to '#', onclick: 'return false;', disabled: true, class: 'button small dark disabled hide-it', id: requested_button_id do
    # the spinner
    a = content_tag :span, class: 'icon spinner light' do
      ''
    end
    # the title
    b = content_tag :span do
      requested_title
    end
    a + b
  end

  # generate the actual download button with an icon and a title
  # will be shown when response came back long_running_requests channel
  download_button = link_to '#', class: 'button small blue hide-it', target: (new_tab ? '_blank' : ''), id: download_button_id do
    # the icon
    a = content_tag :span, class: 'icon download light' do
      ''
    end
    # the title
    b = content_tag :span do
      download_title
    end
    a + b
  end

  # return all 3 buttons
  request_button + requested_button + download_button
end

When the button is clicked, longRequestButtonClicked is called. All this does is hide the clicked button and show a spinner that says we're generating the PDF.

function longRequestButtonClicked(requestButtonId, requestedButtonId) { 
  requestButton = document.querySelector(`#${requestButtonId}`); 
  requestedButton = document.querySelector(`#${requestedButtonId}`);
  // hide the request_button
  requestButton.classList.add('hide-it'); 
  // show the requested_button
  requestedButton.classList.remove('hide-it'); 
}

In the controller that is receiving our request, this is the called method.

app/controllers/welcome_controller.rb

def download_pdf
  # do your authentication

  Resque.enqueue(GenerateAndSendPdf, {
    content_disposition: params[:content_disposition],
    session_id: session.id.to_s,
    request_button_id: params[:request_button_id],
    requested_button_id: params[:requested_button_id],
    download_button_id: params[:download_button_id],
  })

  head :ok
end

It puts the generation of the PDF on a Resque background job and responds with a head :ok.

The Resque job will create and upload the PDF and transmit the data to the channel where the user is connected to. It also transmits the correct button ids that came from the request. (again) We do this so we can have many download-buttons on one page.

app/jobs/generate_and_send_pdf.rb

class GenerateAndSendPdf
  # I run on Heroku
  extend Resque::Plugins::Heroku
  @queue = :action_cable_broadcast

  def self.perform(hash)
    pdf = # your PDF generation
    pdf_url = # your PDF URL from uploading it somewhere
    
    session_id = hash["session_id"]
    channel_name = "long_running_request_#{session_id}"

    ActionCable.server.broadcast channel_name, {
      download_url: pdf_url,
      request_button_id: hash["request_button_id"],
      requested_button_id: hash["requested_button_id"],
      download_button_id: hash["download_button_id"],
    }
  end
end

The long_running_requests channel will handle the broadcast.

app/assets/javascripts/channels/long_running_requests.js

if ($('meta[name=action-cable-url]').length) {
  App.messages = App.cable.subscriptions.create('LongRunningRequestsChannel', {
    connected: function() {
    },
    disconnected: function() {
    },
    received: function(data) {
      downloadUrl = data['download_url']
      requestButtonId = data['request_button_id']
      requestedButtonId = data['requested_button_id']
      downloadButtonId = data['download_button_id']

      requestedButton = document.querySelector(`#${requestedButtonId}`);
      // hide the requested_button
      requestedButton.classList.add('hide-it');

      downloadButton = document.querySelector(`#${downloadButtonId}`);
      // show the download_button
      downloadButton.classList.remove('hide-it');
      downloadButton.href = downloadUrl;
    }
  });
}

And there you go. The actual download button is now visible and has an href to the uploaded document URL. Clicking it will download the PDF.

example

One question I have for you guys is: Is this safe?

Thank you.

0 Answers
Related