Capturando erro de parse com JSON inválido

Como capturar erro de parse com JSON inválido utilizando middleware do Rails


O problema

Imagine a seguinte situação:

Você possui uma aplicação com uma Api externa disponibilizada para os seus clientes, obviamente você não consegue controlar o conteúdo e nem o formato que serão enviados para os endpoints. Em um belo dia você se depara com um número crescente de erros 500 acontecendo devido ao parse do conteúdo passado no payload.

O erro é parecido com esse:

Error occurred while parsing request parameters.
Contents:

{
  invalid JSON
}

Entendendo o ocorrido

Esse erro é disparado nesta linha e ocorre todas as vezes em que não é possível realizar o parse do conteúdo enviado por uma requisição. O problema é que a requisição nem chega a acessar a action do controller já que o erro ActionDispatch::Http::Parameters::ParseError é disparado no Action Dispatch do Rails.

Solução

A ideia por trás da solução é criar um middleware para capturar o erro e retornar uma resposta para o usuário.

Um middleware necessita de dois métodos, initialize e call. Os middlewares são executados todas as vezes que uma requisição é recebida pela aplicação da seguinte forma: Primeiro o middleware é instanciado com a própria aplicação e em seguida é chamado o método call, que por final chamará o middleware seguinte.

Nosso middleware só precisa executar o método call e capturar o erro disparado ao tentar realizar o parse do JSON com erro.

Criaremos nossa classe do midleware em app/middleware/catch_json_parse_errors.rb:

# app/middleware/catch_json_parse_errors.rb

class CatchJsonParseErrors
  FAILSAFE_RESPONSE = [
    400, { 'Content-Type' => 'application/json' },
    [
      {
        status: 400,
        message: 'Parse error on payload body.'
      }.to_json
    ]
  ].freeze

  def initialize app
    @app = app
  end

  def call env
    @app.call(env)
  rescue ActionDispatch::Http::Parameters::ParseError => exception
    is_content_type_json?(env) ? FAILSAFE_RESPONSE : raise(exception)
  end

  private

  def is_content_type_json? env
    env['CONTENT_TYPE'] =~ /application\/json/
  end
end

Para que nossa classe seja adicionada na inicialzação da aplicação devemos adicionar a seguinte linha em config/application.rb:

# config/application.rb

module MyApplication
  class Application < Rails::Application
    # ...

    config.middleware.use CatchJsonParseErrors

    # ...
  end
end

Testando o Método Create de um Controller com Nested Attributes

Como testar o método create de um controller que utiliza nested_attributes


Estava eu essa semana tentando terminar os scripts de teste de um projeto. Tudo estava dando certo, terminei de cobrir todos os testes dos models e comecei a trabalhar nos controllers, até que me deparei com o teste do método create de um controller que utiliza nested_attributes para cadastro de um model e suas associações. A estrutura dos models e controller é semelhante a essa:

# app/models/author.rb
class Author < ActiveRecord::Base
  has_many :posts, dependent: :destroy

  accepts_nested_attributes_for :posts, allow_destroy: true

  ...
end
# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :author

  ...
end
# app/controllers/authors_controller.rb
class AuthorsController < ApplicationController
  ...

  def create
    @author = Author.new author_params

    if @author.save
      flash[:success] = 'Autor cadastrado com sucesso!'
      redirect_to @author
    else
      render 'new'
    end
  end

  ...

  private

  def author_params
    params.require(:author).permit(:name, posts_attributes: [
                                            :title, :content,
                                            :id, :_destroy
                                          ])
  end

  ...
end

Eu utilizo FactoryGirl para facilitar meus testes, a principio pesquisei por algum método mágico dessa gema que convertesse a Hash dos atributos exatamente para o aceito pelo parâmetro do controller (com nested_attributes). Passei longos 30 minutos e não achei nada, então resolvi fazer a seguinte gambiarra no script de teste:

require 'rails_helper'

RSpec.describe AuthorsController, type: :controller do
  ...

  describe 'POST #create' do
    context 'with valid attributes' do
      let!(author_params) do
        FactoryGirl.attributes_for(:author)
                   .merge(posts_attributes: [FactoryGirl.attributes_for(:post)])
      end

      it 'creates a new author' do
        expect {
          post :create, author: author_params
        }.to change { Author.count }.by(1)
      end

      it 'redirects to :show view' do
        post :create, author: author_params
        expect(response).to redirect_to(Author.last)
      end
    end

    ...
  end

  ...
end

Acredito que este não seja a forma mais correta de testar esse tipo de situação, mas pra mim deu certo.