Building an URL shortener in a single file Elixir script

Photo by Vipul Jha on Unsplash

Building an URL shortener in a single file Elixir script

A couple of years ago, I discovered elixir, and since then, it has become my go-to language for creating full web applications, APIs, or just scripts.

Elixir comes with strong scripting capabilities, enabling us to write code in a file—like some_script.exs—and run it directly using elixir some_script.exs.

Most Elixir projects meant for production are compiled through mix, taking advantage of all possible optimizations and performance improvements. However, let's see what we can achieve when we focus on scripting and skip the compilation step!

Let's have some fun and write a simple URL shortener in one file using: Phoenix, LiveView, and Erlang Term Storage (ETS).

Mix.install/2

Mix.install/2 allows you to install any hex package.

Mix.install([
  {:bandit, "~> 1.1"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7.11"},
  {:phoenix_live_view, "~> 0.20.14"}
])

We install the latest version of bandit (pure Elixir HTTP server for Plug & WebSock applications), jason, phoenix and LiveView.

Application.put_env/4

We'll use Application.put_env/4 to configure the Application at runtime. Below is the base configuration we're going to use in this script:

Application.put_env(:short_url, ShortUrl.Endpoint,
  adapter: Bandit.PhoenixAdapter,
  http: [ip: {0, 0, 0, 0}, port: 4001],
  url: [host: "localhost"],
  check_origin: ["//localhost"],
  server: true,
  live_view: [signing_salt: Enum.take_random(?a..?z, 10) |> to_string],
  secret_key_base: Enum.take_random(?a..?z, 64) |> to_string
)

I prefer this method over adding a config to Mix.install/2 like this:

Mix.install([ 
      {:bandit, "~> 1.1"},
      {:jason, "~> 1.0"},
      {:phoenix, "~> 1.7.11"},
      {:phoenix_live_view, "~> 0.20.14"}
    ],
    config: [
        short_url: [
            ShortUrl.Endpoint: [
                http: [ip: {0, 0, 0, 0}, port: 4001],
                url: [host: "localhost"],
                check_origin: ["//localhost"],
                server: true,
                live_view: [signing_salt: Enum.take_random(?a..?z, 10) |> to_string],
                secret_key_base: Enum.take_random(?a..?z, 64) |> to_string
            ]
        ]
    ]
)

I find the first method more readable than this one.

We're set...

With just two functions and 16 lines of code, all contained in a single, portable file, we've established the foundation for our URL shortener.

We're now ready to create short links, store them, and search for links by their hash key:

defmodule ShortUrl do
  def permalink(bytes_count) do
    bytes_count
    |> :crypto.strong_rand_bytes()
    |> Base.url_encode64(padding: false)
  end

  def add_url(link, hash) do
    :ets.insert_new(:short_urls, {hash, link})
  end

  def get_url(hash) do
    :ets.lookup(:short_urls, hash)
  end
end

This simple module is straightforward: we generate a hash with the permalink/1 function, then use :ets.insert_new/2 to store the hash and the original link, and :ets.lookup/2 to retrieve the link associated with the hash value.

Phoenix LiveView

We're going to use Phoenix LiveView to display the short link generation form, generate the short url and show the user the new shortened url:

defmodule ShortUrl.IndexLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    socket = assign(socket, short_url: nil)

    {:ok, socket}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="https://cdn.jsdelivr.net/npm/phoenix@1.7.11/priv/static/phoenix.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@0.20.14/priv/static/phoenix_live_view.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <div class="bg-white py-16 sm:py-24">
      <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
        <div class="relative isolate overflow-hidden bg-gray-900 px-6 py-24 shadow-2xl sm:rounded-3xl sm:px-24 xl:py-32">
          <h2 class="mx-auto max-w-2xl text-center text-3xl font-bold tracking-tight text-white sm:text-4xl">One file url shortner</h2>
          <p class="mx-auto mt-2 max-w-xl text-center text-lg leading-8 text-gray-300">Try it out! Add your url in the form below!</p>
          <form phx-submit="generate" class="mx-auto mt-10 flex max-w-md gap-x-4">
            <label for="link-address" class="sr-only">Link address</label>
            <input id="link-address" name="link" type="url" required class="min-w-0 flex-auto rounded-md border-0 bg-white/5 px-3.5 py-2 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-white sm:text-sm sm:leading-6" placeholder="Enter your url">
            <button type="submit" class="flex-none rounded-md bg-white px-3.5 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">Make it short</button>
          </form>
          <div :if={@short_url}>
            <p class="mx-auto mt-2 max-w-xl text-center text-lg leading-8 text-gray-300">your short url is: <a href={@short_url} target="_blank"><%= @short_url %></a></p>
          </div>
        </div>
      </div>
    </div>
    """
  end

  def render(assigns) do
    ~H"""
    <div></div>
    """
  end

  def handle_event("generate", %{"link" => link}, socket) do
    short_url_hash = ShortUrl.permalink(8)
    short_url = ShortUrl.Endpoint.url() <> "/r/" <> short_url_hash

    ShortUrl.add_url(link, short_url_hash)

    socket = assign(socket, short_url: short_url)

    {:noreply, socket}
  end
end

301 redirects

Whenever a user visits the redirect endpoint, we will search for the hash and perform a 301 redirect to the matching URL:

defmodule ShortUrl.RedirectController do
  use Phoenix.Controller

  def index(conn, %{"hash" => hash}) do
    case ShortUrl.get_url(hash) do
      [{_, link}] ->
        conn
        |> put_status(:moved_permanently)
        |> redirect(external: link)

      [] ->
        conn
        |> send_resp(200, "Link not found")
    end
  end
end

One file URL shortener

Mix.install([
  {:bandit, "~> 1.1"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7.11"},
  {:phoenix_live_view, "~> 0.20.14"}
])

Application.put_env(:short_url, ShortUrl.Endpoint,
  adapter: Bandit.PhoenixAdapter,
  http: [ip: {0, 0, 0, 0}, port: 4001],
  url: [host: "localhost"],
  check_origin: ["//localhost"],
  server: true,
  live_view: [signing_salt: Enum.take_random(?a..?z, 10) |> to_string],
  secret_key_base: Enum.take_random(?a..?z, 64) |> to_string
)

defmodule ShortUrl do
  def permalink(bytes_count) do
    bytes_count
    |> :crypto.strong_rand_bytes()
    |> Base.url_encode64(padding: false)
  end

  def add_url(link, hash) do
    :ets.insert_new(:short_urls, {hash, link})
  end

  def get_url(hash) do
    :ets.lookup(:short_urls, hash)
  end
end

defmodule ShortUrl.IndexLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    socket = assign(socket, short_url: nil)

    {:ok, socket}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="https://cdn.jsdelivr.net/npm/phoenix@1.7.11/priv/static/phoenix.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@0.20.14/priv/static/phoenix_live_view.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <div class="bg-white py-16 sm:py-24">
      <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
        <div class="relative isolate overflow-hidden bg-gray-900 px-6 py-24 shadow-2xl sm:rounded-3xl sm:px-24 xl:py-32">
          <h2 class="mx-auto max-w-2xl text-center text-3xl font-bold tracking-tight text-white sm:text-4xl">One file url shortner</h2>
          <p class="mx-auto mt-2 max-w-xl text-center text-lg leading-8 text-gray-300">Try it out! Add your url in the form below!</p>
          <form phx-submit="generate" class="mx-auto mt-10 flex max-w-md gap-x-4">
            <label for="link-address" class="sr-only">Link address</label>
            <input id="link-address" name="link" type="url" required class="min-w-0 flex-auto rounded-md border-0 bg-white/5 px-3.5 py-2 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-white sm:text-sm sm:leading-6" placeholder="Enter your url">
            <button type="submit" class="flex-none rounded-md bg-white px-3.5 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">Make it short</button>
          </form>
          <div :if={@short_url}>
            <p class="mx-auto mt-2 max-w-xl text-center text-lg leading-8 text-gray-300">your short url is: <a href={@short_url} target="_blank"><%= @short_url %></a></p>
          </div>
        </div>
      </div>
    </div>
    """
  end

  def render(assigns) do
    ~H"""
    <div></div>
    """
  end

  def handle_event("generate", %{"link" => link}, socket) do
    short_url_hash = ShortUrl.permalink(8)
    short_url = ShortUrl.Endpoint.url() <> "/r/" <> short_url_hash

    ShortUrl.add_url(link, short_url_hash)

    socket = assign(socket, short_url: short_url)

    {:noreply, socket}
  end
end

defmodule ShortUrl.RedirectController do
  use Phoenix.Controller

  def index(conn, %{"hash" => hash}) do
    case ShortUrl.get_url(hash) do
      [{_, link}] ->
        conn
        |> put_status(:moved_permanently)
        |> redirect(external: link)

      [] ->
        conn
        |> send_resp(200, "Link not found")
    end
  end
end

defmodule ShortUrl.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", ShortUrl do
    pipe_through(:browser)

    get("/r/:hash", RedirectController, :index)

    live("/", IndexLive, :index)
  end
end

defmodule ShortUrl.Endpoint do
  use Phoenix.Endpoint, otp_app: :short_url
  socket("/live", Phoenix.LiveView.Socket)
  plug(Router)
end

{:ok, _} = Supervisor.start_link([ShortUrl.Endpoint], strategy: :one_for_one)
:ets.new(:short_urls, [:set, :public, :named_table])
Process.sleep(:infinity)

In just one file, we've created a fully functional Phoenix LiveView application!

I aimed to keep the features minimal, but feel free to add more, like permanent storage (for example, PostgreSQL), initialize ETS when the application starts, and so on.

To run the application, use: elixir app.exs then visit: http://localhost:4001

Bonus: let's create a Dockerfile for it

FROM "hexpm/elixir:1.15.7-erlang-26.1.2-debian-bullseye-20240130-slim"

# install dependencies
RUN apt-get update -y && apt-get install -y build-essential git libstdc++6 openssl libncurses5 locales \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

WORKDIR "/app"

# Copy our file over
COPY app.exs /app

RUN mix local.hex --force && \
    mix local.rebar --force

EXPOSE 4001

CMD elixir /app/app.exs

Build with docker build -t short_url . and run it with docker run -p 127.0.0.1:4001:4001 short_url .

Now it's ready for production :).

Of course, this isn't code or an application ready for production, but I wrote this article to demonstrate the capabilities of Elixir, Mix.install/2, and what you can achieve with just one file and 100 lines of code.

If you're interested in seeing what else you can do with single-file Elixir scripts, I recommend checking out this GitHub repository: mix_install_examples