Chuck Norris jokes Haskell CLI app with cabal
🏠 Go home
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
- The
appname
is the directory or path where to put the project. Can be omitted and current directory is used in that case. - The
-n
stands for--non-interactive
and it means default settings will be used. If omitted
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⏎