Does Router support glob like [!a-e]

I want a catch all route , does Phoenix Router support something like this?

get “/[!a-e]*anyfollowingpath”, PageController, :catch_all

This can read like this: the first char is not in a to e, so can be f-zA-Z0-9-~% and follow with any path.

I tried, not successful, but Phoenix.Router doc mentioned support glob-like patterns.

3 Likes

The documentation was not very clear on it, so I dove in the source of Phoenix.

The comments at that location state:

  # Handle each segment match. They can either be a
  # :literal ("foo"), an :identifier (":bar") or a :glob ("*path")

Long story short: What is considered glob-syntax is some/static/base*anything where anything is the dynamic part (and I think that whatever dynamic value is entered will be stored in a field of the %Plug.Conn{} struct called anything in this case, but don’t quote me on that).

In any case:

  • It is not supported to have two glob-statements right next to each other.
  • The [!a-e]*-syntax you have in your example is not part of what is known as ‘glob syntax’. It is part of (Perl-style) Regular Expressions. Elixir allows the usage of regular expressions using e.g. ~r{[!a-e]*} which would match any symbol except any of a,b,c,d or e zero or more times. Unfortunately, Phoenix (or more accurately, Plug) does not support Regular Expressions in the routing paths, (it only accepts binary strings).
7 Likes

Great, thanks @Qqwy for your time to dig into the source code.

I were thinking if glob-like as mentioned in doc should already have basic implementation, I checked Wikipedia

The most common wildcards are *, ?, and […].

1 Like

The Phoenix router only supports trailing globs and in a way that we can rely on binary pattern matching. We don’t support regexes or extended globs because they would be a large perf hit to the way we dispatch in the router. They are also rarely needed, so we prefer explicit handling for those cases via a plain old plug. To get what you want, you can do something like the following:

defmodule Router do
  get "/", PageController, :index
  ... # other routes
  get "/*path", GlobRouter, []
end

defmodule GlobRouter do
  def init(opts), do: opts
  def call(%Plug.Conn{request_path: path} = conn, _opts) do
    cond do
      Regex.match?(path, ~r{[!a-e]*}) -> to(conn, PageController, :catch_all)
      Regex.match?(path, ~r{another}) -> to(conn, AnotherContorller, :other)

      true -> raise Phoenix.Router.NoRouteError, conn: conn, router: Router
    end
  end

  defp to(conn, controller, action) do
    controller.call(conn, controller.init(action))
  end
end

You could also use named captures in your regex and add whatever glob params to the conn.params before calling your controller. Hope that helps!

19 Likes

Thanks @chrismccord for provide the perfect solution.

I have another question related to this:
Does scope care about order in Router.ex ? I found if I use /*path in first scope it will block any other scope after it in source code.

The router patterns translate in a very simple way into a pattern match on the request path. In pattern matching order matters - it also matters in the router. Specifically /*path is like using a variable in the pattern match - everything matches, so no further clauses can ever match.

Probably phoenix marks the generated code as generated (surprise, surprise!) so no warnings are emitted, but I wonder if it would make sense to apply those marks more carefully, so a warning that some routes can never match is actually generated by the compiler analysing the generated pattern matches.

5 Likes

I followed Chris’ instruction and made it worked,

  Regex.match?( ~r/\/n{1}.*/ , path) -> to(conn,HelloPho.CatchAllController, :catch_start_with_n)
  Regex.match?( ~r/.*/ , path) -> to(conn, HelloPho.CatchAllController, :catch_all)

But I got something that from Cowboy that not catched in Phoenix. Is there a way to catch this as well?

00:20:53.944 [error] Process #PID<0.472.0> raised an exception
** (FunctionClauseError) no function clause matching in :cow_qs.urldecode/2
(cowlib) src/cow_qs.erl:335: :cow_qs.urldecode("%", “sdj546456&^$”)
(cowboy) src/cowboy_router.erl:331: :cowboy_router."-split_path/2-lc$^1/1-1-"/1
(cowboy) src/cowboy_router.erl:331: :cowboy_router.split_path/2
(cowboy) src/cowboy_router.erl:334: :cowboy_router.split_path/2
(cowboy) src/cowboy_router.erl:271: :cowboy_router.match_path/4
(cowboy) src/cowboy_router.erl:169: :cowboy_router.execute/2
(cowboy) src/cowboy_protocol.erl:442: :cowboy_protocol.execute/4
00:20:53.944 [error] Ranch protocol #PID<0.472.0> (:cowboy_protocol) of listener HelloPho.Endpoint.HTTP terminated
** (exit) an exception was raised:
** (FunctionClauseError) no function clause matching in :cow_qs.urldecode/2
(cowlib) src/cow_qs.erl:335: :cow_qs.urldecode("%", “sdj546456&^$”)
(cowboy) src/cowboy_router.erl:331: :cowboy_router."-split_path/2-lc$^1/1-1-"/1
(cowboy) src/cowboy_router.erl:331: :cowboy_router.split_path/2
(cowboy) src/cowboy_router.erl:334: :cowboy_router.split_path/2
(cowboy) src/cowboy_router.erl:271: :cowboy_router.match_path/4
(cowboy) src/cowboy_router.erl:169: :cowboy_router.execute/2
(cowboy) src/cowboy_protocol.erl:442: :cowboy_protocol.execute/4

Thank you @michalmuskala, I split scope / to two and moved other scope in between and that works.

Thanks for the tip @chrismccord !
I was getting this error:

no function clause matching in Regex.match?/2

so I changed this

Regex.match?(path, ~r{[!a-e]*}) -> to(conn, PageController, :catch_all)

to this:

Regex.match?(~r{[!a-e]*}, path) -> to(conn, PageController, :catch_all)

and it started working.

https://hexdocs.pm/elixir/Regex.html#match?/2

@chrismccord, thanks for sharing this solution. It helps a lot.

You could also use named captures in your regex and add whatever glob params to the conn.params before calling your controller.

How can I add custom data into conn.params?
Is playing with conn.params a good idea in this context, or should I use Plug.Conn.assign/3 ?

Thanks,
Marek

It is just an embedded map on the conn, you can edit it just like any other embedded map. :slight_smile:

If it is custom data then this is usually preferred. However if it is something related to the path or params I tend to stick it in the params, else on assigns.

1 Like

It is just an embedded map on the conn, you can edit it just like any other embedded map.

Ah, ok. I still learn Elixir syntax and I did a stupid mistake.

Thanks for the help.

This is awesome, I could now write Vue SPA within Phoenix with just config webpack, without necessarily splitting Vue-CLi and Phoenix for API