Dynamically construct path by action and helper name

Question

Given a route with a known controller action (:library_action) and a known helper name (as: :my_lib), how can I reliably generate the route’s path?

Background

I’m creating a library that integrates with Phoenix. This library provides a controller action MyLib.Controller.library_action/2, and asks consumers to add a route to their application’s router, such as:

get "/user/chosen/path/with/:param", MyLib.Controller, :library_action, as: :my_lib

For flexibility, I require the name of the action (:library_action) and the :as option, but leave the exact path up to the consumer. Furthermore, the consumer may choose to reimplement the :library_action controller action in their own application, ignoring the one provided by the library.

Whether the consumer uses the library-provided implementation or substitutes their own controller, it is necessary for other parts of the library to know the path for this action. In the past, the library could rely on the router’s Helpers module, and use the generated my_lib_path(conn :library_action, ...) function to create the path or URL. However, with Phoenix 1.7, the Helpers module is no longer guaranteed to be generated.

Other Notes

Here’s what we can do already:

  1. Get the current router dynamically from the conn via Phoenix.Controller.router_module/1.
  2. Get a list of known routes via Phoenix.Router.routes/1.
  3. Filter by the helper and plug_opts keys to find a route with the correct action name and :as option.
  4. Get the full path as a string, for example "/user/chosen/path/with/:param".
  5. Manually replace path segments with values, when necessary.

However, this process seems error-prone, especially the manual replacement of path segments.

All suggestions welcome! Thank you.

5 Likes

That’s such an interesting question, shame nobody chimed in.

2 Likes

A more-explicit approach would be to ask users to configure your library with a {module, function, args} tuple that can generate the route.

For instance, you might configure {MyAppWeb.LibHelper, :my_lib_route, [:extra1, :extra2]} and define:

defmodule MyAppWeb.LibHelper do
  def my_lib_route(conn, params, _extra1, _extra2) do
    # extra1 and extra2 are just to show what happens
    MyAppWeb.my_lib_path(conn, :library_action, params)
  end
end

Your library could call this with:

def call_route({mod, fun, args}, conn, params) do
 apply(mod, fun, [conn, params] ++ args)
end
1 Like

Thanks for pinging this thread back into view. The possibility that helpers generation goes away also impacts localized route generation and localized helpers.

Both @BartOtten’s phoenix_localized_routes and my own ex_cldr_routes use helpers to generate routes at runtime to reflect the locale in effect at the time. This cannot be parsed at compile time because at that time the locale is not known.

Localized routes have the same helper name and same path structure but have path components translated (and may have locale components interpolated).

For that reason alone I hope that Helper generation can have a more definitive future.

3 Likes

I like this approach. There are a few places in the library where I plan to do this or something very similar. Especially if I can find a way to make the existing route helpers module meet the contract as well.

I certainly agree. Verified Routes is a powerful addition to Phoenix, and — just to be clear — I fully support it as the default for consumers. Maybe there is a different mechanism that would support 3rd-party libraries like the route helpers do now. Perhaps something with a bit less metaprogramming magic (since we wouldn’t be optimizing for the ergonomics of calling a helper function with exactly the right number of arguments). Not sure what that might look like, but the intersection of our use-cases is quite interesting.

1 Like

@kip do you have time to talk about it with Phoenix Team? I am living without digital devices as much as possible and the network is really bad over here :slight_smile: Will be back in a week, but those 7 days might make a difference.

I wonder why you need the path helper in the first place. conn.script_name should give you the segments of the path, which was used to get to your controller. No need to consult the router or anything. Should work fine with both Plug.Router as well as Phoenix.Router. You can use that to build the path within the code you control. If the user wants to link to your controller they know the necessary details and can just use the normal phoenix tools (helpers, verified routes, …) to do so.

It not really about knowing the path that got to the current controller. It’s generating paths and URLs to other parts of the site, where those paths are localised. Since they are localised the path cannot be validated at compile time (or at least I can’t think of a way). Using helpers give some minimal guarantee since an unknown function error at compile time acts as a signal.

1 Like

I guess my question was more targeted at @aj-foster’s usecase, which seems to be related to urls to itself, rather than other pages.

Apologies for not being clear. In reality this library has multiple controller actions. I don’t need the path to the current controller action, but rather the path to another controller action also prescribed by the library. Hence the need to look up the action by name/helper.

Note that verified routes work just fine with the localized routes, provided the user constructs them:

From the ex_cldr_routes default path structure:

~p"/pages_#{@locale}/#{@page}"

But your library also generates the explicit locale helpers, so the user calls those when they are in areas specific to that locale (page_de_path(.... So that usecase simply becomes writing the actual verified path:

~p"/pages_de/#{@page}"

And for convenience, your library currently providers a LocalizedHelpers module, so it could also provide the same kind of thing which wraps the canonical ~p:

localized_path(@locale, ~p"/pages/#{@page"})

So I’m not seeing the issue with verified routes. In fact, I actually think it’s a big win to simply write the paths vs helpers when that’s all the user needs, (~p"/pages_de/#{@page}" vs Routes.pages_de_path(@conn, @page) and the localized_path macro would also still be quite nice.

Lastly, your library users can still use helpers (by not passing helpers: false to Phoenix.Router) and use all the existing functionality if they so choose :slight_smile:

1 Like

Chris, thanks for chiming in. I’ve no issues with verified routes (other than the small nit that it recfactoring routes also requires refactoring code). And if route helpers aren’t going away any time soon then there is less urgency for me to find an alternative implementation.

What I haven’t got conceptually clear in my head is that ~p is a compile time verification and helpers are a runtime (sort of) verification. I need to inspect the implementation of ~p but I’m assuming for now it returns an iodata type that still allows runtime interpolation into the path.

I’m interested to investigate your suggested localized_path(@locale, ~p"/pages/#{@page"}) approach. What would happen if I do:

~p[/Gettext.dgettext("routes", "pages")/#{@page}]

That’s the rough equivalent of what my library does today, except it does the translation at compile time generating the required routes for all configured locales. And then at runtime there is no overhead doing translation - my localised helpers are only deciding which of the explicit locale helpers to call.

It would be very unusual for ex_cldr_routes user code to call ~p"/pages_de/#{@page}" given that doing so requires explicitly determination of the current locale, deciding the canonical route, translating the route segments and interpolating. That’s basically a lot of boilerplate that the library is trying hard to simplify in the interests of a good developer experience.

My goals for localisation are (and have always been) that it should have immaterial impact on performance, be no more complex the non-localised code and be as compatible as possible at the API level with existing Elixir code.

If they went away it wouldn’t be until Phoenix 2.0, of which there is currently no timeline or planned milestones. There would probably also be a separate lib that adds them back for legacy apps, but we’re not close to worry about that at the moment.

Agree, I was just keying off where the ex_cldr docs start by showing the different route helpers.

That works, but you want to verify the static segments at compile time, so I would instead build the interpolation ast so it produces:

~p[/pages_#{locale}/#{@page}]

You could also inject a conditional branch that reads the runtime value and does two different ~p’s. Btw, ~p itself simply returns a string at runtime, but your macro would rewrite the {:sigil_p, _, _} ast.

That’s the tricky bit. With localised routes, assuming a configuration of locales :es, :de and :en, and the router has:

defmodule MyApp.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router
  use MyApp.Cldr.Routes

  localize do
    get "/pages/:page", PageController, :show
  end

The there are actually three routes generated:

get "/pages/:page", PageController, :show, private: %{locale: :en}
get "/paginas/:page", PageController, :show, private: %{locale: :es}
get "/seiten/:page", PageController, :show, private: %{locale: :de}

You’ll see that localised routes isn’t only (or even often) about interpolating the locale into the route - that would be an exception in many cases. Its more about translating the path segments (this relates a lot to SEO and overall customer engagement).

Using helpers, a single helper function can decide what path to generate based upon the locale at runtime. And of course route recognition happens as normal, no special handling required.

With the ~p sigil, I don’t know that its possible to have compile-time route verification given the the path to be returned cannot be known until runtime since it is based upon the locale.

Translations are available at compile time, so I feel like it should be possible to figure out the language dependent segments at compile time and then return something akin to this:

# ~x"/pages/#{@page}"
case @locale do
  :en -> ~p"/pages/#{@page}"
  :es -> ~p"/paginas/#{@page}"
  :de -> ~p"/seiten/#{@page}"
end
2 Likes

Exactly! This is what I meant about branching

1 Like

Ahh, the fog is lifting :-). Thank @LostKobrakai, that makes sense. And is a whole lot cleaner than the helper approach (which in all transparency was a mind-bending pain to make work well for localisation).

Thanks to you and @chrismccord for having the patience to get my brain back in the right place.