Rails Mixin: Skip automatic params parsing
- ruby
- rails
- mixins
There’s a common pattern when receiving third-party notifications (a.k.a. webhooks) in Rails applications: using request.raw_post
to
initialize a wrapper around the notification data. ActionDispatch automatically parses the body of the request into the
request.params
hash, which can incur significant overhead for large payloads. Skipping this automatic params parsing
can result in a big speedup:
In this case, I’m handing eBay notifications using the ebayapi gem, which
constructs an Ebay::Notification
object from the raw post body:
notification = Ebay::Notification.new(request.raw_post)
eBay notifications are very large XML documents that need to be parsed into an XML object, then mapped from XML to ruby objects. All of this is handled by the ebayapi gem, so I don’t need to access these parameters via the request at any point. New Relic handily breaks out time spent parsing parameters, so it’s easy to see that parsing the large XML payload into a params hash adds 50 milliseconds - or more - to each request.
Finding something to modify
It’s pretty easy to track down the automatic parameter parsing. New Relic gives us a big hint by naming the segment
ActionDispatch::ParamsParser#call
, which is a very straightforward Rack middleware method:
def call(env) if params = parse_formatted_parameters(env) env["action_dispatch.request.request_parameters"] = params end @app.call(env) end
Determining how to modify
All we’ll need to do to skip parameter parsing is make the call to parse_formatted_parameters
return nil
instead
of actually parsing the parameters. That’s pretty simple, but we’re going to want two pieces to this new behavior. In
addition to the modification to skip paramter parsing, we’ll need a way to toggle this behavior on and off. For
simplicity, we’ll assume that toggling parameter parsing off on a per-path basis will be sufficient.
Adding the new behavior
First, we’ll need to store which paths we want to skip parameter parsing for. These paths will be stored in a class attribute for easier access:
module AddSkipParamsParsingOption def self.prepended(mod) mod.class_eval do cattr_accessor :skipped_paths do [] end end end # ... end
This is a bit tricky: since we’re going to be prepending a module with a parse_formatted_parameters
method, we’ll
hook into that module being prepended to add a cattr_accessor
for skipped_paths
to the module (or class) it is being
prepended to. By default, skipped_paths
will be an empty array - []
- so we won’t skip params parsing for any paths.
This allows us to add to the list of paths that skip params parsing from anywhere with:
ActionDispatch::ParamsParser.skipped_paths << '/some_path'
Now, to actually skip the parameter parsing, it’s pretty simple:
def parse_formatted_parameters(env) request = ActionDispatch::Request.new(env) if skipped_paths.include?(request.path) ::Rails.logger.info "Skipping params parsing for path #{ request.path }" nil else super(env) end end
Of note in the above code:
- We wrap an
ActionDispatch::Request
around the raw request environment to get the current path. - We log that the params parsing was skipped. This will serve as a reminder a year from now when we’re trying to figure
out why
request.params
doesn’t actually contain the parsed parameters for some action.
The mixin
In an initializer - e.g. config/initializers/action_dispatch_params_parser.rb
- add:
module AddSkipParamsParsingOption def self.prepended(mod) mod.class_eval do cattr_accessor :skipped_paths do [] end end end private def parse_formatted_parameters(env) request = ActionDispatch::Request.new(env) if skipped_paths.include?(request.path) ::Rails.logger.info "Skipping params parsing for path #{ request.path }" nil else super(env) end end end ActionDispatch::ParamsParser.send :prepend, AddSkipParamsParsingOption
In your application_controller.rb, add the following method:
def self.skip_params_parsing(*paths) ActionDispatch::ParamsParser.skipped_paths += Array.wrap(paths).flatten end
In a controller, add a call to skip_params_parsing
with the path you want to skip params parsing for:
class EbayNotificationsController < ApplicationController skip_params_parsing '/notifications/ebay' # ... end
Now, requests to the “/notifications/ebay” path will skip parameter parsing, leaving it up to the action itself to
handle the raw body of the request. Note that the params
hash will still include route-based parameters, like
:controller
and :action
.
The caveats
Specifying which action to skip by path is pretty ugly. Since the skip_params_parsing
method is added to
controllers, it would be a lot nicer to write skip_params_parsing :ebay_notification
and have the skip_params_parsing
method figure out which path to skip.
Since we’re skipping parsing per-path, there isn’t a way to skip a path with a dynamic segment. We would need to modify
the condition in the prepended parse_formatted_parameters
to be a lot smarter about matching the route to handle
paths like /users/1
.