GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS

Written by thoughtbot

Catching Invalid JSON Parse Errors with Rack Middleware

There is a world where developers need never worry about poorly formatted JSON. This is not that world.

If a client submits invalid / poorly formatted JSON to a Rails 3.2 or 4 app, a cryptic and unhelpful error is thrown and they're left wondering why the request tanked.

Example errors:

The error thrown by the parameter parsing middleware behaves differently depending on your version of Rails:

  • 3.2 throws a 500 error in HTML format (no matter what the client asked for in its Accepts: header), and
  • 4.0 throws a 400 "Bad Request" error in the format the client specifies.

Here's the default rails 3.2 error - not great.

> curl  -H "Accept: application/json" -H "Content-type: application/json" 'http://localhost:3000/posts' -d '{ i am broken'

<!DOCTYPE html>
<html>
  <!-- default 500 error page omitted for brevity -->
</html>

Here's the default Rails 4 error. Not bad, but it could be better.

> curl -H "Accept: application/json" -H "Content-type: application/json" 'http://localhost:3000/posts' -d '{ i am broken'

{"status":"400","error":"Bad Request"}%

Neither message tells the client directly about the actual problem - that invalid JSON was submitted.

Why?

The middleware that parses parameters (ActionDispatch::ParamsParser) runs long before your controller is on the scene, and throws exceptions when invalid JSON is encountered. You can't capture the parsing exception in your controller, as your controller is never involved in serving the failed request.

TDD All Day, Every Day.

Here's the test where we're looking for a more informative error message to be thrown. We're using curb in our JSON client integration tests to simulate a real-world client as closely as possible.

feature "A client submits JSON" do
  scenario "submitting invalid JSON", js: true do
    invalid_tokens = ', , '
    broken_json = %Q|{"notice":{"title":"A sweet title"#{invalid_tokens}}}|
   
    curb = post_broken_json_to_api('/notices', broken_json)
   
    expect(curb.response_code).to eq 500
    expect(curb.content_type).to match(/application\/json/)
    expect(curb.body_str).to match("There was a problem in the JSON you submitted:")
  end
   
  def post_broken_json_to_api(path, broken_json)
    Curl.post("http://#{host}:#{port}#{path}", broken_json) do |curl|
      set_default_headers(curl)
    end
  end
   
  def host
    Capybara.current_session.server.host
  end
   
  def port
    Capybara.current_session.server.port
  end
   
  def set_default_headers(curl)
    curl.headers['Accept'] = 'application/json'
    curl.headers['Content-Type'] = 'application/json'
  end
end

Middleware to the Rescue

Fortunately, it's easy to write custom middleware that rescues the errors thrown when JSON can't be parsed. To wit, the version for rails 3.2:

# in app/middleware/catch_json_parse_errors.rb
class CatchJsonParseErrors
  def initialize(app)
    @app = app
  end

  def call(env)
    begin
      @app.call(env)
    rescue MultiJson::LoadError => error
      if env['HTTP_ACCEPT'] =~ /application\/json/
        error_output = "There was a problem in the JSON you submitted: #{error}"
        return [
          400, { "Content-Type" => "application/json" },
          [ { status: 400, error: error_output }.to_json ]
        ]
      else
        raise error
      end
    end
  end
end

And the Rails 4.0 version:

# in app/middleware/catch_json_parse_errors.rb
class CatchJsonParseErrors
  def initialize(app)
    @app = app
  end

  def call(env)
    begin
      @app.call(env)
    rescue ActionDispatch::ParamsParser::ParseError => error
      if env['HTTP_ACCEPT'] =~ /application\/json/
        error_output = "There was a problem in the JSON you submitted: #{error}"
        return [
          400, { "Content-Type" => "application/json" },
          [ { status: 400, error: error_output }.to_json ]
        ]
      else
        raise error
      end
    end
  end
end

The only difference is what errors we're looking to rescue - MultiJson::LoadError under rails 3.2, and the more generic ActionDispatch::ParamsParser::ParseError under 4.0.

What this does is:

  • Rescue the relevant parser error,
  • Look to see if the client wanted JSON by inspecting their HTTP_ACCEPT header, and
  • If they want JSON, give them back a friendly JSON response with info about where parsing failed.
  • If they want something OTHER than JSON, re-raise the error and the default behavior takes over.

You need to insert the middleware before ActionDispatch::ParamsParser, thusly:

# in config/application.rb
module YourApp
  class Application < Rails::Application
    # ...
    config.middleware.insert_before ActionDispatch::ParamsParser, "CatchJsonParseErrors"
    # ...
  end
end

The results

Now when a JSON client submits invalid JSON, they get back something like:

> curl  -H "Accept: application/json" -H "Content-type: application/json" 'http://localhost:3000/posts' -d '{ i am broken'

{"status":400,"error":"There was a problem in the JSON you submitted: 795: unexpected token at '{ i am broken'"}

Excellent. Now we're telling our clients why we rejected their request and where their JSON went wrong, instead of leaving them to wonder.