Reading Rails - HTTP DELETEs With a Link

We're going to take a slightly different focus today, and look at the intersection of Rails, Rails-UJS, and Rack. These libraries work seamlessly together to provide functionality you probably take for granted. We'll also meander along a few detours and find some interesting concepts we can employ in our own code.

The conventional approach in Rails is to build your controllers as RESTful resources, using HTTP methods like GET, POST, and DELETE to interact with those resources. There's just one problem, links always perform GET requests. What happens if we want to have a link that deletes a resource? Even if you used a form instead, you can only make GET and POST requests. Let's unravel this little mystery, and walk through Rails' solution starting with the view.

The Link

Rails allows you to specify a method when creating a link:

link_to "Delete User", person_path(id: 1), method: :delete
#=> <a href='people/1' rel="nofollow" data-method="delete">Delete User</a>

Let's find out how that data-method gets in there, then we'll figure out why it works.

def link_to(name = nil, options = nil, html_options = nil, &block)
  html_options, options, name = options, name, block if block_given?
  options ||= {}

  html_options = convert_options_to_data_attributes(options, html_options)

  url = url_for(options)
  html_options["href".freeze] ||= url

  content_tag("a".freeze, name || url, html_options, &block)
end

Right off the bat, this humble method yields an interesting pattern. link_to can either get its content from the first agrument, or a block. Although convenient for the caller, it's awkward from an implementation standpoint. Typically we put optional arguments at the end of Ruby methods, not the beginning. If a block is used, link_to rewrites its arguments using multiple assignment.

html_options, options, name = options, name, block if block_given?

If you haven't seen multiple assignment in Ruby, that line is the equivalent of:

if block_given?
  html_options = options
  options = name
  name = block
end

The key difference is that all of the assignments happen independently. Here's a smaller example:

x,y = 1,2
x,y = y,x
x #=> 2
y #=> 1

Aside from being shorter, this also avoids errors like this:

# Oops!
if block_given?
  name = block
  options = name
  html_options = options
end

In this case, you'd end up with name, options, and html_options all equalling block. So, this is neat pattern to remember if you ever need to reorder method arguments.

With that out of the way, let's see what's happening in convert_options_to_data_attributes. It appears to take both the options hash, and the html_options hash, and just return a new hash of html_options.

def convert_options_to_data_attributes(options, html_options)
  #...
  method = html_options.delete('method'.freeze)

  add_method_to_attributes!(html_options, method) if method

  html_options
  #...
end

This looks very promising, it removes the method option using Hash#delete, then hands it off to add_method_to_attributes!

def add_method_to_attributes!(html_options, method)
  if method && method.to_s.downcase != "get".freeze && html_options["rel".freeze] !~ /nofollow/
    html_options["rel".freeze] = "#{html_options["rel".freeze]} nofollow".lstrip
  end
  html_options["data-method".freeze] = method
end

Here we can see that if the method is not get, and if rel doesn't already specify nofollow, Rails will tack it on. Then it will add the data-method attribute. So now we know where those extra attributes came from:

link_to "Delete User", person_path(id: 1), method: :delete
#=> <a href='people/1' rel="nofollow" data-method="delete">Delete User</a>

Hold on though, there are three things worth looking into in this method alone!

To the first point, although it's incredibly ugly, Ruby 2.3 can optimize frozen string literals. Otherwise every string literal causes an allocation. Ugly as it is, this is a good optimization for a framework to make. Should you rewrite your code to make use of this? Probably not. Save this optimization for when you need it, and then only use it when you're going to allocate strings on every request.

Second point: I've been writing Ruby for a startlingly long time, and the number of times I have seen the !~ operator can be counted on one hand, even if it were missing a finger or two. This is the "does not match" operator:

"Apple" =~ /pp/ #=> 1
"Apple" !~ /pp/ #=> false

Now you know! If you find yourself writing !("Apple" =~ /pp/), consider using !~.

Finally, what's up with this nofollow? A common use for nofollow is to instruct search engines to ignore a link for scoring purposes. In this case however it's to prevent web crawlers from following links which have side effects, such as modifying content. Imagine if you had a forum with a link for flagging spam. You wouldn't want a web crawler to follow each spam link and flag everything in the forum, so we include the nofollow hint. Of course, it's just a hint, but it can help avoid some headaches.

That wraps up the Rails side of this mystery.

The JavaScript

Let's switch gears and read some JavaScript. By itself, the data-method attribute does absolutely nothing. Rails-UJS however handles these special links by setting up a listener on the root of your page:

//...

var rails;
var $document = $(document);

$.rails = rails = {
  linkClickSelector: 'a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]',
  //...
}

//...
$document.delegate(rails.linkClickSelector, 'click.rails', function(e) {
  //...
});

Here you can see we're delegating an event handler on $document that listens for click events emitted by elements that match rails.linkClickSelector.

$document is a jQuery object which is used throughout Rails-UJS. The dollar sign prefix is a common idiom for projects using jQuery to indicate it's a jQuery object wrapping one or more DOM elements. Also note that Rails-UJS assigns everything to both $.rails and rails. The $.rails allows you to call its methods from outside of this library, and the assignment to rails provides a shortcut for internal method calls in Rails-UJS.

delegate registers one listener that will catch any event that bubbles up through elements matching the selector. In this case the selector defined by linkClickSelector selects links with specific data attributes:

a[data-confirm], 
a[data-method], 
a[data-remote]:not([disabled]), 
a[data-disable-with], 
a[data-disable]

So for instance it would match clicks on the following link, and also the span inside it since the event will bubble up through the a tag with an appropriate attribute (data-method):

<div>                       <!-- div:  No Match -->
  <a data-method="delete">  <!-- a:    Match -->
    <span>                  <!-- span: Match -->
      Delete                <!-- text: Match -->
    </span>
  </a>
</div>

Let's take a look at what Rails-UJS does when it intercepts a click on one of these links:

$document.delegate(rails.linkClickSelector, 'click.rails', function(e) {
  var link = $(this), method = link.data('method');

  //... lots of exciting, but distracting logic.

  rails.handleMethod(link);
  return false;
}

The HTTP method is plucked out of the link's data attribute using link.data('method'). Then we see a call to handleMethod which is exactly what we're interested in. Before we explore that, take a look at the return false. If a jQuery event handler returns false, the default browser action is canceled. In this case, that means we won't follow the link. Let's take a look at handleMethod to see why:

handleMethod: function(link) {
  var href = rails.href(link),
    method = link.data('method'),
    //...
    form = $('<form method="post" action="' + href + '"></form>'),
    metadataInput = '<input name="_method" value="' + method + '" type="hidden" />';

  form.hide().append(metadataInput).appendTo('body');
  form.submit();
}

First the link's href is extracted using the helper function defined on rails.href. Next, method is extracted just as we saw in the event handler. Finally two DOM elements are created: form and metadataInput.

Calling jQuery with HTML instead of a CSS selector returns a newly created DOM element. For what it's worth, you can also pass jQuery an object containing attributes and event handlers. So those two elements could be defined this way:

form = $("<form>", {method: "post", action: "href"}),
metadataInput = $('<input>', {name: "_method", value: method, type: "hidden"});

I find that a bit more pleasant. If you find yourself needing to create a DOM element here and there, I think it's a handy approach.

With those two elements at the ready, Rail-UJS hides the form, attaches the metadataInput to it, and then appends the whole thing to the bottom of the page. Finally, it submits the form for us. Notice that while the _method input will have delete as its value, the form's actual method is post.

So now we know what clever magic goes on in the browser, it's time to head back to the server.

Into Rack

Sitting between Rails and your Ruby webserver (Thin, Passenger, Unicorn, etc.) is Rack, a truly useful little library. Rack takes the HTTP request that your webserver receives, filters and transforms it, and then hands it off to your application. In our case that is a Rails application, though it could also be one of the many Rails alternatives. The piece we're interested in here, is how Rack transforms requests that contain a _method parameter, and that my friends can be found in the MethodOveride middleware.

The API for a Rack middleware is quite simple, it needs to have a method called call which accepts a Hash, and it should either call the next middleware, or return an array containing: [status_code, headers, response_body]. Let's take a look at MethodOveride's call method:

HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK]

METHOD_OVERRIDE_PARAM_KEY = "_method".freeze
HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE".freeze
ALLOWED_METHODS = %w[POST]

#...

def call(env)
  if ALLOWED_METHODS.include?(env[REQUEST_METHOD])
    method = method_override(env)
    if HTTP_METHODS.include?(method)
      env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD]
      env[REQUEST_METHOD] = method
    end
  end

  @app.call(env)
end

The env argument is a Hash containing several known keys. In this case env[REQUEST_METHOD] will contain POST since the form was submitted as a POST. MethodOveride will only override HTTP POSTs for security reasons. Next the desired method is plucked out of env using method_override which checks for either the _method parameter or a special header.

If that method is one of the known methods listed in HTTP_METHODS, the original HTTP method will be stored off for reference, and replaced with the new HTTP method, DELETE in the case of our link example.

I particularly like this middleware because neither Rails, nor your app need to know about any of this, as far as they're concerned the browser sent it an HTTP DELETE.

Recap

We've seen how Rails, Rails-UJS, and Rack all work in concert to transparently work around a browser limitation. Rails generates links with special attributes, Rails-UJS intercepts those and transforms them into form posts with special params, and Rack intercepts those and transforms our HTTP request accordingly.

Along the way, we came across several interesting things:

There is no magic, just code that is yet to be read.

blog comments powered by Disqus
Monkey Small Crow Small