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 400
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 rescue
s 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.