URL shortener with Elixir Phoenix 1.6 and LiveView
Tags: elixir, phoenix
Published: 2021-10-17 22:24:34


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
```

[options]: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html#module-options

## Config

It is convenient to have `:env` in our application env.

```elixir
# config/config.exs

import Config

config :my_app,
  env: Mix.env(),
  ecto_repos: [MyApp.Repo]

  # ...

```

Later we can retrieve the value like this.

```elixir
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`

```bash
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:

```elixir
# 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.

```elixir
# 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
```

[`ecto_fields`]: https://github.com/jerel/ecto_fields

## 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`

```elixir
# 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.

```elixir
# 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.

```elixir
# 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.

```elixir
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.

```elixir
# 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

```elixir
# 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.

[DOM patching & temporary assigns]: https://hexdocs.pm/phoenix_live_view/dom-patching.html
[client hooks]: https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks

```html
<!-- 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>
```

```elixir
# 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.

```js
// 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;
```

```js
// 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`.

```elixir
# 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!


[Elixir]: https://elixir-lang.org/
[Phoenix]: https://www.phoenixframework.org/
[Phoenix.LiveView]: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
[Bitly]: https://bitly.com/
[URL shortener with Elixir and Phoenix by Aleksi Holappa]: https://dev.to/hlappa/url-shorten-er-with-elixir-and-phoenix-aol
[mix phx.gen.live]: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Live.html#content
[mix phx.new]: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html#content