Random Musings

O for a muse of fire, that would ascend the brightest heaven of invention!


I Can Haz Phoenix?

Monday, 16 Oct 2023 Tags: formsphoenixtutorials

Phoenix is an incredible web framework, but how does it even work under the hood?

We’ll take a tour from the default web page, through the various files, and end up building our own simple web form.

Build a default app & run it

$ mix phx.new --binary-id --no-tailwind forms && cd forms
$ iex --name forms -S mix phx.server
Generated forms app
[info] Running FormsWeb.Endpoint with Bandit 1.0.0 at 127.0.0.1:4000 (http)
[info] Access FormsWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes...

/ getting to the default page

When we go to the default page, / what happens?

First up, our browser opens a new network connection via a TCP socket, to the Phoenix server’s endpoint.

This is provided by a webserver. In Phoenix parlance, this is called an adapter, and in our case we are using Bandit to provide this. It’s a great webserver, designed specifically for Phoenix, and written in Elixir. The Phoenix endpoint is the top-level application that the Erlang VM starts, that runs Bandit on our behalf.

However we’re not interested in this part at the moment, so let’s stay above the networking layer, and just focus on what Phoenix is doing. Just remember that the webserver Bandit, is started automatically for us by Elixir, via the endpoint, and the endpoint hands over control of each new connection to Phoenix for us.

Browsing to http://localhost:4000/ we are shown the default Phoenix page, and in our iex console, we also see:

[info] GET /
[debug] Processing with FormsWeb.PageController.home/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 158ms
🧪

OK! we can see our app received a GET / request, and that this was handed over to the FormsWeb.PageController.home/2 function, with an empty map of parameters, and through the browser pipeline.

Looking for pipeline or :browser in our application files, we find this is in the forms_web/router.ex file.

But how did the request get from the endpoint to the router? And how did the router know where to send, or dispatch it? Looking for Endpoint in the code, we see a file called forms_web/endpoint.ex. This is actually hard-wired in various config/config.* files.

This links the starting application to the endpoint module that runs for every request.

Looking at the first and last few lines, skipping the middle we can see how the endpoint module finally ends up calling the FormsWeb.Router module:

defmodule FormsWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :forms
...
  plug Plug.Static,
    at: "/",
...
  plug Plug.Session, @session_options
  plug FormsWeb.Router
end

Again, let’s defer digging deeper into Plug, just noticing that it appears repeatedly, handling various types of functionality, from serving static files, to reloading, telemetry, parsers, and session handling. It looks stackable, or composable.

Let’s leave plugs and the endpoint, and head over to the router via FormsWeb.Router, which is the final plug in the endpoint module. This is defined in forms_web/router.ex.

the router

defmodule FormsWeb.Router do
  use FormsWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {FormsWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end
...

The router looks very similar to the endpoint. It receives a web request from the endpoint, and passes it through the defined pipeline of plugs, each one refining the request further.

Each plug is a function that complies with a common set of input & output parameters, so that they can be chained together. The plug uses a conn struct to achieve this.

This may seem confusing at first, but the router is handing the conn from the webserver, repeatedly through a list of functions in the pipeline, each one modifying the conn as it needs to, building up headers and parameters incrementally.

Because of this standard struct, anybody can write a custom plug that matches the same interface, or typespec (function arity, both as parameters and returned types), to do anything from logging, to authentication and redirection.

The finalised conn is the result of the webserver breaking up all the raw strings sent via the browser, validating them, and turning them into the specific page requested, the type of web request, and any further URL parameters or settings that may be present, for us.

This is passed into a scope, which is a convenience to group routes with common pipelines and plugs. Within the scope, finally there is the link from the request to the module that handles it:

defmodule FormsWeb.Router do
  use FormsWeb, :router
...
  scope "/", FormsWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
...

It looks like get "/" defines a relationship between the request method GET and request path / to a module PageController, and presumably a function, :home.

Each function receives the processed conn, as a%Plug.Conn{} struct that was already extracted from the web request, through the plug pipeline.

the controller

forms_web/controllers/page_controller.ex is a pretty short file, with a single home function as expected:

defmodule FormsWeb.PageController do
  use FormsWeb, :controller

  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end
end

render/3 takes 3 parameters:

  • the %Plug.Conn{} struct
  • a template name
  • assigns that are merged overtop of assigns within the conn

The template name is used to define which template is used to render the web page. This is finally where the user-supplied query is passed to a function that can generate the actual page.

If you remove layout: false and use render/2 instead, you’ll see a header injected at the top of the page. This is from app.html.heex, if you search for Get Started in the code.

templates and layouts

All renderable templates end in eex - there are a few types, but for the moment only the .heex ones are of interest.

Layouts are containers that templates can be rendered inside. A great example is the standard metadata, headers, and nav components that are displayed on every page. If you’re rendering plain text, or structured data like JSON or XML, likely the layout is not required.

Looking again for files called home and that end in .heex there is forms_web/controllers/page_html/home.html.heex.

The single most obvious thing about this file is that it’s not valid HTML, there is no doctype, no head, no meta tags, nor any body.

Somewhere there is a wrapper somewhere that this is inserted into. Let’s see where that is.

The easiest way to find this is to search for <!DOCTYPE html> which is used by the web browser to identify that this page is HTML5 and not a preceding version. Luckily, searching for DOCTYPE alone is sufficient, and this leads us to a layout in forms_web/components/layouts/root.html.heex.

This leads us to the overall structure:

  • root.html.heex provides the outer-most layer for all web pages
  • if layout: false is set, app.html.heex is included in the body
  • next, the requested template is rendered
  • in many cases, this template will include a component which provides flash messages and some error handling if the server websocket connection is lost

walking the templates

root.html.heex the outer layout

The @inner_content includes different components depending on whether layout: false is set. It’s also possible to select different layouts but let’s not cover this just yet.

<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" · Phoenix Framework">
      <%= assigns[:page_title] || "Forms" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>
  <body class="bg-white antialiased">
    <%= @inner_content %>
  </body>
</html>

Note the {~p"/..."} sigil that ensures routes are verified at compile time, rather than waiting for a run-time crash.

app.html.heex providing headers

This is ideal for nav bars and other similar header functionality, while users are logged-in or working within your application.

This is trimmed for clarity - here we can see a typical template insertion where the <%= ... %> operator allows running normal elixir functions:

<header>
...
      <p>
        v<%= Application.spec(:phoenix, :vsn) %>
      </p>
...
</header>
<main>
  <div>
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

Finally a further assigns is included, <%= @inner_content %> which fetches the requested :home template itself. This file is located under forms_web/controller/page_html/. Other controller’s templates will live in their corresponding private folder.

home.html.heex the final content

OK that’s not quite true. The second line in this template pulls in a Core Component template, which is a generated component that provides a few liveview related helpers, tracking connected state, adding javascript hooks, and so forth.

At this point we are mainly interested in the syntax to define links, and how the <.xyz /> syntax is used to inject or invoke components to be included in the rendered template.

<link phx-track-static rel="stylesheet" href={~p"/assets/home.css"} />
<.flash_group flash={@flash} />
...

Let’s add a new page, to accept messages from visitors to our site. It’s possible to do this via mix phx.gen.html and similar generators as well, but rolling it by hand is the path to understanding.

/contact a new page

We now need to add a route, controller, views, and templates.

/contact router

We’ll need to add two routes in forms_web/router.ex:

  • GET for displaying the form
  • POST for handling the form submission from the browser to our app
  scope "/", FormsWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/contact", ContactController, :new      # added
    post "/contact",  ContactController, :create # added
  end

Use the phx.routes command to validate this is correct:

> mix phx.routes
Compiling 1 file (.ex)
warning: FormsWeb.ContactController.init/1 is undefined (module FormsWeb.ContactController is not available or is yet to be defined)
Invalid call found at 2 locations:
  lib/forms_web/router.ex:21: FormsWeb.Router.__checks__/0
  lib/forms_web/router.ex:22: FormsWeb.Router.__checks__/0

  GET   /         FormsWeb.PageController :home
  GET   /contact  FormsWeb.ContactController :new
  POST  /contact  FormsWeb.ContactController :create
  ...

/contact controller

The error message for the missing page leads us to the next step, adding a controller.

The controller is responsible for choosing the correct function to call, and passing in any parameters needed. These functions take parameters, and call a render/2 function that returns the required HTML. app.

For the moment, continue using layout: false to keep the generated HTML simple – remember this just excludes the title bar and headers from the page, which is injected via .../layouts/app.html.heex.

# lib/forms_web/controllers/contact_controller.ex
defmodule FormsWeb.ContactController do
  use FormsWeb, :controller

  def new(conn, _params) do
    # returns a simple HTML form
    render(conn, "new.html", layout: false)
  end

  def send(conn, _params) do
    # basically write a flash saying thanks for your comment
    render(conn, "send.html")
  end
end

When this page is requested, ContactController.new/2 is called. The function render(conn, "new.html", layout: false) will only render the outer root.html.heex layout, because layout: false is set, and fail with:

ArgumentError at GET /contact no “new” html template defined for FormsWeb.ContactHTML (the module does not exist) the module exists but does not define new/1 nor render/2)

ContactHTML view

The ContactHTML module is a small wrapper around the HTML .heex template which we’ll add soon. It’s a good place to add small bits of functionality that aren’t dependent on the application’s data model, like date. These functions are directly accessible inside the template:

# lib/forms_web/controllers/contact_html.ex
defmodule FormsWeb.ContactHTML do
  use FormsWeb, :html

  def today() do
    Date.utc_today()
  end

  embed_templates "contact_html/*"
end

Like much of Phoenix and Elixir, files can generally go anywhere you like. In practice, grouping Controllers, Templates, and Components together is a good idea.

Reloading the page now produces almost the same error:

ArgumentError at GET /contact no “new” html template defined for FormsWeb.ContactHTML (the module exists but does not define new/1 nor render/2)

So add a file, lib/forms_web/controllers/contact_html/new.html.heex:

<section>
    <h1>Contact <%= today() %></h1>
</section>

Note how the function today/0 defined in ContactHTML is available.

Reload the page if needed, and you’ll see today’s date. Excellent!

Note that it’s possible to include the heex format template directly in our view module just above, as a function, wrapped in the ~H sigil:

...
  def new(assigns) do
    ~H"""
    <section>
      <h1>Contact <%= today() %></h1>
    </section>
    """
  end

The next stage is to add a simple form control, and a button, to the template in contact_html/new.html.heex, but we will use this chance to introduce another of Phoenix’s helpful tools to encapsulate things.

TODO

  • explain links, validated paths, assigns syntax
  • Generators