Usando responders no Rails


Leia em 4 minutos

O Responder é uma funcionalidade que foi introduzida no Rails 3.0. Com ele é possível abstrair o modo como nossos controllers devem responder às requisições feitas na aplicação. Veja o controller abaixo:

class TasksController < ApplicationController
  def index
    @tasks = Task.all

    respond_to do |format|
      format.html # index.html.erb
      format.json { render json: @tasks }
    end
  end

  def show
    @task = Task.find(params[:id])

    respond_to do |format|
      format.html # show.html.erb
      format.json { render json: @task }
    end
  end

  def new
    @task = Task.new

    respond_to do |format|
      format.html # new.html.erb
      format.json { render json: @task }
    end
  end

  def edit
    @task = Task.find(params[:id])
  end

  def create
    @task = Task.new(params[:task])

    respond_to do |format|
      if @task.save
        format.html { redirect_to @task, notice: 'Task was successfully created.' }
        format.json { render json: @task, status: :created, location: @task }
      else
        format.html { render action: "new" }
        format.json { render json: @task.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    @task = Task.find(params[:id])

    respond_to do |format|
      if @task.update_attributes(params[:task])
        format.html { redirect_to @task, notice: 'Task was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: "edit" }
        format.json { render json: @task.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @task = Task.find(params[:id])
    @task.destroy

    respond_to do |format|
      format.html { redirect_to tasks_url }
      format.json { head :no_content }
    end
  end
end

Este controller foi gerado à partir de um scaffold, mas é muito comum vermos aplicações reais usarem algo muito próximo disso. O grande problema é que existe grandes chances de ter códigos semelhantes a este espalhado por diferentes controllers. Perceba como a chamada ao método respond_to é bastante semelhante em todas as actions.

Para resolver este problema, podemos usar o método respond_with, que abstrai a classe ActionController::Responder, fazendo com que nosso controller seja muito mais conciso e simples.

class TasksController < ApplicationController
  respond_to :html, :json

  def index
    @tasks = Task.all
    respond_with(@tasks)
  end

  def show
    @task = Task.find(params[:id])
    respond_with(@task)
  end

  def new
    @task = Task.new
    respond_with(@task)
  end

  def edit
    @task = Task.find(params[:id])
    respond_with(@task)
  end

  def create
    @task = Task.new(params[:task])
    flash[:notice] = 'Task was successfully created.' if @task.save
    respond_with(@task, :location => @task)
  end

  def update
    @task = Task.find(params[:id])
    flash[:notice] = 'Task was successfully updated.' if @task.update_attributes(params[:task])
    respond_with(@task, :location => @task)
  end

  def destroy
    @task = Task.find(params[:id])
    @task.destroy
    respond_with(nil, :location => tasks_path)
  end
end

Para poder usar o Responder, você deve especificar quais formatos um controller pode responder. Neste nosso caso, o controller irá responder aos formatos HTML e JSON. Isso foi feito com o método respond_to, com uma chamada realizada na classe ActionController::Base (lembre-se que ApplicationController herda da classe ActionController::Base).

O Responder irá verificar o estado do recurso (se foi salvo ou se possui erros) para saber como ele deve responder. Além disso, ele leva em consideração qual o tipo de formato que está sendo solicitado para saber se deve responder como um comportamento de navegação ou de API.

Mesmo a nossa nova e simplificada implementação de controller, ainda podemos reduzir um pouco mais a duplicação. Perceba como definimos a flash message baseado na resposta dos métodos ActiveRecord::Base#save e ActiveRecord::Base#update_attributes. Você poderia criar um novo Responder que lidasse com as flash messages dependendo do estado do recurso. Mas em vez de fazermos isso manualmente, nós iremos utilizar a gem responders, criada pelo José Valim.

Usando a gem responders

Para instalá-la, adicione a linha abaixo ao seu arquivo Gemfile.

gem "responders"

Execute o comando bundle install.

Agora, abra o arquivo app/controllers/application_controller.rb e defina os formatos que os controllers deverão responder por padrão. Nós também iremos definir quais responders nosso controller irá usar, através do método responders, adicionado pela gem.

class ApplicationController < ActionController::Base
  protect_from_forgery

  respond_to :html, :json
  responders :flash
end

Remova todas as definições de flash messages que existir no controller. Aproveite também para alterar o modo como os objetos são salvos/criados.

class TasksController < ApplicationController
  def index
    @tasks = Task.all
    respond_with(@tasks)
  end

  def show
    @task = Task.find(params[:id])
    respond_with(@task)
  end

  def new
    @task = Task.new
    respond_with(@task)
  end

  def edit
    @task = Task.find(params[:id])
    respond_with(@task)
  end

  def create
    @task = Task.create(params[:task])
    respond_with(@task, :location => @task)
  end

  def update
    @task = Task.find(params[:id])
    @task.update_attributes(params[:task])
    respond_with(@task, :location => @task)
  end

  def destroy
    @task = Task.find(params[:id])
    @task.destroy
    respond_with(nil, :location => tasks_path)
  end
end

Com a gem responders você tem suporte transparente a flash messages internacionalizadas.

en:
  flash:
    actions:
      create:
        notice: "%{resource_name} has been created."
        alert: "Double check your form before continuing."
      update:
        notice: "%{resource_name} has been updated."
        alert: "Double check your form before continuing."
      destroy:
        notice: "%{resource_name} has been removed."
        alert: "%{resource_name} couldn't be removed."

Se você precisar personalizar a mensagem, utilize o escopo flash.<controller>.action.<tipo>.

en:
  flash:
    tasks:
      create:
        notice: "This task has been created!"
      update:
        notice: "This task has been updated!"
      destroy:
        notice: "This task has been removed!"

Essa gem possui outros Responders bastante interessantes, então não deixe de dar uma olhada na documentação.

Usando um Responder personalizado

Quando você precisa sair um pouco do padrão utilizado pelo Responder, é muito comum voltarmos a usar o método respond_to. A ação abaixo sempre redireciona para um endereço, independente de o objeto ter sido salvo com sucesso ou não. Isso provavelmente aconteceria no caso de comentários, onde o formulário de criação será sempre exibido no relacionamento-pai, ou seja, no Post.

class CommentsController < ApplicationController
  def create
    @post = Post.find(params[:post_id])
    @comment = post.comments.create(params[:comment])

    respond_to do |format|
      format.html do
        if @comment.new_record?
          flash[:notice] = t("flash.comments.create.notice")
        else
          flash[:alert] = @comment.errors.full_messages.to_sentence
        end

        redirect_to @post
      end

      format.json
    end
  end
end

Funciona, mas é feio! Para resolver este problema podemos criar nosso próprio Responder, que irá abstrair apenas a lógica do formato HTML. Primeiro, crie um diretório app/responders. Se você estiver executando o servidor, lembre-se de reiniciá-lo. Depois, crie o arquivo app/responders/application_responder, que irá definir o comportamento padrão para todos os responders que criarmos.

class ApplicationResponder < ActionController::Responder
  include Responders::FlashResponder

  delegate :t, :flash, :to => :controller
end

A classe ActionController::Responder não implementa os métodos flash e t e, por isso, estamos fazendo a delegação para o objeto controller.

Agora, podemos criar nosso Responder personalizado, que será usado apenas na action create. Crie o arquivo app/responders/comments/create_responder.rb com o código abaixo:

module Comments
  class CreateResponder < ApplicationResponder
    def to_html
      if resource.new_record?
        flash[:notice] = t("flash.comments.create.notice")
      else
        flash[:alert] = @comment.errors.full_messages.to_sentence
      end

      redirect_to navigation_location
    end
  end
end

Só falta refatorar nosso controller, de modo que ele use este Responder. Como seu comportamento é específico da ação create, podemos especificá-lo com a opção :responder.

class CommentsController < ApplicationController
  def create
    @post = Post.find(params[:post_id])
    @comment = post.comments.create(params[:comment])

    respond_with(@comment, {
      :location => post_path(@post),
      :responder => Comments::CreateResponder
    })
  end
end

E para garantir que este responder esteja funcionando como esperado, escrevo testes de integração (que eu já faria mesmo sem usar o Responder), fazendo expectativas quanto a presença da mensagem baseado no estado do objeto.