Home

Feb 21

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:

New Relic Stats

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.

blog comments powered by Disqus