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:
- The
Child
application is fully independent and works as a standalone when runningmix phoenix.server
ormix test
. - 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:
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.