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.
Link hash, storage and lookup
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