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!
- What’s up with all the
"string".freeze
s? - Since when was
!~
an operator? - Why bother with
rel="nofollow"
anyways?
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:
- Multiple assignment is handy for juggling method arguments
- Frozen String literals are a Ruby 2.3 optimization
!~
is the “does not match” operator- Use
rel="nofollow"
to suggest that web crawlers not follow links $element
is a jQuery idiom indicating a jQuery object$.delegate
and$.on
allow one event handler to listen to many elements$("<div>")
can be used to create new elements
There is no magic, just code that is yet to be read.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions