Building an URL shortener in a single file Elixir script

Search for a command to run...

No comments yet. Be the first to comment.
I recently wrote an article about how I do deployments with Kamal, which included setting up SSL for your domain and redirecting from non-ssl to ssl. In this article, I want to go further and show you how I redirect from www to non-www. In the config...

Besides programming, tech, and entrepreneurship, I also like to run marathons and I'm a 1 Dan black belt in Kyokushin Karate. There are some big differences between these two sports, but they also have common grounds: training schedules, nutrition, m...

What is Kamal Kamal is a new deployment tool designed for the efficient rollout of containerized applications. It's developed by 37Signals, the creators of Basecamp and Hey. Kamal provides zero-downtime deployments, rolling restarts, remote builds, a...

In December 2022 I joined Neurony Solutions , a 20 years old development agency in Bucharest, as their CTO and one of my first tasks was to improve our hiring process. I'm familiar with the company and the team, as this wasn't our first time meeting ...

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

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