Serve a Plug router app from Phoenix

Hello,

I’m working on a Plug application and I’m trying to find an easy way to “mount” it into a Phoenix app or another Plug app.

My Plug application is built on Plug.Router, and responds to some GET requests to render HTML and POST/PATCH/DELETE from the clients. It’s meant to be a web dashboard and control panel for another package I’ve been working on.

I can successfully run it standalone with Plug.Adapters.Cowboy.http(...), but I would like to make it embeddable into a host application. The use case is that you’d use the main package for its functionality, and the web control panel would be an optional extra that you can serve from the application itself. This practice is common in Ruby on Rails, for example.

An additional requirement is that it should be possible to mount it at an arbitrary path. My app needs to be aware of this path, or namespace, because it needs it to build the relative paths in its HTML pages (e.g. <form action="/custom-namespace/foo">).

Now, I’m having some troubles finding a good way to integrate it.

I’ve found this other package that does something similar (I guess that’s a typo and they actually meant “Using with Phoenix”), but the API seems clunky:

# phoenix router
pipeline :exq do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :put_secure_browser_headers
  plug ExqUi.RouterPlug, namespace: "exq"
end

scope "/exq", ExqUi do
  pipe_through :exq
  forward "/", RouterPlug.Router, :index
end

To be honest I don’t like the repetition, and I’m wondering if it’s really necessary, but I’ve tried this approach nonetheless.

In that example, ExqUi.RouterPlug will use the KW option to set that namespace in conn.assigns, which will then be used by the Router. Interestingly, the plug will invoke the router even though the forward will basically do the same thing a couple of lines later. This works because all the routes in that Plug router end with halt(), which will make the forward pointless or, rather, it’s there just to make the Phoenix router’s pattern matching work.

I’ve tried to adapt my own Plug router (or use a separate plug) so that I could plug it directly into the pipeline, but if I don’t declare anything in scope it will complain because there are no routes matching the request (which makes sense, I guess).
If I add the forward statement a few lines later in my scope and add halt() in all the routes in my plug router, it works. As I said, however, I don’t really like the repetition.

While I would appreciate comments on whether there is a more elegant variation on that aproach, I can think of two other ways to do what I want.

The first one is to configure the path namespace in the Mix config file. It would work, but I’d have to duplicate that info in two places.
The alternative is that I could just forward to the router, and then extract the first path fragment and set it in conn.assigns for later use.

Is there a better and established way?

I’ve come up with another way to do it.
I’ve noticed the init_opts option in the docs for Plug.Router.forward/2. Apparently something similar is also supported in Phoenix.Router.forward/4, although it’s not well documented.

So, knowing that, I can do:

  pipeline :mounted do
    plug :accepts, ["html"]
    plug :put_secure_browser_headers
  end

  scope path: "/mounted" do
    pipe_through :mounted
    forward "/", MyPlugRouterApp, namespace: "mounted", foo: "bar"
  end

There, [namespace: "mounted", foo: "bar"] will be passed to the init/1 and call/2 functions of my plug router.

As you’ve found, forward/3 is the answer, but to expand, every macro in the router wires up Just A Plug™, so even things like get "/foo", MyFooPlug, opt1: :thing works. To forward all requests at a path for a Plug.Router, the forward macro is the way to go.

3 Likes

Thanks Chris.

Yes, I’m quite happy with the forward/3 solution. That’s the kind of API I was hoping for. :thumbsup:

For further reference, I’ve documented how to accomplish this in the readme of the library I was working on.