Routing for self-nested resource with friendly_id slugs

I’ve got a Page model which has_many pages (as children), and which uses friendly_id for slug URLs. I do not want /page in the URL, so I set path: "", but I do want the URL to include the slug for a page’s parent (and it’s parent) if it has any. I’ve hacked together a ridiculous routing configuration that achieves this for pages up to three levels of nesting, but would like to learn the correct way to do this:

  resources :pages, only: :show, path: "" do
    resources :pages, only: :show, path: "" do
      resources :pages, only: :show, path: ""
    end
  end

Surely there is a better way?

I’m guessing overriding some method on the model is the way to go? to_url? to_path?

I would not use resources but instead a non-resourceful route. Since you have a bit of a tree structure I might suggest specifically route globbing. Since you don’t want any sort of prefix you need to make this the last route defined so it’s a catch-all after all other routes have been processed. Example:

get '*path', to: 'pages#show'

You’re controller will receive a param called path which will contain the path to that page. So if the user tries to access https://yoursite.com/path/to/your/content then param[:path] will equal path/to/your/content. You can use that info to then load the relevant page.

Since you mentioned using slugs in the URL keep in mind to do things such as if the page name or ancestors change (which I assume change the path) that you don’t break links. Generally this is done either by:

  • Including the ID in that slug. So a page named “My Page” might be 349-my-page. This way even if you later change it to “Your Page” and it becomes 349-your-page you can still find the page as you only care about the ID. The rest is just for SEO.
  • Keep a history of past paths to that page. Every time the canonical path changes save the old path in a list so you can find it by the current canonical path or any of the historical paths (preferring canonical paths over historical)

Thanks! The reason I wanted to go after the model’s to_path (or rather, to_param) method is that I want the path to include the instance’s parent path (if any), and its parent path, and so on. That would allow me to get the full hierarchy with page_path. But this won’t fly, since the output of to_param gets escaped, and forward slashes end up as %2f. There’s a lengthy discussion about this on the Rails Github, but I don’t have the time to go too deep into the weeds. Instead I came up with a helper method that does what I want and replaced all relevant page_path instances with it:

def full_page_path(page)
	path = ["/"]
	page.ancestors.each do |parent|
		path << parent.friendly_id
	end
	path << page.friendly_id
	File.join(path)
end

This works well. But I’m still at a loss for how to deal with this on the routing side; I don’t want to make any assumption about how deeply pages can be nested, though in practice I expect it would be very rare to want to go deeper than three levels. The situation is complicated because Page has_many :images (and further associations will be added). Route globbing as suggested breaks access to these nested resources. This, however, does work - though it looks ridiculous and only goes three levels deep:

resources :pages, only: :show, path: "" do
  resources :images, only: :show
  resources :pages, only: :show, path: "" do
    resources :images, only: :show
    resources :pages, only: :show, path: "" do
      resources :images, only: :show
    end
  end
end

Yes, I’ve enabled freindly_id’s history module, and I’m generating a new slug whenever the page.title changes:

class Page < ApplicationRecord
  extend FriendlyId
  friendly_id :title, use: [:slugged, :history]
  ...
  def should_generate_new_friendly_id?
    title_changed? || super
  end
end

If you’re using FriendlyID, then your pages can be found by just the last segment of your nested URL, right? If you work out how to “glob” the middle part in your routes file, then the last segment will do the actual record-finding for you. The trick is that Rails uses the / as the separator, but you would want that to be a legal character in the middle of the routes, while also being used to signal “this is the last segment”. I’m not sure how you do that. I appreciate that you want to avoid any sort of hard-coded “only four segments” sort of thing in your routes.

Maybe the thing to do is treat the whole URL as a wildcard segment, and then split that entire segment to find the last pieces.

You may find something to use here: Rails Routing from the Outside In — Ruby on Rails Guides

The other thing to try is a really-wild wildcard.

  # must stay last
  get "/:static_file", to: "pages#show", static_file: /[-_a-z0-9]+/, as: "wildcard"

is taken from a current site of mine. Note the definition of the legal characters for that static_file placeholder. I believe it is possible to include a / in that (escaped, naturally) so it becomes a legal character. Then your controller method would be responsible for deciphering that that ‘entire path as one attribute’ means. The controller method from my site does this (as a security measure):

  def show
    render action: template_name and return if static?

    @page = Page.friendly.find page_slug
  end

  private

  def page_slug
    params[:id] || params[:static_file]
  end

  def template_name
    page_slug&.to_s&.underscore
  end

  def static?
    Page.static_pages.include? template_name
  end

Page.static_pages is a method that globs the views/pages directory and ensures that the user is asking for something that is really there.

In your case, you want to just take that value, split it by the / character, and use the last segment as your slug.

Walter

1 Like

Would it work to route-glob the images as well?

get '*path/images/:id', to: 'images#show'
get '*path', to: 'pages#show'

The image one needs to be first since it is more restrictive. If the other came first, it would also match the images. And if you wanted to tie a bow on you might disallow someone creating a page with the name images to avoid a page accidentally matching that first glob.

Thanks! I think there’s a typo there; the page route is missing its :id. Unfortunately even with it the globbing does not work - I’ve got

get "*path/images/:id", to: "images#show"
get "*path/:id", to: "pages#show"

at the the bottom of my routes but

No route matches [GET] "/some-page"

I think it needs to include

get ":id", to: "pages#show"

for root pages. This kind of works:

get "*path/images/:id", to: "images#show"
get "*path/:id", to: "pages#show"
get ":id", to: "pages#show"

But it breaks image path helpers:

undefined method `page_image_path' for #<ActionView::Base:0x0000000000feb0>

No typo. I didn’t call it id because it’s not an ID. Seems confusing to me to call it that so I called it path. But if you want to call the param id you can do that:

get '*id', to: 'pages#show'

Now if you navigate to /my/cool/path then param[:id] will equal to /my/cool/path.

If you want path helpers then just define them. In your routes.rb file:

get '*path/images/:id', to: 'images#show', as: :page_image
get '*path', to: 'pages#show', as: :page

In your page.rb file:

class Page < ApplicationRecord
  ...
  def to_param
    # ... return full path to page ...
  end
end

With this in place if you want to link to a page in a view:

<%= link_to page.name, page_path(page) %>

If you want to redirect to a page in a controller:

redirect_to page_url(page)

With both of those I think you can just pass the raw “page” object in and it will assume the page named route. If you want to show an image in a view:

<%= image_tag page_image_path(image, path: params[:path]) %>

That extra path argument is a bit annoying. I think you can also use path params to get rid of that. Define your routes to instead be:

scope '*path' do
  get 'images/:id', to: 'images#show', as: :page_image
end
get '*path', to: 'pages#show', as: :page

Then in your application_controller.rb file define:

class ApplicationController < ActionController::Base
  def default_url_options
    { path_params: { path: params[:path] } }
  end
end

Now the current path param will be automatically applied on the page_image named route.

Disclaimer: None of the above is tested. Just is just off the top of my head. I’m sure there is a bug or two in there but the general idea should work. I highly recommend the Routing Guide as it goes over a lot of this stuff.

1 Like