Aprenda / Ruby on Rails

IntermediárioAula 20 de 2026 min0% da trilha

Projeto guiado: evoluindo a API de Pedidos

A mesma API de Pedidos da lição de fundamentos ganha paginação, envelope de resposta, job assíncrono e teste de request — fechando fundamentos e intermediário juntos.

O projeto guiado de fundamentos deixou o recurso Pedido com scaffold, validação, Ability, service, serializer e controller autenticado/autorizado. Agora cada peça evolui com o que as lições intermediárias acrescentaram: index com paginação, resposta em envelope com erro tratado centralmente, o service devolvendo um Resultado em vez de true/false e disparando um job assíncrono, e um teste de request cobrindo o fluxo inteiro.

1. Paginação no index

app/controllers/pedidos_controller.rb (index)
class PedidosController < ApplicationController
  include Pagy::Backend

  before_action :authenticate_usuario!
  load_and_authorize_resource

  def index
    pagy, pedidos = pagy(current_usuario.pedidos.order(created_at: :desc))
    render json: {
      data: PedidoSerializer.new(pedidos).serializable_hash[:data],
      meta: { pagina: pagy.page, total_paginas: pagy.pages, total_registros: pagy.count }
    }
  end
end

2. Envelope de resposta e rescue_from central

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  rescue_from ActiveRecord::RecordNotFound, with: :registro_nao_encontrado
  rescue_from CanCan::AccessDenied, with: :acesso_negado

  private

  def registro_nao_encontrado(excecao)
    render json: { error: { codigo: "nao_encontrado", mensagem: excecao.message } }, status: :not_found
  end

  def acesso_negado(_excecao)
    render json: { error: { codigo: "acesso_negado", mensagem: "Você não tem permissão para essa ação" } },
           status: :forbidden
  end
end

3. Service com Resultado, disparando job assíncrono

app/services/resultado.rb
class Resultado
  attr_reader :erros, :pedido

  def initialize(sucesso:, pedido: nil, erros: [])
    @sucesso = sucesso
    @pedido = pedido
    @erros = erros
  end

  def sucesso?
    @sucesso
  end
end
app/services/finalizar_pedido.rb
class FinalizarPedido
  def initialize(pedido)
    @pedido = pedido
  end

  def call
    return Resultado.new(sucesso: false, erros: ["Pedido sem itens"]) unless @pedido.itens.any?

    ActiveRecord::Base.transaction do
      @pedido.update!(status: :finalizado)
      @pedido.itens.each { |item| item.produto.decrement!(:estoque, item.quantidade) }
    end

    PedidoMailer.confirmacao(@pedido).deliver_later   # roda fora do request, via Solid Queue

    Resultado.new(sucesso: true, pedido: @pedido)
  end
end

"Pedido sem itens" continua sendo um resultado esperado, não uma exception — e o envio do e-mail de confirmação não bloqueia mais a resposta, porque roda em background depois que a transação já foi confirmada.

4. Controller final — todas as peças juntas

app/controllers/pedidos_controller.rb (completo)
class PedidosController < ApplicationController
  include Pagy::Backend

  before_action :authenticate_usuario!
  load_and_authorize_resource

  def index
    pedidos = current_usuario.pedidos.order(created_at: :desc)
    pedidos = pedidos.where(status: params[:status]) if params[:status].present?
    pagy, pedidos_paginados = pagy(pedidos)
    render json: {
      data: PedidoSerializer.new(pedidos_paginados).serializable_hash[:data],
      meta: { pagina: pagy.page, total_paginas: pagy.pages, total_registros: pagy.count }
    }
  end

  def create
    pedido = current_usuario.pedidos.new(pedido_params)
    if pedido.save
      render json: { data: PedidoSerializer.new(pedido).serializable_hash[:data] }, status: :created
    else
      render json: { error: { codigo: "invalido", mensagens: pedido.errors.full_messages } },
             status: :unprocessable_entity
    end
  end

  def finalizar
    resultado = FinalizarPedido.new(@pedido).call
    if resultado.sucesso?
      render json: { data: PedidoSerializer.new(resultado.pedido).serializable_hash[:data] }
    else
      render json: { error: { codigo: "invalido", mensagens: resultado.erros } }, status: :unprocessable_entity
    end
  end

  private

  def pedido_params
    params.require(:pedido).permit(:total)
  end
end

5. Teste de request cobrindo o fluxo de finalizar

spec/requests/pedidos_spec.rb
RSpec.describe "Pedidos", type: :request do
  describe "POST /pedidos/:id/finalizar" do
    it "finaliza o pedido e enfileira o e-mail de confirmação" do
      usuario = Usuario.create!(email: "ana@exemplo.com", password: "12345678")
      pedido = usuario.pedidos.create!(total: 150)
      pedido.itens.create!(produto: Produto.create!(nome: "Teclado", preco: 100, estoque: 5), quantidade: 1)

      expect {
        post "/pedidos/#{pedido.id}/finalizar", headers: { "Authorization" => "Bearer #{token_para(usuario)}" }
      }.to have_enqueued_mail(PedidoMailer, :confirmacao)

      expect(response).to have_http_status(:ok)
      expect(pedido.reload.status).to eq("finalizado")
    end

    it "devolve erro quando o pedido não tem itens" do
      usuario = Usuario.create!(email: "bia@exemplo.com", password: "12345678")
      pedido = usuario.pedidos.create!(total: 0)

      post "/pedidos/#{pedido.id}/finalizar", headers: { "Authorization" => "Bearer #{token_para(usuario)}" }

      expect(response).to have_http_status(:unprocessable_entity)
    end
  end
end

have_enqueued_mail confirma que o job de e-mail foi enfileirado — não que ele foi entregue. A entrega de fato roda depois, fora do ciclo do teste, processada pelo worker do Solid Queue; o request spec só precisa garantir que a chamada certa foi enfileirada.

Exercício

O index acima já filtra por status. Adicione também um filtro opcional por data mínima (?desde=2026-01-01), combinado com o filtro de status e a paginação já existentes, sem interpolar nada direto numa string SQL.

Quiz rápido

Depois de adicionar paginação e envelope ao index, o que a resposta passa a incluir além dos dados dos pedidos?
No teste de request de finalizar, por que verificar que o e-mail foi enfileirado (have_enqueued_mail) em vez de checar se ele foi realmente entregue?