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.
What I want to do
- User is able to create a short link to a given URL
- User is able to navigate to a short link that is redirected to the original URL
- User is able to see how many times a link has been used real time.
Elixir versions etc
elixir 1.12.3-otp-24
erlang 24.1.1
phoenix 1.6.2
Create a new Phoenix app
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
Config
It is convenient to have :env
in our application env.
# config/config.exs
import Config
config :my_app,
env: Mix.env(),
ecto_repos: [MyApp.Repo]
# ...
Later we can retrieve the value like this.
Application.get_env(:my_app, :env)
#=> :prod
Create resources for LiveView
The mix phx.gen.live generator generates LiveView, templates, and context for a resource.
I call it ShortLink
but it is arbitrary.
- context name:
ShortLinks
- resource name:
ShortLink
- table name:
short_links
mix phx.gen.live ShortLinks ShortLink short_links \
key:string \
url:text \
hit_count:integer
Migration
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:
- Add not-null constraint on
:key
and:url
columns - Add default value
0
on:hit_count
column - Add unique constraint on
:key
column
Our 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
Schema
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
Context
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:
- Find a short link record by
key
- Generate a random short string
- Assign a random string on the
key
column - Increment
hit_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.
Pub/Sub
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
Routes
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 "/", MyAppWeb do
# pipe_through :browser
#
# get "/:key", ShortLinkRedirectController, :index
# end
We will implement two routes later:
- for redirecting a short URL to its original URL
- for a public-facing UI like Bitly
Redirect controller
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
Custom LiveView UI
In order to distiguish our public-facing UI from Phoenix-generated MyAppWeb.ShortLinkLive
, I name it MyAppWeb.ShortLinkPublicLive
.
Form
The form is almost the same as the one generated by Phoenix. Here are some minor adjustments:
- Clear the form after successful submission
- Debounce the validation
- Some HTML and styles
# 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
LiveView
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_deleted
For 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 Application.get_env(:my_app, :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
Client hooks
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
# ...
Wrap up
That's it!
Top comments (1)
I would reconsider piping the result of a
Repo
call into a broadcast function. Clearly the broadcasting should only be done when theRepo
call was successful, but it is not thePubSub
's responsibility to check the result of aRepo
call.Could be: