Constraints in Routes

Hi everybody,

I wanted to know if we can have constraints in the route level. I mean defining a route with some constraints (based e.g. on the request sessions data).

Sorry if I do a parallel with rails, but in rails (as explained from here in the guides) we can have constraints on the routes.

The constraints I’m particularly interested in are those based on lambdas.
Let me give you an example use case I had in rails…
I have different kind of users (regular, admins, etc.) where I have its type stored in a session variable once logged in.
Then I can do the following:

constraints lambda { |req| req.session[:user_type] == 'Admin' } do
  get  '/dashboard', to: 'admin_dashboard#show', as: 'dashboard'
  ...
end

constraints lambda { |req| req.session[:user_type] == 'User' } do
  get  '/dashboard', to: 'user_dashboard#show', as: 'dashboard'
  ...
end

This example is a little extract and might not tell all the story but as you might noticed it’s somehow an authorization mechanism.

I can now have some resources controllers scoped to the type of users and I know that, thanks to the constraints at the route level, I will never have some authorization mishaps.
In more complex situations I can have only the show action for a user while having the whole resources actions for an admin.

Doing it like this let me not bother at all at the controller level and hence I don’t have the need to import any authorization package or code a complex custom authorization handler.

In the Router doc page, there is no mention at all of the word constraint.

So, do you know how I can do this?

Note: elixir being functional and Phoenix being based on plugs, I’m pretty sure that I can easily achieve this kind of constraint based routes, thanks to plugs, pipelines, forwarding routes and pattern matching, right? I just need some guidance.

Routes are solely based on the request path.

Any additional constraints can be checked by addtional plugs in the controller or the actions logic itself.

In my opinion having an /admin scope is more clean than routing the same path to different controllers.

1 Like

Does the conn object available at that moment? Where I suppose session data can be accessed?

This is exactly what I wanted to avoid.

In fact on my rails project I’m actually doing scoping but only for the controllers and not for the path.

constraints lambda { |req| req.session[:user_type] == 'Admin' } do
  scope module: 'admin' do
    get  '/dashboard', to: 'admin_dashboard#show', as: 'dashboard'
    ...
  end
end

constraints lambda { |req| req.session[:user_type] == 'User' } do
  scope module: 'employee' do
    get  '/dashboard', to: 'user_dashboard#show', as: 'dashboard'
    ...
  end
end

Having the exact same path for the resources really simplify the whole. And then all the actions for a specific kind of user can perform only its related actions defined in its controller.
In my use-cases I had 6 types of users with some other resources. And I have several combinations of what a kind of user can do (view/edit/delete) to other users and other resources.
Trust me, filtering right at the route level simplified so much the whole codebase and everything worked very well without using any kind of authorization library.

So in conclusion, is it simply impossible to have constraints at the route level with Plug/Phoenix?

In the meantime I came across the following SO question where the OP asked something similar about constraint based routes. Unfortunately, there isn’t any solution in his question…

The phoenix router can be so fast, because it works by harnessing elixir’s pattern matching. Therefore a certain url either matches to a path registered in the router or not. If you have multiple routes matching the first one will win. For the pattern match the only variables to match on are the path, host and method.

Given those constraints you cannot handle the logic you’re looking for in a phoenix router. This is not to say that you cannot nest your plug hierarchy more to get to the same resulting functionality. You could have one router match the path /dashboard, which forwards to a plug, which depending on your constraints decides which controller to call.

3 Likes

As a noob myself, can you provide me some details about how to do that (with very little snippet of code)?
Particularly how I can load a plug from a route and how I can call a controller/action from a plug?

Looking at the docs, I can only go so far (which is completely guesstimating some arbitrary code)

In the main router.ex file

#MyAppWeb.Router
pipeline :authorize do
  plug :authorize_user
end

...

scope "/", pipe_through :authorize

Then in a dedicated Plug Module (but I’m not sure at all if the following can work or even compile at all)

defmodule MyApp.Plug.AuthorizeUser do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    # 1. Checking in the Plug.Conn object conn for the value stored in a session variable
    # Which one? is it in conn["session"]?

    # 2. Somehow forwarding to the dedicated sub-router
    # To be done with pattern matching btw.. It's just an example here :)

    case conn["session"]["user_type"]
      "admin" ->
        forward("/", AdminRouter)
      "user" ->
        forward("/", UserRouter)
      ...
    end
  end
end

Now I can have dedicated Router modules that routes only to authorized and dedicated controllers/actions.
For example for the admin:

# AdminRouter
defmodule MyAppWeb.AdminRouter do
  ...
  resources "/users", Admin.UserController
  ...
end

And for a user:

# AdminRouter
defmodule MyAppWeb.UserRouter do
  ...
  resources "/users", User.UserController, only: [:index, :show]
  ...
end

Again this is a little example, but in my use case I can have very complex inter-related authorized actions, and as you can notice having scoped controllers give me the ability to have completely different behavior for each kind of user.

Thank you for any guiding about how I can achieve this.

I was thinking about something akin to this.

# router
get  '/dashboard', ControllerByTypePlug, action: :show, controller: %{
  admin: Admin.DashboardController,
  user: User.DashboardController
}

# plug
defmodule MyApp.ControllerByTypePlug do
  import Plug.Conn

  def init(opts) do
    action = Keyword.fetch!(opts, :action)
    controller = Keyword.fetch!(opts, :controller)
    unless [:admin, :user] in Map.keys(controller) do
      raise "missing controller mapping"
    end
    %{action: action, controller: controller}
  end

  def call(conn, %{action: action, controller: controller}) do
    case type_to_atom(conn["session"]["user_type"]) do
      {:ok, type} -> 
        controller = Map.fetch!(controller, type)
        controller.call(conn, action)
      {:error, _} -> # handle error
    end
  end

  defp type_to_atom(str) when str in ["admin", "user"], do: {:ok, String.to_atom(str)}
  defp type_to_atom(str), do: {:error, str}
end

But if you can have separate routers per user type that’s even simpler and imho the even cleaner solution.

2 Likes

In my attempt above (and in my rails project) it’s the case.
Indeed, put in other words I wanted to know if I can apply a whole Router module in a particular case (in my attempt I used forward but it’s not working)…

The problem in your example is that it work only for a given list of routes (first extract part of the router).
It would have been great to be able to apply a whole Router module so that in each case the whole routes can be different and dedicated.

Don’t know if this explanation is clear or not, but I’m a bit disappointed to learn that I cannot do what it’s easy in rails about constraints in routes…

Phoenix might be constraint in what you can do directly on the router level, but on the otherhand as everything is a plug you can quite easily nest/switch out stuff.

# endpoint
plug :fetch_session # So session is available
# plug MyAppWeb.Router
plug MyAppWeb.Plug.DependantRouter

# This one forwards to the actual routers.
defmodule MyAppWeb.Plug.DependantRouter do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    case conn["session"]["user_type"]
      "admin" -> AdminRouter.call(conn, [])
      "user" -> UserRouter.call(conn, [])
      ...
    end
  end
end
2 Likes

This!

calling the router was the missing part and now it seems that I’ll be able to mimic somehow what I was doing with rails.

Thank you!