How to pass options like `only_path: true` and `anchor:` to `url_for` when generating a route from an array or object (instead of from a hash)
Asked Answered
V

2

6

I'm trying to change a function — which currently only accepts a URL (path) string — to make it more flexible, so that it will also accept as input the same arguments that you can pass to url_for. The problem is, url_for returns a full URL including protocol/host, and I only want the path portion...

url_for has a lovely only_path: true option that makes it skip adding the protocol/host. This works great as long as you're passing it as part of a single hash to url_for:

main > app.url_for(controller: 'users', action: 'index', only_path: true)
=> "/users"

But how do you pass options when you are passing an array or a model object to url_for?

main > app.url_for(user, only_path: true)
ArgumentError: wrong number of arguments (given 2, expected 0..1)
from /gems/actionpack-5.1.6/lib/action_dispatch/routing/url_for.rb:166:in `url_for'

main > app.url_for([user, :notification_preferences], only_path: true)
ArgumentError: wrong number of arguments (given 2, expected 0..1)
/actionpack-5.1.6/lib/action_dispatch/routing/url_for.rb:166:in `url_for'

You can't! Clearly, it's not even syntactically possible to pass an options hash if you're passing anything other than a hash, since its arity is 0..1 and it only takes a single argument: url_for(options = nil).


So my question is, is there a Rails helper that takes the same options as url_for (including anchor:) but returns a path?

I suppose it wouldn't be too hard to add one, like this one, that uses URI.parse(path).path (probably what I'll do for now)... but that seems inefficient, inelegant, and inconsistent.

Inefficient and inelegant because it generates a string containing extra unwanted information and then has to parse it, convert it to a structured data structure, and then convert it back to a string without the unwanted information.

Inconsistent because:

  1. Rails includes a _path variant for every _url route helper. Why doesn't it have a built-in "path" variant of url_for (or does it and it's called something else?)?

  2. Other routing helpers that accept an object or array—such as polymorphic_path— let you pass in options too:

Example:

main > app.polymorphic_url [user, :notification_preferences], anchor: 'foo'
=> "http://example.com/users/4/notification_preferences#foo"

main > app.polymorphic_path [user, :notification_preferences], anchor: 'foo'
=> "/users/4/notification_preferences#foo"

main > app.polymorphic_path user, anchor: 'foo'
=> "/users/4#foo"

ActionDispatch::Routing::RouteSet actually has a path_for:

  # strategy for building urls to send to the client                        
  PATH    = ->(options) { ActionDispatch::Http::URL.path_for(options) }     
  UNKNOWN = ->(options) { ActionDispatch::Http::URL.url_for(options) }    

  def path_for(options, route_name = nil)                                   
    url_for(options, route_name, PATH)                                      
  end                                                                       

  # The +options+ argument must be a hash whose keys are *symbols*.         
  def url_for(options, route_name = nil, url_strategy = UNKNOWN)            
    options = default_url_options.merge options    
    ...

— just not ActionDispatch::Routing::UrlFor, apparently. Anyway, ActionDispatch::Routing::RouteSet#path_for is buried too deep in internals to be helpful to me; I need a helper that is callable from the controller/view.

So, what is a good solution for this that is elegant, consistent with other Rails routing helpers, and relatively efficient (no URI.parse)?


Better yet, is there any reason the method signature of the built-in url_for couldn't simply be modified (in a future version of Rails, by submitting a pull request) to allow both a subject (model object, array, or hash) and any number of optional options to be passed in?

Probably the original url_for was written before Ruby had keyword arguments. But nowadays, it's pretty trivial to do exactly that: accept an "object" plus any number of optional keyword options:

def url_for(object = nil, **options)
  puts "object: #{object.inspect}"
  puts "options: #{options.inspect}"
end

main > url_for ['user', :notification_preferences], anchor: 'anchor'
object: ["user", :notification_preferences]
options: {:anchor=>"anchor"}
=> nil

main > url_for ['user', :notification_preferences], only_path: true
object: ["user", :notification_preferences]
options: {:only_path=>true}

Is there any reason we couldn't/shouldn't change url_for's method signature to url_for(object = nil, **options)?

How would you modify url_for/full_url_for such that it remained as backwards compatible as possible but also let you call it with an array + keyword options?

Vaquero answered 8/1, 2019 at 5:28 Comment(0)
G
1

Years later, I have found the right answer, or at least the answer that works with Rails 7:

url_for [user, :notification_preferences, only_path: true]
=> /users/4/notification_preferences

The docs say url_for accepts only one argument. And while not explicitly written in any example, one array with the last element being the options hash is perfectly fine.

Ghee answered 12/7, 2024 at 14:11 Comment(1)
lol... thank you so much for thisHyperbolism
V
0

Here's one potential solution that shows kind of what I'm looking for:

  def url_for(object = nil, **options)
    full_url_for(object, **options)
  end

  def full_url_for(object = nil, **options)
    path_or_url_for = ->(*args) {
      if options[:only_path]
        _routes.path_for(*args)
      else
        _routes.url_for( *args)
      end
    }
    polymorphic_path_or_url = ->(*args) {
      if options[:only_path]
        polymorphic_path(*args)
      else
        polymorphic_url( *args)
      end
    }

    case object
    when nil, Hash, ActionController::Parameters
      route_name = options.delete :use_route
      merged_url_options = options.to_h.symbolize_keys.reverse_merge!(url_options)
      path_or_url_for.(merged_url_options, route_name)
    when String
      object
    when Symbol
      HelperMethodBuilder.url.handle_string_call self, object
    when Array
      components = object.dup
      polymorphic_path_or_url.(components, components.extract_options!)
    when Class
      HelperMethodBuilder.url.handle_class_call self, object
    else
      HelperMethodBuilder.url.handle_model_call self, object
    end
  end

(Compare to source of original Rails version)

I haven't tried running the Rails test suite against this code yet, so it very well may have missed something, but it seems to work for my simple tests so far:

main > app.url_for(controller: 'users', action: 'index')
=> "http://example.com/users"

main > app.url_for(controller: 'users', action: 'index', only_path: true)
=> "/users"

main > app.url_for [user, :notification_preferences]
=> "http://example.com/users/4/notification_preferences"

main > app.url_for [user, :notification_preferences], only_path: true
=> "/users/4/notification_preferences"

Let me know if you find any bugs in this or have any improvements or can think of a cleaner way to accomplish this.

Please do post any improved version you may have as an answer!

Vaquero answered 8/1, 2019 at 7:14 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.