Phoenix crash course
modified 20/04/2024 22:57<2023-11-12 Sun>: let’s start learning some phoneix because why not. My goal is going to be building a 999.md clone in it. But I don’t know elixir at all, so I’m going to be picking up everything along the way. Let’s go.
Installation
On my Ubuntu I used asfd and will writing code VSCode. Pretty simple to install, just do
KERL_BUILD_DOCS=yes asdf install erlang 26.1.2 # this installs documentation for erlang too
asdf install elixir 1.15.7-otp-26 # make sure otp-<version> matches version of erlang
mix archive.install hex phx_new # installs phoenix
mix
is like a manage.py script from Django – it creates the base project and you can use it to build and start the app, generate migrations & so on but also much more.
For VSCode I installed the ElixirLS and Phoenix Framework extensions.
Just run mix phx.new hello
and your hello
app should be created.
Then, to create the database (postgres by default) run mix setup.
Elixir crash course
Go over to https://learnxinyminutes.com/docs/elixir/ and try typing everything in iex
which is a elixir repl. Play around with various inputs, like passing negative numbers to the elem
function or going over the bounds and see what happens. For everything else, you can also use https://elixirschool.com/en/, but we’ll be picking up everything as needed.
Phoenix project structure
I’ll only cover what’s necessary. For more, refer to the phoenix docs on this.
mix.exs
This is like a requirements + pyproject.toml file for Python but also a package.json for npm – define requirements and general config for the app.
defmodule Hello.MixProject do
use Mix.Project
This are our first language constructs: defmodule
defines a module, use
imports a library. We can read more about what Mix.Project
does by using the hover documentation, but esentially it exports a bunch of functions that allow configuring our project.
I wish I could go to the definition of it, and actually read the source code but VSCode doesn’t allow this.
We see functions defined by def
(public) and defp
(private).
.ex
vs.exs
.ex files are only compiled, while .exs are meant to be compiled and executed (exscript, I guess?); so use .exs for scripts, .ex for everything else.
How does it work?
Skimming through the source code, the main functionality is done by the push
function which when called when a module is compiled uses the module inside mix.exs
as the config atom. push
merges the map (like a python .update() dict method – overriding using the last value) using [app: app] (we define it in our project function) ++ (list concatination) a default configuration + our actual config (atom.project
). This is how the project
function actually gets called, and the stuff returned from it is used to override everything previously in the dict.
I’ll assume that the other functions work/are being called the same way (call the specific function defined in a module)
Phoenix dependencies
Let’s take a look at what we get out of the box:
ecto_sql
: sql binding for various database systemspostgrex
: postgresql driver, dicts are converted to sql types, which is pretty coolfloki
: this is like a beautifulsoup, meaning you can doFloki.find(document, "p.headline")
and you get the contents of the pargraph with the class headline. Useful stuff, but it’s only defined for the test env, so I’m assuming it’s used only for testing that the html matches what we expectesbuild
: this bundles our js (phoneix comes out of the box with tailwindcss) in priv/static/assets/app.[css|js]swoosh
: allows creating email messages (content, as well as metadata)finch
: http client, can be used to make requests & send mail I guess?telemetry_metrics
andtelemetry_poller
: stuff for metrics&telemetrygettext
: localization&internationalizationjason
: json parser&encoderdns_cluster
: configure dns clustering (esentially, using the same domain for servers in different locations)plug_cowboy
: adapter for the Cowboy web server.
/config
This is where config files reside.
config.exs
Main config file, that uses the Config
module and imports (calls import_config
) the specific config based on the environment we’re using.
Interestingly, parantheses are optional, so we can do
config :hello,
ecto_repos: [Hello.Repo],
generators: [timestamp_type: :utc_datetime]
which is gonig to call config on the hello atom and provide some general config, then
config :hello, HelloWeb.Endpoint,
url: [host: "localhost"],
adapter: Phoenix.Endpoint.Cowboy2Adapter,
render_errors: [
formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON],
layout: false
],
pubsub_server: Hello.PubSub,
which is going to call config again on the HelloWeb.Endpoint module and so on. This indenation looks awesome! And it’s like passing kwargs. You’re esentially calling config with [url: [host: "localhost"], adapter: ...]
- I wonder why not
%{host: "localhost"}
, but[host: "localhost"]
instead?[host: "localhost"]
is actually a list containing the tuple{:host, "localhost"}
, while%{host: "localhost"}
is an entirely different thing. I guess functions use atoms instead of maps.
runtime.exs
Here’s config that gets execuetd at runtime (i.e. reading from env): api port and host, db url, secret key
- Intersting word-matching:
x in ~w(true 1)
will match true or 1. ~w is called a sigil, there’s some more like ~r for regex, and more
dev|prod|test.exs
Manual config for different environments. I believe the convention is that for the dev and test envs we configure using dev.exs
while for prod, we use environment variables and rely on config.exs
.
/lib
Where the actual meat is.
/hello
hello.ex
This is where the business-logic-related stuff resides.
application.ex
: where the app is defined: telemetry, postgresql repo, dns cluster, pubsub, email http server and actual endpoint. Starting the app starts all of them. Everything else is some custom functionality (for the repo and the mailer)
/hello_web
& hello_web.ex
Where endpoints, routers, telemetry and internationalization is defined and configured.
controllers
components
Endpoints
API routing functionality happens inside the hello_web/router.ex
file. Here we see something like
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
For now ignore everything except get "/", PageController, :home
. What this does is map the method (get
) to the path (/
) using the :home
function of our PageController
(defined in controllers/
). What home
does is call render with the :home
atom, which renders the home.html.heex
page.
Adding a new endpoint
To add a new endpoint, we need 2 things:
- Adding a new entry in the “/” scope that’s going to point to the function of a new controller and
- A new controller, with a function that will perform some stuff.
HEEx
Defining a View in MVC means creating a html template. For this we use the convention <controller_name> + _html
.
We can use Phoenix’s own templating language using the ~H sigil that’s esentially Jinja but also a lot of other stuff like built-in xss protection. We can use functions for simple html, or actual templates for something more complex.
How it works
Esentially, we have 4 layers, all managed by various plugs.
- endpoint: stuff that happens on every request
- router: specific endpoints, their paths and methods
- controller: gets data from request, prepares data for rendering
- view: what actually gets displayed.
Plugs
Plugs are essentially some combination of middleware but also specification for design, and all components are built from plugs.
- Function plugs: must take and return and a connection. All other plugs can modify connection I guess?
- Module plugs: must have a
init
function (gets called when the plug actually gets added i think? ther return value will get passed to the call function) and acall
function, which is a function plug that does some stuff
Plugs can be set at different levels: endpoint (will get applied to every request), router (will get applied to all requests in a scope), controllers (allows executing custom stuff for specific actions only)
LiveView ToDo app
Let’s dive deeper and get some hands-on experience with Phoenix LiveView. I’ll be relying on https://github.com/dwyl/phoenix-liveview-todo-list-tutorial for this, but will try to make sense of everything I don’t understand on my own. (I’ll skip over tests and css/html stuff, but that’s simple enough to pick on your own I believe)
The controller
First real step (after creating & running app) is adding a PageLive
controller.
defmodule LiveViewTodoWeb.PageLive do
use LiveViewTodoWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Let’s disect this line-by-line
defmodule LiveViewTodoWeb.PageLive do
This defines a module calledLiveViewTodoWeb.PageLive
use LiveViewTodoWeb, :live_view
- This is a bit different from languages like Python; basically, elixir provides
alias
(which is like animport x as y
in Python),require
(which imports only the macros?),import
which imports macros + functions anduse
you allow a module to do something with the module you calleduse
in - In this case, using
use
willrequire
theLiveViewTodoWeb
module and will call theLiveViewTodoWeb__using__(:live_view)
macro. You can see what this does by going tolib/live_view_todo_web.ex
(it calls thelive_view
function which creates & returns our live view); this is really cool!!!
- This is a bit different from languages like Python; basically, elixir provides
@impl true
This essentially is a hint for other devs that this function is an implementation for a function, specifically – themount
function (aka callback).def mount(_params, _session, socket) do
This is the function that we’re implementing.
The router
As we saw, the router is where we specify the url and view.
The next step requries changing
get "/", PageController, :index
to live "/", PageLive
in the LiveViewTodoWeb
router, to essentially define a live view, instead of a regular one.
The Item
schema
Now comes the interesting part: the schema. Running mix phx.gen.schema Item items text:string person_id:integer status:integer
will generate both, a file for the model and the migration for it. Let us again go line-by-line
defmodule LiveViewTodo.Item do
use Ecto.Schema
import Ecto.Changeset
alias LiveViewTodo.Repo
alias __MODULE__
schema "items" do
field :person_id, :integer
field :status, :integer
field :text, :string
timestamps()
end
@doc false
def changeset(item, attrs) do
item
|> cast(attrs, [:text, :person_id, :status])
|> validate_required([:text])
end
end
use Ecto.Schema
We already saw thatuse
will import all the macros and then call the__using__
macro of the packagealias LiveViewTodo.Repo
Allows usingRepo
instead ofLiveViewTodo.Repo
to avoid writing entire thing.import Ecto.Changeset
This allows using the public functions insideEcto.Changeset
without having to call them asEcto.Changeset.<function>
alias __MODULE__
This makes it so we can use theItem
struct instead ofLiveViewTodo.Item
schema "items" do
schema
is a macro that allows defining the modelfield :status, :integer, default: 0
& other fields Pretty self-explanatory, we define fields and rules for themdef changeset(item, attrs) do
This is like a Django serializer but on steroids: you can use this for validation, pre-processing as well as other stuff, and everything inside the same method. It has to be called manually, though.item |> cast(attrs, [:text, :person_id, :status]) |> validate_required([:text])
Pipe operator(|>)x |> y |> z
is esentially the same asz(y(x))
so here we’re taking only the 3 specified fields, and makes sure thattext
is set, otherwise throwing an errordef create_item(attrs \\ %{}) do
Function definition;attrs \\ %{}
setsattrs
to a default, if not set.%Item{} |> changeset(attrs) |> Repo.insert()
We’re creating aItem
struct using the validated data passed inattrs
, and inserting it into the database. The syntax is so simple though.def get_item!(id), do: Repo.get!(Item, id)
By convention, functions that end with!
can throw an exception. This gets an object by its id, and throws an exception if it’s not found.update_item
,list_items
These two follow the same conventions, essentially validate usingchangeset
and call aRepo
function.
Live handlers
We’ve got our models, our router and our controller. But they’re not communicating in any way. The way we make them is by
- triggering an event
- handling the event
Point number 1 is addressed with the template. We add a form to our template with the phx-submit="create"
attribute, which will trigger the “create” event once submitted.
Point number 2 requires updating our live controller.
Here’s what it’s supposed to look like:
defmodule LiveViewTodoWeb.PageLive do
use LiveViewTodoWeb, :live_view
alias LiveViewTodo.Item
@topic "live"
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: LiveViewTodoWeb.Endpoint.subscribe(@topic)
{:ok, assign(socket, items: Item.list_items())} # add items to assigns
end
@impl true
def handle_event("create", %{"text" => text}, socket) do
Item.create_item(%{text: text})
socket = assign(socket, items: Item.list_items(), active: %Item{})
LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
{:noreply, socket}
end
@impl true
def handle_info(%{event: "update", payload: %{items: items}}, socket) do
{:noreply, assign(socket, items: items)}
end
end
-
@topic "live"
Stuff that starts with@
is a module variable, meaning we can use it anywhere in the module. We’ll use it as the name of our websocket channel. -
mount
functiondef mount(_params, _session, socket) do if connected?(socket), do: LiveViewTodoWeb.Endpoint.subscribe(@topic) {:ok, assign(socket, items: Item.list_items())} # add items to assigns end
The
mount
function is the first function that’s called byLiveView
. This subscribes to thelive
topic (to wait for events) and adds ourItem
objects to the socket, so that they can be accessed from the template. -
handle_event
function@impl true def handle_event("create", %{"text" => text}, socket) do Item.create_item(%{text: text}) socket = assign(socket, items: Item.list_items(), active: %Item{}) LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns) {:noreply, socket} end
As far as I understand it, we can define as many
handle_event
functions as we want. We use pattern-matching to tell Phoenix which event to handle (in this case, “create”). Keep in mind thathandle_event
functions are only for events triggered by the client (i.e stuff from the template) So, what we do is create a item, add it to the socket, (not sure whatactive: %Item{}
does) and callbroadcast_from
to trigger anupdate
event. -
handle_info
function@impl true def handle_info(%{event: "update", payload: %{items: items}}, socket) do {:noreply, assign(socket, items: items)} end
We need a
handle_info
to handle events created by elixir (like theupdate
event that we triggered inLiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
. According to docs, we send:noreply
to specify that no additional information was sent (though I don’t know what that means), and callassign
to add the items to our socket; same as inmount
, but we’re given the items here instead of fetching them from the database for some reason.
Let’s now display our items.
<ul class="todo-list">
<%= for item <- @items do %>
<li data-id={item.id} class={completed?(item)}>
<div class="view">
<%= if checked?(item) do %>
<input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" checked />
<% else %>
<input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" />
<% end %>
<label><%= item.text %></label>
<button class="destroy" phx-click="delete" phx-value-id={item.id}></button>
</div>
</li>
<% end %>
</ul>
This looks pretty similar to Django/Jinja templates. We’re iterating over all items, checking if the item is completed (with the checked?
function we didn’t define yet); if so, we’re setting the checked
attribute. We also have an destroy
button that will call a destroy
event (which we didn’t define yet).
Define the functions
def checked?(item) do
not is_nil(item.status) and item.status > 0
end
def completed?(item) do
if checked?(item), do: "completed", else: ""
end
…and we’re now ready to add and read items!
Live Components
This is working, but it’s kinda split across a bunch of modules and files. And the template renders everything. This is ok for a small number of objects (we only have items) but once we add multiple objects, it’s going to become a mess.
To prevent this, we can group together everything regarding one object in the same place, using a Live Component, which is going to have a render
function. And in our template, we’ll only call it.
That’s it
You’re now ready to build whatever! Just use google and the docs whenever you need to know how to do something.