Using a Phoenix app as a dependency - a start

The purpose of this post is start the process of modifying an existing Phoenix application (a Child) so it can be used by another Phoenix applications (a Parent) as a dependency. There are two victory conditions:

  1. The Child application is fully independent and works as a standalone when running mix phoenix.server or mix test.
  2. The Parent application requires minimal modification to enable passing %Conn{} information to child.

TLDR: Final code is located here.

Initial issues

There are some initial issues in tackling this problem and most of them stem from figuring out how to properly route requests that belong to the Child from the Parent.

Phoenix.Router provides the forward macro which allows you to map a url endpoint in a router to an arbitrary Plug. However, the documentation specifically states

Note, however, that we don't advise forwarding to another
endpoint. The reason is that plugs defined by your app
and the forwarded endpoint would be invoked twice, which
may lead to errors

This makes sense as the Plug pipelines in each Endpoint transform the %Conn{} specific to that endpoint. So if you follow the recommendation and forward the url to Child.Router instead of Child.Endpoint you are avoiding this problem, but you are also missing out on crucial plugs like Plug.Static which will load static content inside the Child.Endpoint. You would have to start unloading that functionality to Plug.Static in Parent.Endpoint which would start messing with our second victory condition.

Injecting into the Endpoint Pipeline

To avoid the cautionary statement above, I decided that it would be best to intercept the request before it hits the Parent.Endpoint. As Endpoint is composed of Plugs I thought I could create a __using__ macro inside my Child that the Parent could invoke to capture any connection destined for the Child application. What I came up with was this in child/lib/child/mount.ex:

defmodule Child.Mount do

  defmacro __using__(path: path) do
    quote bind_quoted: [path: path] do
      unless String.starts_with?(path, "/"), do: path = "/" <> path
      plug Child.Plug.Mount, path
      socket "#{path}/socket", Child.UserSocket
    end
  end

end

which could invoked from the Parent.Endpoint like this in parent/lib/parent/endpoint.ex:

defmodule Parent.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app
  use Child.Mount, path: "/test"

All you need to do is pass the path you want to mount the Child application on as a path value. The macro itself does a couple of things. First it makes sure that the path starts with a /. Next it invokes another plug called Child.Plug.Mount which will do all the heavy work. Lastly it also declares a socket on the passed path, so that any sockets that we are using in our Child are also available through the Parent.

Next I defined Child.Plug.Mount in child/lib/child/plug/mount.ex:

defmodule Child.Plug.Mount do
  import Plug.Conn

  def init(default), do: default

  def call(conn, path) do
    if String.starts_with? conn.request_path, path do
      conn
      |> assign(:mount_path, path)
      |> Phoenix.Router.Route.forward(
        [String.replace(path, "/", "")],
        Child.Endpoint,
        [])
      |> halt
    else
      conn
    end
  end
end

The code contains the required init/1 and call/2 functions. call/2 checks if the request_path inside our conn matches the mount path we declared. If it does, it assigns the path as a variable :mount_path to the conn and then uses Phoenix.Router.Route.forward/3 to invoke the Child.Endpoint. Following that we need to call halt otherwise the Parent.Endpoint plug pipeline will get called as well. If the request_path does not match it should just pass the conn back. Please note that both of these files are located inside the Child app, which also helps with Victory Condition #2

What is the assign(:mount_path) variable about?

So there are a couple of other issues when you are invoking a phoenix app from within a mount point. For example, <%= static_path(@conn, "/css/app.css") %> always returns /css/app.css unless you mess with Plug.Static. What you want though is to access the css under the path specified in the use call. ex: /test/css/app.css. You can do this by making the following changes to certain paths in your Child templates:

<%= static_path(@conn, "/css/app.css") %>

=>

<%= static_path(@conn, "#{@conn.assigns[:mount_path]}/css/app.css") %>

and

<%= static_path(@conn, "/js/app.js") %>

=>

<%= static_path(@conn, "#{@conn.assigns[:mount_path]}/js/app.js") %>

The nice thing is that if :mount_path is not defined it is just an empty string, thus ensuring Victory Condition #1.

So what about sockets?

If you remember we declared our Child sockets in our __using__ macro:

socket "#{path}/socket", Child.UserSocket

which means that any javascript socket call, (for example in /web/static/js/socket.js) needs to know about the moved socket. To do this we can make the following changes:

Declare a global ᶘᵒᴥᵒᶅ variable and then include that in our socket load paths:

Inside /web/templates/layout.eex:

</div> <!-- /container -->
<script> MOUNT_PATH = "<%= @conn.assigns[:mount_path] %>"</script>
<script src="<%= static_path(@conn, "#{@conn.assigns[:mount_path]}/js/app.js") %>"></script>

and then when invoking a socket connection - for example in socket.js:

let socket = new Socket(MOUNT_PATH + "/socket", {params: {token: window.userToken}})

Again if :mount_path is not defined, nothing bad happens.

uh … Victory?

Not sure - on the surface it works. To try it out yourself create a new Phoenix project and adding the following to your dependencies:

{:phoenix_dependency_test, github: "maxneuvians/phoenix_dependency_test"}

and then include it in your applications:

applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext, :phoenix_dependency_test]]

Now include the use call inside your Endpoint:

defmodule MyApp.Endpoint do
  use Phoenix.Endpoint, otp_app: :main
  use PhoenixDependencyTest.Mount, path: "/test"

where you can replace the path with an arbitrary mount point.

Run mix phoenix.server, hit the /test endpoint and then you should see something like this:

Image of PhoenixDependencyTest

which is the content for the Child

Potential problems

Here are some potential issues I am trying to work through:

  • Shared sessions - right now each app will have its own session
  • Database migrations - you would need to write mix tasks for your dependent applications
  • Complex applications - the sample Child app is really simple and there might be a lot more problems ahead

Conclusion

I am a lot more confident that I can get this to work than before I started. I wish there was a golden path to follow here that made it all simple and easy, but I could not find one. I am happy for any suggestions or comments, please email me at max@neuvians.io or send me a tweet at @maxneuvians.