Chuck Norris jokes Haskell CLI app with cabal

🏠 Go home
Haskell Cabal 2022-08-13

TLDR

I'll show how to setup a Haskell project using cabal and how to use http-client, http-client-tls, text and aeson libraries. I'll build a small CLI haskell app that will call HTTP API for Chuck Norris jokes and print the formatted output to stdout.

I use cabal 3.8.1.0. The CLI interface for the previous 3.6.2.0 version differs so make sure you have the 3.8.1.0 version! You can install cabal using ghcup. If you don't have the mentioned version, install it using ghcup tui.

Create a new app

$ cabal init haskell-app -n

We should see this directory structure in the haskell-app folder.

├── CHANGELOG.md
├── app
│   └── Main.hs
└── haskell-app.cabal

In the haskell-app.cabal, we'll se we have a single executable haskell-app.

Run the app

We can build and run the app using

$ cabal run haskell-app
# or just
$ cabal run

We can also build the app and run it using two separate commands.

$ cabal build
$ cabal run

Adding a new local module

Let's create app/ChuckNorrisApi.hs with the following content.

module ChuckNorrisApi where

apiUrl :: String
apiUrl = "https://api.chucknorris.io/jokes/random"

Now, we need to let cabal know we'll use other modules for the executable haskell-app.

    other-modules:    ChuckNorrisApi

Update the app/Main.hs as follows.

module Main where

import ChuckNorrisApi (apiUrl)

main :: IO ()
main = putStrLn $ "Hello, I'm gonna use " <> apiUrl <> " later!"

And let's run the app.

$ cabal run
# you're gonna see some build logs there
Hello, I'm gonna use https://api.chucknorris.io/jokes/random later!

Adding dependencies

Firstly, update the package index if you didn't do so for some time.

$ cabal update

We'll add a http-client library dependency to our project.

Extend the build-depends section in executable haskell-app in the haskell-app.cabal file.

    build-depends:    base ^>=4.14.3.0,
                      http-client ^>= 0.7.13.1

Run cabal run again to make cabal build the dependencies. We are going to need two more libraries. The text for unicode text types and http-client-tls for TLS support.

    build-depends:    base ^>=4.14.3.0,
                      http-client ^>= 0.7.13.1,
                      http-client-tls ^>= 0.3.6.1,
                      text ^>= 1.2.5

Using http-client and http-client-tls

I can't really go into details of how the http-client works because I don't know the details :(. The important point here is the library uses a Manager structure to keep track of connections and we're using the tlsManagerSettings from http-client-tls the create the manager with an ability to perform https calls.

The parseRequest function creates a Request object from a String which is the URL. httpLbs takes the Request and Manager and returns IO (Response ByteString). Note that the ByteString is Lazy which is the reason we use Data.Text.Lazy and Data.Text.Lazy.Encoding.

module ChuckNorrisApi where

import Network.HTTP.Client.TLS (tlsManagerSettings)
import qualified Network.HTTP.Client as HttpClient
import qualified Data.Text.Lazy as T
import qualified Data.Text.Lazy.Encoding as TE

apiUrl :: String
apiUrl = "https://api.chucknorris.io/jokes/random"

getRandomChuckNorrisJoke :: IO T.Text
getRandomChuckNorrisJoke = do
    manager <- HttpClient.newManager tlsManagerSettings
    request <- HttpClient.parseRequest apiUrl
    response <- HttpClient.httpLbs request manager
    return $ TE.decodeUtf8 (HttpClient.responseBody response)

In the main function, we'll simply print the output of the API.

module Main where

import ChuckNorrisApi (getRandomChuckNorrisJoke)

main :: IO ()
main = getRandomChuckNorrisJoke >>= print

Let's run it!

cabal run
"{\"categories\":[],\"created_at\":\"2020-01-05 13:42:26.447675\",\"icon_url\":\"https://assets.chucknorris.host/img/avatar/chuck-norris.png\",\"id\":\"gZGSnZbITBagu3fPgIkDLg\",\"updated_at\":\"2020-01-05 13:42:26.447675\",\"url\":\"https://api.chucknorris.io/jokes/gZGSnZbITBagu3fPgIkDLg\",\"value\":\"Chuck Norris has lasted this long simply because Heaven wants nothing to do with him, and Hell is afraid that he'll take over the place.\"}"

Parsing JSON with the aeson library

Haskell has the awesome aeson library. It is used to parse a ByteString containing a JSON. It allows us to very easily implement ToJSON and FromJSON for pure haskell data structures and decode input byte strings into Maybe. Let's first add the library to our build dependencies.

    build-depends:    base ^>=4.14.3.0,
                      http-client ^>= 0.7.13.1,
                      http-client-tls ^>= 0.3.6.1,
                      text ^>= 1.2.5,
                      aeson ^>= 2.1.0.0

Then, we'll define ChuckNorrisJoke structure we'll let the Haskell magic automatically generate ToJSON and FromJSON instances. At the end, we'll replace the Text decode with aeson's decode. We also need to change the signature of the whole function to IO (Maybe ChuckNorrisJoke) because the parsing might fail.

{-# LANGUAGE DeriveGeneric #-}

module ChuckNorrisApi where

import GHC.Generics

import Network.HTTP.Client.TLS (tlsManagerSettings)
import qualified Network.HTTP.Client as HttpClient
import qualified Data.Text.Lazy as T
import Data.Aeson (ToJSON, FromJSON, decode)

data ChuckNorrisJoke = ChuckNorrisJoke { url :: T.Text, value :: T.Text } deriving (Generic, Show)

instance ToJSON ChuckNorrisJoke
instance FromJSON ChuckNorrisJoke

apiUrl :: String
apiUrl = "https://api.chucknorris.io/jokes/random"

getRandomChuckNorrisJoke :: IO (Maybe ChuckNorrisJoke)
getRandomChuckNorrisJoke = do
    manager <- HttpClient.newManager tlsManagerSettings
    request <- HttpClient.parseRequest apiUrl
    response <- HttpClient.httpLbs request manager
    return $ decode (HttpClient.responseBody response)

In the main module, we'll transform the output of the API to T.Text and print it to stdout using putStrLn function. I also add OverloadedStrings extension to simplify working with Text literals.

{-# LANGUAGE OverloadedStrings #-}

module Main where

import ChuckNorrisApi (getRandomChuckNorrisJoke, ChuckNorrisJoke(..))
import qualified Data.Text.Lazy as T

chuckNorrisJokeToOutput :: Maybe ChuckNorrisJoke -> T.Text
chuckNorrisJokeToOutput Nothing = "Something's wrong :("
chuckNorrisJokeToOutput (Just ChuckNorrisJoke{url=_url, value=_value}) = _value <> "\n\nURL: " <> _url

main :: IO ()
main = getRandomChuckNorrisJoke >>= putStr . T.unpack . chuckNorrisJokeToOutput

Triggering the cabal run now will result in a nice readable result.

Chuck Norris is known to save people's lives from heart attacks - so that he can kill them himself.

URL: https://api.chucknorris.io/jokes/kZBeKZmjQ_GBRgYuE-j2hg⏎