URL shortener with Elixir Phoenix 1.6 and LiveView

Search for a command to run...

I wrote a script called phx-docker-compose-new that can be used instead of mix phx.new to generate a new Phoenix application. Building the development environment for a Phoenix application using Docker Compose is convenient because you can set up not...
この記事について Raspberry Pi、TensorFlow、Pythonのいずれにも詳しくない筆者が、物体検出をやって楽しんだ成果の記録です。 TensorFlow公式の物体検出のサンプルプログラムを実行します。 動作環境 ボード Raspberry Pi 4 Model B OS Raspberry Pi OS (32-bit または 64-bit) デスクトップ環境 カメラ Raspberry Pi カメラモジュール v2 Python Python ...
One day I was trying to upgrade one of my Phoenix 1.5 apps to Phoenix 1.6. In that app, I customize Bootstrap styles using SCSS. I want to talk about what I found and stumbled on when setting up SCSS. TIL All explained in Phoenix 1.6 Asset Managemen...
This is written in Japanese. I might convert it to English later, maybe. はじめに Elixirのテストでモックを用意するときに利用するElixirパッケージとして、moxが人気です。Elixir作者のJosé Valimさんが作ったからということもありますが、ただモックを用意するだけではなくElixirアプリの構成をより良くするためのアイデアにまで言及されているので、教科書のようなものと思っています。 一言でいうと「その場...
TIL Access supports keyword lists (Keyword) and maps (Map) out of the box, but not structs. There is a reason why Elixir does not implement Access behaviour for structs. Perfer data.key for accessing predefined atom keys of map or struct. Access be...
I wanted to make a simple URL shortnener app like Bitly using Phoenix.LiveView.
This article called URL shortener with Elixir and Phoenix by Aleksi Holappa was helpful when I started off.
elixir 1.12.3-otp-24
erlang 24.1.1
phoenix 1.6.2
If we do not alrealy have a Phoenix app, we can generate one by running the mix phx.new command with our prefered options.
mix phx.new my_app --database sqlite3 --no-mailer
Create a database.
mix ecto.create
The mix phx.gen.live generator generates LiveView, templates, and context for a resource.
I call it ShortLink but it is arbitrary.
ShortLinksShortLinkshort_linksmix phx.gen.live ShortLinks ShortLink short_links \
key:string \
url:text \
hit_count:integer
In priv/repo/migrations, we have a migration file that is generated by the mix phx.gen.live generator above.
Before running migration, we want to improve the content of the migration file. Here are things I would do:
:key and :url columns0 on :hit_count column:key columnOur migration file might look like this:
# priv/repo/migrations/20211013125905_create_short_links.exs
defmodule MyApp.Repo.Migrations.CreateShortLinks do
use Ecto.Migration
def change do
create table(:short_links) do
add :key, :string, null: false
add :url, :text, null: false
add :hit_count, :integer, default: 0
timestamps()
end
create index(:short_links, [:key], unique: true)
end
end
Then run the migration.
mix ecto.migrate
We want to tweak the schema for validations etc. I used ecto_fields package for URL validation.
# lib/my_app/short_links/short_link.ex
defmodule MyApp.ShortLinks.ShortLink do
use Ecto.Schema
import Ecto.Changeset
schema "short_links" do
field :hit_count, :integer, default: 0
field :key, :string
field :url, EctoFields.URL
timestamps()
end
@doc false
def changeset(short_link, attrs) do
short_link
|> cast(attrs, [:key, :url, :hit_count])
|> validate_required([:url])
end
end
Now that our database is set up, we want to prepare functions that are necessary for our business, URL shortening, in our context modules. Here are some operations we want to add:
keykey columnhit_count# lib/my_app/short_links.ex
defmodule MyApp.ShortLinks do
import Ecto.Query, warn: false
alias MyApp.Repo
alias MyApp.ShortLinks.ShortLink
# alias MyApp.ShortLinks.PubSub # TODO: uncomment later
def get_short_link_by_key(key), do: Repo.get_by(ShortLink, key: key)
def create_short_link(attrs \\ %{}) do
attrs = maybe_assign_random_key(attrs)
%ShortLink{}
|> ShortLink.changeset(attrs)
|> Repo.insert()
# |> PubSub.broadcast_record(:short_link_inserted) # TODO: uncomment later
rescue
# Retry in case the same key already exists in database
Ecto.ConstraintError ->
create_short_link(attrs)
end
# Generates and assigns a random key when key is blank.
defp maybe_assign_random_key(attrs) do
if attrs["key"] in [nil, ""] do
Map.put(attrs, "key", random_string(4))
else
attrs
end
end
defp random_string(length) when is_integer(length) do
:crypto.strong_rand_bytes(length)
|> Base.url_encode64()
|> String.replace(~r/[-_\=]/, "")
|> binary_part(0, length)
end
def update_short_link(%ShortLink{} = short_link, attrs) do
short_link
|> ShortLink.changeset(attrs)
|> Repo.update()
# |> PubSub.broadcast_record(:short_link_updated) # TODO: uncomment later
end
def increment_hit_count(short_link) do
update_short_link(short_link, %{hit_count: short_link.hit_count + 1})
end
def delete_short_link(%ShortLink{} = short_link) do
Repo.delete(short_link)
# |> PubSub.broadcast_record(:short_link_deleted) # TODO: uncomment later
end
def change_short_link(%ShortLink{} = short_link, attrs \\ %{}) do
ShortLink.changeset(short_link, attrs)
end
end
Pub/Sub-related code is commented out but we will implement it soon.
This is optinal but why not? That would be cool if we can immediately see any changes other users make.
I decided to place this module under the context namespace.
# lib/my_app/short_links/pubsub.ex
defmodule MyApp.ShortLinks.PubSub do
@topic inspect(__MODULE__)
def subscribe do
Phoenix.PubSub.subscribe(MyApp.PubSub, @topic)
end
def broadcast_record({:ok, record}, event) when is_struct(record) do
Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {event, record})
{:ok, record}
end
def broadcast_record({:error, reason}, _event), do: {:error, reason}
end
Probably there should have been example routes printed to the terminal when we generated resources above. Those routes are suitable for the admin UI.
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# ...
scope "/", MyAppWeb do
pipe_through :browser
# Public-facing UI
# live "/", ShortLinkPublicLive, :index # TODO: uncomment later
# Admin UI (generated by mix phx.gen.live)
live "/short_links", ShortLinkLive.Index, :index
live "/short_links/new", ShortLinkLive.Index, :new
live "/short_links/:id/edit", ShortLinkLive.Index, :edit
end
# ...
# TODO: uncomment later
# Catch-all routes must be at the end of the list.
# scope "/", UrlShortnrWeb do
# pipe_through :browser
#
# get "/:key", ShortLinkRedirectController, :index
# end
We will implement two routes later:
We need one controller that redirects a short URL to its original URL.
defmodule MyAppWeb.ShortLinkRedirectController do
use MyAppWeb, :controller
alias MyApp.ShortLinks
# GET /:key
def index(conn, %{"key" => key}) do
case ShortLinks.get_short_link_by_key(key) do
nil ->
conn
|> put_flash(:error, "Invalid short link")
|> redirect(to: "/")
short_link ->
Task.start(fn -> ShortLinks.increment_hit_count(short_link) end)
redirect(conn, external: short_link.url)
end
end
end
Then create a route for this controller. This route needs to be appended at the end of the router because it is a catch-all. We want Phoenix to match other routes first.
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# ...
# Catch-all routes must be at the end of the list.
scope "/", MyAppWeb do
pipe_through :browser
get "/:key", ShortLinkRedirectController, :index
end
In order to distiguish our public-facing UI from Phoenix-generated MyAppWeb.ShortLinkLive, I name it MyAppWeb.ShortLinkPublicLive.
The form is almost the same as the one generated by Phoenix. Here are some minor adjustments:
# lib/my_app_web/live/short_link_public_live/form_component.ex
defmodule MyAppWeb.ShortLinkPublicLive.FormComponent do
use MyAppWeb, :live_component
alias MyApp.ShortLinks
alias MyApp.ShortLinks.ShortLink
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> clear_changeset()
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div>
<.form
let={f}
for={@changeset}
id="short_link-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save">
<%= text_input f, :url, phx_debounce: "300", placeholder: "Shorten your link" %>
<%= error_tag f, :url %>
<div>
<%= submit "Shorten", phx_disable_with: "Processing..." %>
</div>
</.form>
</div>
"""
end
@impl true
def handle_event("validate", %{"short_link" => short_link_params}, socket) do
changeset =
socket.assigns.short_link
|> ShortLinks.change_short_link(short_link_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"short_link" => short_link_params}, socket) do
save_short_link(socket, socket.assigns.action, short_link_params)
end
defp save_short_link(socket, _new, short_link_params) do
case ShortLinks.create_short_link(short_link_params) do
{:ok, _short_link} ->
socket =
socket
|> put_flash(:info, "Short link created successfully")
|> clear_changeset()
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
defp clear_changeset(socket) do
socket
|> assign(short_link: %ShortLink{})
|> assign(changeset: ShortLinks.change_short_link(%ShortLink{}))
end
end
There are many different ways to implement LiveView for collections. For a toy project it does not matter which but I choose to use DOM patching & temporary assigns: that helps reduce the memory usage.
In order to access current URL in the template, I pre-process it and assign it to the socket in the handle_params callback.
On mount, we subscribe to MyApp.ShortLinks.PubSub so that all the MyAppWeb.ShortLinkPublicLive processes will be notified whenever there is a change in short_links table. The types of incoming messages are as follows:
:short_link_inserted:short_link_updated:short_link_deletedFor inserting and updating a record, Phoenix phx-update="prepend" is smart enough to figure out how the UI should be updated. All we need to prepend a record to the existing list when the insertion or update happens.
One disadvantage of using temporary assigns is that deletion is not easy. So I'll use client hooks so that I can delete an item in the UI by JavaScript when the server singnals the deletion for a given record.
<!-- lib/my_app_web/live/short_link_public_live.heex -->
<%= live_component(@socket, MyAppWeb.ShortLinkPublicLive.FormComponent, id: :stateful, action: @live_action) %>
<table>
<thead>
<tr>
<th>Destination</th>
<th>Shortened URL</th>
<th>Hits</th>
</tr>
</thead>
<tbody id="short_links" phx-update="prepend" phx-hook="ShortLinkTable">
<%= for short_link <- @short_links do %>
<tr id={"short_link-#{short_link.id}"}>
<td style="overflow-x:auto;max-width:33vw"><%= short_link.url %></td>
<% shortened_url = "#{@app_url}/#{short_link.key}" %>
<td style="overflow-x:auto"><%= link shortened_url, to: shortened_url, target: "_blank" %></td>
<td><%= short_link.hit_count %></td>
</tr>
<% end %>
</tbody>
</table>
<span><%= live_patch "Admin", to: Routes.short_link_index_path(@socket, :index) %></span>
# lib/my_app_web/live/short_link_public_live.ex
defmodule MyAppWeb.ShortLinkPublicLive do
use MyAppWeb, :live_view
alias MyApp.ShortLinks
alias MyApp.ShortLinks.PubSub
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
PubSub.subscribe()
end
socket = assign(socket, :short_links, list_short_links())
{:ok, socket, temporary_assigns: [short_links: []]}
end
@impl true
def handle_params(params, url, socket) do
socket =
socket
|> assign_url(url)
|> apply_action(socket.assigns.live_action, params)
{:noreply, socket}
end
defp assign_url(socket, url) do
parsed_url = URI.parse(url)
app_url =
if Mix.env() in [:dev, :test] do
"http://#{parsed_url.host}:#{parsed_url.port}"
else
"https://#{parsed_url.host}"
end
socket
|> assign(app_url: app_url)
|> assign(current_path: parsed_url.path)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:short_link, nil)
end
## UI events
@impl true
def handle_event("delete", %{"id" => id}, socket) do
short_link = ShortLinks.get_short_link!(id)
{:ok, _} = ShortLinks.delete_short_link(short_link)
{:noreply, assign(socket, :short_links, list_short_links())}
end
## PubSub
@impl true
def handle_info({:short_link_inserted, new_short_link}, socket) do
socket = update(socket, :short_links, &[new_short_link | &1])
{:noreply, socket}
end
@impl true
def handle_info({:short_link_updated, updated_short_link}, socket) do
socket = update(socket, :short_links, &[updated_short_link | &1])
{:noreply, socket}
end
@impl true
def handle_info({:short_link_deleted, deleted_short_link}, socket) do
# Let JS remove the row because temporary_assigns with phx-update won't delete an item.
socket = push_event(socket, "short_link_deleted", %{id: deleted_short_link.id})
{:noreply, socket}
end
## Utils
defp list_short_links do
ShortLinks.list_short_links()
end
end
This is for deleting a table row when a record is deleted in the database.
// assets/js/hooks/short_link_table.js
const ShortLinkTable = {
mounted() {
this.handleEvent('short_link_deleted', ({ id }) => {
this.el.querySelector(`#short_link-${id}`).remove();
});
},
};
export default ShortLinkTable;
// assets/js/app.js
// ...
import ShortLinkTable from './hooks/short_link_table';
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content');
let liveSocket = new LiveSocket('/live', Socket, {
hooks: {
ShortLinkTable,
},
params: { _csrf_token: csrfToken },
});
// ...
Create a route for ShortLinkPublicLive.
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# ...
scope "/", MyAppWeb do
pipe_through :browser
# Public-facing UI
live "/", ShortLinkPublicLive, :index
# Admin UI (generated by mix phx.gen.live)
live "/short_links", ShortLinkLive.Index, :index
live "/short_links/new", ShortLinkLive.Index, :new
live "/short_links/:id/edit", ShortLinkLive.Index, :edit
end
# ...
That's it!