As part of our internal Haskell training, we use a URL shortener as a way to get started with Haskell projects and web applications in particular. We recommend using the Scotty web framework, but other frameworks can be used as well. This tutorial is a detailed walk-through of how to build the URL shortener from scratch.
As a note, we use this exercise after a few weeks of training, so some familiarity with Haskell, but not Haskell projects, is expected. For this, we're currently using part I of Programming in Haskell by Graham Hutton or the corresponding chapters from Learn You a Haskell for Great Good! by Miran Lipovača, but there's a lot of Haskell documentation to choose from!
Before we begin, here's a list of the versions used for preparing this tutorial:
- LTS Haskell 22.35 (GHC 9.6.6)
- Stack 3.1.1 and Cabal 3.12.1.0
- Scotty 0.20.1
Setting up a Haskell project
To recap, we're going to create a URL shortener using Scotty. The idea is to have a page where a user can submit a URL and get a shortened version, which they can then access and be redirected to the original URL.
First, we need a Haskell project, which we're going to set up using
both Stack and Cabal (but you only need to use one of them). For
Stack, create a directory for the project (shortener
) and add a
stack.yaml
file, as follows:
mkdir shortener
cd shortener
echo "resolver: lts-22.35" > stack.yaml
As of this writing, LTS 22.35 is the most recent (LTS)
resolver, which uses GHC 9.6.6. To choose a different resolver, go to
Stackage and check the latest releases (or run
stack ls snapshots --lts remote
to get a similar list). For Cabal,
skip the stack.yaml
file part.
Next, we need a Cabal file (shortener.cabal
), which we'll create
with very little data (a lot more can be added, but this is enough for
our application and close to a minimal Cabal file):
cabal-version: 2.2
name: shortener
version: 0.1.0.0
common common
build-depends:
, base >= 4.18
default-language: Haskell2010
ghc-options: -Wall
library
import: common
hs-source-dirs: src
exposed-modules: Shortener
build-depends:
, scotty >= 0.20.1 && < 0.21
executable shortener
import: common
hs-source-dirs: app
main-is: Main.hs
build-depends:
, shortener
We'll add more things to this file later on. Let's review some of it:
- The
cabal-version
line specifies the version of the Cabal file format we want to use. In this case, we're using version 2.2 so that we can use common stanzas, but note that this is not the most recent version. This line is required to be the first line of the Cabal file. - The
name
andversion
lines include the name and version of the library associated to the Cabal file. A Cabal file defines one library and zero or more executables (which can be executables, tests, or benchmarks). - The
common common
block defines a common stanza calledcommon
, which we can reuse to list common dependencies (in this case, thebase
library), the default language, and compiler options (in this case,-Wall
to enable all warnings): - The default language is normally
Haskell2010
, which is the current definition of the Haskell language. - The
base
library is the Haskell standard library, which defines thePrelude
, and also works to specify the version of the compiler that we want to support. In this case,base >= 4.18
means that we support GHC 9.6.1 or higher. For more information about thebase
library and thePrelude
, see What I Wish I Knew When Learning Haskell'sbase
section or the Type Classes Prelude reference. - The
library
block defines the library component. We import the common stanza we already talked about (which has to be the first item of the block), choosesrc
as the directory for the source files, and only specify one module calledShortener
(together, the last two mean that there must be a filesrc/Shortener.hs
with a module calledShortener
). Finally, we addscotty
as a dependency using the version in LTS 22.35 as a lower constraint. - The
executable
block defines an executable with nameshortener
(the same name as the library), which is the one we can run after compiling the application. The rest of the block is similar to thelibrary
block, except that the only dependency is the library and that there must be a fileapp/Main.hs
with aMain
module.
In order for everything to work with the current setup, let's create
the needed files without the actual solution. Here's
src/Shortener.hs
:
module Shortener where
shortener :: IO ()
shortener =
putStrLn "Shortener"
And here's app/Main.hs
:
module Main (main) where
import Shortener (shortener)
main :: IO ()
main =
shortener
In Shortener
, we define shortener
for what we'll implement and
print the word "Shortener" for now. In Main
, we call shortener
,
which means that our Main
module is a wrapper for shortener
and we
can forget about it for the rest of the tutorial.
With all of this in place, you can run stack build
or cabal build
to install dependencies and build the application. To run the
application, run stack run
or cabal run
.
Hello, Scotty!
The Scotty repository includes a small example to get
started, and additional documentation and examples (including a URL
shortener that we'll use as a base for our
solution). Based on this, let's update the Shortener
module to
display the word "Shortener" instead of printing it:
{-# LANGUAGE OverloadedStrings #-}
module Shortener where
import Web.Scotty
shortener :: IO ()
shortener =
scotty 3000 $
get "/" $
html "<h1>Shortener</h1>"
Let's go over the contents of the file:
- The line at the beginning is called a language pragma and extends
the language with nice features. In this case,
OverloadedStrings
allows us to write"hello"
and it gets automatically converted to the string type we need (String
,ByteString
, orText
). - For more information about language pragmas and overloaded strings, see Kowainik's Extensions post or the Type Classes Introduction to GHC extensions
scotty
is the entry function that Scotty defines for running an application. The first parameter is the port that we want it to run in, and the rest is the application, which looks like a list of routes and handlers.- For now, we only have one route (the root) and a handler, which is a GET and returns an HTML string with a title.
If everything works as expected, you can build and run the application
using stack run
or cabal run
, which should display a message
similar to the following:
Setting phasers to stun... (port 3000) (ctrl-c to quit)
Open http://localhost:3000/
and see the title.
Before moving on, let's use an HTML-based templating engine to
generate HTML in Haskell. There are several libraries, but we're going
to use blaze-html
. To do so, we need to add it as a
dependency to our Cabal file, as follows (note that this is just for
the dependencies part of the file for the library component):
build-depends:
, blaze-html >= 0.9 && < 0.10
, scotty >= 0.20 && < 0.21
As Blaze is not the main part of the exercise, you can check out the BlazeHtml tutorial. Here's the updated application:
{-# LANGUAGE OverloadedStrings #-}
module Shortener where
import Text.Blaze.Html.Renderer.Text (renderHtml)
import qualified Text.Blaze.Html5 as H
import Web.Scotty
shortener :: IO ()
shortener =
scotty 3000 $
get "/" $
html $ renderHtml $
H.html $
H.body $ do
H.h1 "Shortener"
Instead of raw HTML, we now have Haskell functions that look like an HTML document. The main advantage of this is that we can get type errors if we do something wrong. If you rebuild and run, and open your browser, the result should be the same as before.
Shortening URLs
For the URL shortener, we need 3 things:
- A landing page with a form to input a URL (we have a page, but not a form)
- A POST request to handle the submitted form and create a shortened version of a URL
- A GET request to handle a shortened URL and redirect to the original URL
Let's add the form, which is mostly HTML. Take a look at the Haskell code:
{-# LANGUAGE OverloadedStrings #-}
module Shortener where
import Text.Blaze.Html.Renderer.Text (renderHtml)
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Web.Scotty
shortener :: IO ()
shortener =
scotty 3000 $
get "/" $
html $ renderHtml $
H.html $
H.body $ do
H.h1 "Shortener"
H.form H.! A.method "post" H.! A.action "/" $ do
H.input H.! A.type_ "text" H.! A.name "url"
H.input H.! A.type_ "submit"
This will get transformed to this output HTML form:
<form method="post" action="/">
<input type="text" name="url" />
<input type="submit" />
</form>
If you rebuild and run the application, you should see the form. If you submit something, it should fail because there's no handler for the POST request. Let's add it!
To do so, we have to store the shortened URL somewhere. It should be a
database, but we'll use an IORef
, which is a mutable reference to a
value (see What I Wish I knew When Learning Haskell's section on
IORef
for more information). We're going to store the
URLs as Text
in a Map
, so we need two additional dependencies,
containers
and text
. Here are the updated library dependencies:
build-depends:
, blaze-html >= 0.9 && < 0.10
, containers
, scotty >= 0.20 && < 0.21
, text
The containers
and text
libraries are boot libraries, which means
they're already installed with the compiler and the base
library. We
don't specify version contraints for these, but we can find the
version used in the GHC Boot Library Version History.
Here's the new version of the program:
{-# LANGUAGE OverloadedStrings #-}
module Shortener where
import Control.Monad.IO.Class (MonadIO(liftIO))
import Data.IORef (modifyIORef, newIORef)
import Data.Map (Map)
import qualified Data.Map as M
import Data.Text (Text)
import Text.Blaze.Html.Renderer.Text (renderHtml)
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Web.Scotty
shortener :: IO ()
shortener = do
urlsR <- newIORef (1 :: Int, mempty :: Map Int Text)
scotty 3000 $ do
get "/" $
html $ renderHtml $
H.html $
H.body $ do
H.h1 "Shortener"
H.form H.! A.method "post" H.! A.action "/" $ do
H.input H.! A.type_ "text" H.! A.name "url"
H.input H.! A.type_ "submit"
post "/" $ do
url <- formParam "url"
liftIO $ modifyIORef urlsR $
\(i, urls) ->
(i + 1, M.insert i url urls)
redirect "/"
Before creating the Scotty app, we create an IORef
to hold two
things: the current index of the URLs created (we're using numbers, so
shortened URLs will look like http://localhost:3000/1
), and the map
of numbers to original URLs. A Map
is like a list of key-value
tuples, which in this case uses numbers as keys and URLs as values.
For the POST request, we get the URL from the input field with name
url
and modify the map of URLs using modifyIORef
by increasing the
current index by 1 and inserting the new URL to the map.
If you rebuild and run the application, you can now submit the form
and everything seems to work, but how do we know that the shortened
URL was created? Let's add a list of URLs to the main page to see how
to read data from the IORef
:
{-# LANGUAGE OverloadedStrings #-}
module Shortener where
import Control.Monad.IO.Class (MonadIO(liftIO))
import Data.Foldable (for_)
import Data.IORef (modifyIORef, newIORef, readIORef)
import Data.Map (Map)
import qualified Data.Map as M
import Data.Text (Text)
import Text.Blaze.Html.Renderer.Text (renderHtml)
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Web.Scotty
shortener :: IO ()
shortener = do
urlsR <- newIORef (1 :: Int, mempty :: Map Int Text)
scotty 3000 $ do
get "/" $ do
(_, urls) <- liftIO $ readIORef urlsR
html $ renderHtml $
H.html $
H.body $ do
H.h1 "Shortener"
H.form H.! A.method "post" H.! A.action "/" $ do
H.input H.! A.type_ "text" H.! A.name "url"
H.input H.! A.type_ "submit"
H.table $
for_ (M.toList urls) $ \(i, url) ->
H.tr $ do
H.td (H.toHtml i)
H.td (H.text url)
post "/" $ do
url <- formParam "url"
liftIO $ modifyIORef urlsR $
\(i, urls) ->
(i + 1, M.insert i url urls)
redirect "/"
We get the map of URLs using readIORef
and only use the second part
(the current index is only needed when creating a shortened URL). We
display the list of URLs using an HTML table with a row for each URL.
Now, after submitting the form, the list should update!
But what happens if you shorten a URL and then try to open the
shortened URL (http://localhost:3000/1
)?
Can you implement the missing GET handler to redirect the user to the original URL? After doing so, here's one possible solution:
{-# LANGUAGE OverloadedStrings #-}
module Shortener where
import Control.Monad.IO.Class (MonadIO(liftIO))
import Data.Foldable (for_)
import Data.IORef (modifyIORef, newIORef, readIORef)
import Data.Map (Map)
import qualified Data.Map as M
import Data.Text (Text)
import qualified Data.Text.Lazy as LT
import Network.HTTP.Types (status404)
import Text.Blaze.Html.Renderer.Text (renderHtml)
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Web.Scotty
shortener :: IO ()
shortener = do
urlsR <- newIORef (1 :: Int, mempty :: Map Int Text)
scotty 3000 $ do
get "/" $ do
(_, urls) <- liftIO $ readIORef urlsR
html $ renderHtml $
H.html $
H.body $ do
H.h1 "Shortener"
H.form H.! A.method "post" H.! A.action "/" $ do
H.input H.! A.type_ "text" H.! A.name "url"
H.input H.! A.type_ "submit"
H.table $
for_ (M.toList urls) $ \(i, url) ->
H.tr $ do
H.td (H.toHtml i)
H.td (H.text url)
post "/" $ do
url <- formParam "url"
liftIO $ modifyIORef urlsR $
\(i, urls) ->
(i + 1, M.insert i url urls)
redirect "/"
get "/:n" $ do
n <- captureParam "n"
(_, urls) <- liftIO $ readIORef urlsR
case M.lookup n urls of
Just url ->
redirect (LT.fromStrict url)
Nothing ->
raiseStatus status404 "not found"
This solution works, but there are some issues with it that can be used as exercises:
- For a production-ready solution, you should switch to a database
instead of using an
IORef
. Can you update the application to use a database instead of anIORef
? There are some examples in What I Wish I Knew When Learning Haskell's databases section. - When submitting the form, there's no validation of the URL, so anything is accepted. Can you validate the URL and reject invalid ones?
- When a URL is not found, using
status404
requires updating the dependencies to includehttp-types
, as suggested by the compiler if you try to build the proposed solution. Can you update the Cabal file to make it work?
We hope you have fun with Scotty and web development in Haskell, and remember to also try other Haskell web frameworks or use your own idea for a web application! For even more information about Haskell projects and a different walk-through of a URL shortener, see chapters 13 (Building projects) and 19.6 (An end-to-end example: URL shortener) of the Haskell Programming from First Principles book.