DEV Community

Cover image for URL shortener with Elixir Phoenix 1.6 and LiveView
Masatoshi Nishiguchi
Masatoshi Nishiguchi

Posted on • Edited on

URL shortener with Elixir Phoenix 1.6 and LiveView

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create a database.

mix ecto.create
Enter fullscreen mode Exit fullscreen mode

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]

  # ...

Enter fullscreen mode Exit fullscreen mode

Later we can retrieve the value like this.

Application.get_env(:my_app, :env)
#=> :prod
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then run the migration.

mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// 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 },
});
// ...
Enter fullscreen mode Exit fullscreen mode

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
  # ...
Enter fullscreen mode Exit fullscreen mode

Wrap up

That's it!

Top comments (1)

Collapse
 
simonmcconnell profile image
Simon McConnell • Edited

I would reconsider piping the result of a Repo call into a broadcast function. Clearly the broadcasting should only be done when the Repo call was successful, but it is not the PubSub's responsibility to check the result of a Repo call.

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}
Enter fullscreen mode Exit fullscreen mode

Could be:

def broadcast(record, event) when is_struct(record) do
  Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {event, record})
end
Enter fullscreen mode Exit fullscreen mode