Polymorphic URLs with direct Router Helper

Polymorphic URLs with direct Router Helper

The Rails router's direct method lets you create custom url and path helpers, which is especially useful for polymorphic models and delegated types. This post shows how to use a single custom helper to generate URLs for different models, with a practical example from the open source Maybe project.

4 min read

While reading the source code for the Maybe project (which is a really good Rails codebase that follows most of the Rails best practices and conventions), I came across this code in the router config file. It uses the Rails routing feature called direct to create custom URL helpers for a polymorphic model.

direct :entry do |entry, options|
  if entry.new_record?
    route_for entry.entryable_name.pluralize, options
  else
    route_for entry.entryable_name, entry, options
  end
end

At first glance, I didn't understand what it was doing, as I've personally never had to use the direct method like this in my Rails projects so far, so decided to do some reading and wanted to share everything I learned.

First, what is the direct method in the Rails router?

The direct helper lets you defined custom named route helpers that generate URLs based on custom logic, rather than the default resourceful routes. You can then use these helpers throughout your application, just like any other URL helpers in Rails.

💡
It's particularly useful when the URL for a model doesn't follow the standard conventions that resourceful routing handles automatically.

In the simplest case, you can simply return a URL that you'd like to use when that custom URL helper is used. Consider the following example.

direct :course_landing_page do
  "https://writesoftwarewell.com/courses"
end

This will generate following URL helper methods

If you pass any arguments to the helper, they get forwarded to the direct block.

direct :greeting_page do |user|
  "https://writesoftwarewell.com/greet/#{user.name}"
end

This generates the helpers greeting_page_url and greeting_page_path. Assuming matz.name returns "yukihiro", you can use them as follows:

To learn more about the basic usage of the direct method, check out the following post:

Custom URL Helpers in Rails with the direct Method
This is the first post in the Campfire deep dive series where we explore the first ONCE product from 37signals to learn and extract useful patterns, ideas, and best practices. This post explores the direct method in the Rails Router that lets you define custom URL helpers for your application.

The direct method is pretty flexible and you can encapsulate complex or polymorphic path logic in the block, which is what the Maybe router is doing.

direct :entry do |entry, options|
  if entry.new_record?
    route_for entry.entryable_name.pluralize, options
  else
    route_for entry.entryable_name, entry, options
  end
end

This code defines a custom route helper called entry_path and entry_url. When you call entry_path(entry), where entry can be a polymorphic object or a delegated type, Rails will use this block to figure out what URL path to return for the given entry object.

Let's assume that Entry is a polymorphic model and can be a Transaction, Account, or Trade, etc.

What does the block do?

  • If the entry is a new record, i.e. entry.new_record? returns true, Rails will return the value generated by the following code:
route_for entry.entryable_name.pluralize, options

This means, generate the path for the pluralized entryable name, e.g. if entry.entryable_name is "transaction", Rails will return the value of generated by calling route_for "transactions", options which ultimately generates the path for the transactions resource, i.e. transactions_path, which is /transactions.

I'm guessing it's used for the "new" forms, where you want to POST to the collection.

Example:

entry = Entry.new(entryable: Transaction.new)
entry_path(entry)

# => transactions_path, i.e. "/transactions"

Since the entry was not saved, it routed to the collection path for the entryable type.

  • If the entry is persisted (the else condition), it will return the value generated by this code:
route_for entry.entryable_name, entry, options

This means, generate the path for the singular entryable name and pass the entry as the argument to the helper, i.e. transaction_path(entry)

Example:

entry = Entry.find(1) # assume this entry is a transaction
entry_path(entry)

# => transaction_path(entry), i.e. "/transactions/1"

Since the entry was saved, it routed to the member path for the entryable type.

Why use this?

If you have a polymorphic model that can represent multiple types, a single helper can generate the correct path for any type.

In Maybe, an Entry is a polymorphic model that can be one of several types, such as a Transaction, Trade, or Valuation. The URL for an entry should depend on what kind of entry it is.

In addition, the logic forks based on whether the entry has been saved to the database. This is a common pattern in most web applications, where you have to use a different URL in forms, where a new object should POST to a collection URL /transactions, while a form for an existing object should PATCH to a member URL, like /transactions/123.

How to use it?

<%= link_to "View entry", entry_path(entry) %>

This will generate the correct path for the given entry, whether it's a new entry or a persisted entry, regardless of its underlying type.

The following table summarizes it.

Scenario What gets called? Example Output
New Transaction Entry transactions_path(options) /transactions
Existing Transaction transaction_path(entry, options) /transactions/123
New Valuation Entry valuations_path(options) /valuations
Existing Valuation valuation_path(entry, options) /valuations/456

Basically, you can use the direct helper method to handle URL generation for polymorphic types. Creating a single URL helper method that hides the complexity of figuring out the type of the entry and whether the entry is new or an existing record.


That's a wrap. I hope you found this article helpful and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. Your email is respected, never shared, rented, sold or spammed. If you're already a subscriber, thank you.