Allowing dots in Rails routes

Rails routing is very powerful and it allows to define different types of routes. Though resourceful routes is recommended, it also gives us ability to define routes for specific cases using dynamic segments.

post '/users/:id/upgrade', to: 'upgrade#create'

This route matches users/123/upgrade and provides params[:id] with value 123.

All good so far. Now, we want to rather support users/john.smith/upgrade to make the URLs user friendly. As we are using the dynamic id segment in the route already, hopefully we will get that value in the params[:id].

Let's try hitting our application with this URL.

$ curl -iv "http://localhost:3000/users/john.smith/upgrade"

< HTTP/1.1 404 Not Found
< Content-Type: text/html; charset=UTF-8
< X-Web-Console-Session-Id: 8e09e5f6d9e8308f0647622457060bde
< X-Web-Console-Mount-Point: /__web_console
< X-Request-Id: bac4bb34-0577-4134-abba-2f8d6aa437f0
< X-Runtime: 0.101339
< Transfer-Encoding: chunked

Surprisingly, instead of getting expected response, we get a 404 response!

How Rails parses URLs

One of the lesser known thing about how Rails matches the route segments with incoming requests.

The default regular expression used for a route segment is /[^\/.?]+/. It matches any combination of characters that is not a forward slash, dot or question mark.

Because of this, when we hit users/john.smith/upgrade, it is matched with following regular expression by Rails.

re = /\/users\/([^\/.?]+)\/upgrade/
This is simplified form of the actual regular expression used by Rails. Actual regular expression is /\A/users/([^/.?]+)/upgrade(?:.([^/.?]+))?\Z/
> "/users/john.smith/upgrade".match? re
=> false

As we can see, the regular expression does not match with the request if the id segment has a dot inside it.

Constraints to the rescue

Rails supports adding URL constraints for every route which can be used to mitigate this problem. We can add HTTP verb based constraints, request attributes based constraints as well as dynamic segment based constraints. In this case, we will add constraint on the dynamic segment id.

post '/users/:id/upgrade', to: 'upgrade#create', constraints: { id: /[^\/]+/ }

This defines a constraint on :id such that it should be any combination of characters other than forward slash. Now our requests containing dot character will get resolved  by the Rails router.

More information about how to add constraints can be found in Rails guides.

We can also add the constraint more succinctly as follows.

post '/users/:id/upgrade', to: 'upgrade#create', id: /[^\/]+/

Let's see what regular expression Rails uses in this case.

re = /\/users\/([^\/]+)\/upgrade/

> "/users/john.smith/upgrade".match? re
=> true

As per our instructions, the :id segment is now getting matched with /[^\/]+/ instead of the default regular expression used by Rails.

Question about question mark

Although Rails uses /[^\/.?]+/ as regular expression for route segments, by the time the request hits the Rails router, the ? is already separated into query string and is no longer part of the path which is matched with the routes defined in our application.

For eg. consider we are hitting the application with users/john?smith/upgrade

Rails will try to find a route with users/john in this case which will result into 404 if there is no such route.

Takeaway

Rails uses dot and forward slash as separators while parsing the route segments. They can only be matched when explicitly requested using the constraints.


Subscribe to my newsletter to know more such insights about Ruby on Rails.