Pragmatic Haskell II: IO Webservant []

  1. Pragmatic Haskell: Simple servant web server
  2. Pragmatic Haskell II: IO Webservant
  3. Pragmatic Haskell III: Beam Postgres DB

Most Haskell language guides will leave IOuntillater. This guide is different, this guide is about using Haskell. Our focus is different: We build first, then learn trough delight.

Fancy intro image

The previous blog post explained how to get going with a simple minimalist servant web server. In this blog post the simple web server will get an extra REST endpoint that can do IO actions. This is an important part of pragmatic Haskell programming. Without IO our program can do nothing. Programmers are not theorists, therefore we need IO.

Preparation

To keep things simple, the code assumes a file exists. Create one with an empty JSON array in the project root:

echo "[]" > messages.txt

Bytestrings are a convenient way of opening files and putting the results into aeson, the JSON library. Dealing with bytestrings requires another dependency:

dependencies:
- base >= 4.7 && < 5
- servant-server # http server
- aeson # json
- wai # web application (interface)
- warp # web application implementation
- bytestring

A lot of code

These are the magic spells changes which add an endpoint, explained in detail below:

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}

module Lib
    ( webAppEntry
    ) where

import Servant
import Control.Monad.IO.Class(liftIO)
import Data.ByteString.Lazy as LBS (writeFile, readFile) 
import Data.Aeson(ToJSON, FromJSON, encode, decode)
import GHC.Generics(Generic)
import Network.Wai(Application)
import Network.Wai.Handler.Warp(run)
import Data.Maybe (fromMaybe)

type UserAPI = "users" :> Get '[JSON] [User]
      :<|> "message" :> ReqBody '[JSON] Message :> Post '[JSON] [Message]

data Message = Message {
  from :: User,
  content :: String
} deriving (Eq, Show, Generic)

instance ToJSON Message
instance FromJSON Message

data User = User
  { name :: String
  , email :: String
  } deriving (Eq, Show, Generic)

instance ToJSON User
instance FromJSON User

users :: [User]
users =
  [ User "Isaac Newton"    "isaac@newton.co.uk"
  , User "Albert Einstein" "ae@mc2.org"
  ]
messageFile :: FilePath
messageFile = "messages.txt"

messages :: Message -> Handler [Message]
messages message = do 
  result <- liftIO $ LBS.readFile messageFile
  case decode result of
    Nothing -> pure []
    Just x -> do
      let contents = x ++ [message]
      liftIO $ LBS.writeFile messageFile (encode contents)
      return contents

server :: Server UserAPI
server = (pure users) :<|> messages

userAPI :: Proxy UserAPI
userAPI = Proxy

app :: Application
app = serve userAPI server

webAppEntry :: IO ()
webAppEntry = run 6868 app

This will setup another endpoint for messages. The new endpoint will accept a post request under “/message”. It will write the message to a file and then it will return the contents of that file.

Line by line inspection

type UserAPI = "users" :> Get '[JSON] [User]
      :<|> "message" :> ReqBody '[JSON] Message :> Post '[JSON] [Message]

The :<|> operator is used to add an extra endpoint. It combines the two endpoints into one. In these lines, we are constructing something akin to a jump table. The decleration of this operator is surprisingly simple:

data a :<|> b = a :<|> b

Left sign of equality is used for type, right side for data construction. Skimming over this, like true Haskellers we will ignore the inner workings .

The extra end point “message” is similar in structure to the existing “user” endpoint. If read like a sentence “message” is a POST only endpoint, which accepts a Message JSON body, and it returns a list of Messages in JSON.

Message data

data Message = Message {
  from :: User,
  content :: String
} deriving (Eq, Show, Generic)

instance ToJSON Message
instance FromJSON Message

This is Message, apparently it’s from a User and has some content String. Aside from using another data type inside an existing data type, no new concepts are introduced.

File path

messageFile :: FilePath

The type FilePath is just an alias for a String: type FilePath = String. In other words we can use them interchangeably. FilePath acts as documentation.

messageFile = "messages.txt"

This is where we define what file name is used.

Message handler

messages :: Message -> Handler [Message]

We define a Handler (which is an servant api endpoint). It requires a message, then it will return a list of messages within a Handler.

Do notation

messages message = do 

The do keyword allows us to code with do notation. This allows us to do assignments with <- (not assignment, but close enough). This only works when the result container is a Monad. How monads work is a mystery, but usage is simple: Use do notation.

LiftIO

  result <- liftIO $ LBS.readFile messageFile

Here we read the message file as a lazy bytestring and put it into the result. One may wonder why we are using liftIO, if it’s deleted we get this:

/home/jappie/projects/haskell/awesome-project-name/src/Lib.hs:46:13: error:
    • Couldnt match type ‘IO’ with ‘Handler’
      Expected type: Handler Data.ByteString.Lazy.Internal.ByteString
        Actual type: IO Data.ByteString.Lazy.Internal.ByteString
    • In a stmt of a 'do' block: result <- LBS.readFile messageFile
      In the expression:
        do result <- LBS.readFile messageFile
           case decode result of
             Nothing -> pure []
             Just x -> do ...
      In an equation for ‘messages’:
          messages message
            = do result <- LBS.readFile messageFile
                 case decode result of
                   Nothing -> pure ...
                   Just x -> ...
   |
46 |   result <- LBS.readFile messageFile
   |             ^^^^^^^^^^^^^^^^^^^^^^^^

The LBS.readfile function has a return type of IO. However the return type of messages is Handler. Therefore the compiler says that it expects Handler, but the actual type is IO. Handler implements the MonadIO typeclass however, which allows IO by calling the liftIO function. The liftIO function simply tells the Handler container to execute some function within the IO container.

The dollar sign can be replaced with an open parentheses, which is closed at the end of the line. This is equivalent for example:

liftIO (LBS.readFile messageFile)

As said before <- is (basically) used for assignment in do notation, using <- is a good way to get rid of a monad container. result now contains the contents of messageFile, the IO is being evaluated and removed by the <- operator. In Haskell a lot of wrapping and unwrapping is done.

Case .. of

  case decode result of

Here we’re decoding the contents of result. decoding JSON may not succeed and therefore the library authors of aeson made decode return a maybe container:

decode :: FromJSON a => ByteString -> Maybe a 

In this signature, a can be anything as long as it implements FromJSON. We fulfill this condition with generic and instance FromJSON Message. We give as bytestring the result to decode, in return it gives us Maybe a. The compiler deduces that a in this case is [Message].

The return value will have content if decoding succeeded (Just), or it won’t if it fails (Nothing). To get rid of the container we pattern match it. This can be thought of as a switch case statement in other languages.

Nothing

    Nothing -> pure []

In this case decoding fails. An empty list is returned to the client. We still must wrap this list in a Handler, pure is used for that. Note that pure == return. These functions both exist for historical reasons.

Just a

    Just x -> do

In this case there is success, the result is taken and put into x, after which another do block starts.

Let

      let contents = x ++ [message]

Unlike the <- operator, let does not do any unwrapping. We can see what happens if we replace the let binding by contents <- x ++ [message], the errors are:

/home/jappie/projects/haskell/awesome-project-name/src/Lib.hs:50:19: error:
    • Couldnt match type[]’ with ‘Handler’
      Expected type: Handler Message
        Actual type: [Message]
    • In a stmt of a 'do' block: contents <- x ++ [message]
      In the expression:
        do contents <- x ++ [message]
           liftIO $ LBS.writeFile messageFile (encode contents)
           return contents
      In a case alternative:
          Just x
            -> do contents <- x ++ [message]
                  liftIO $ LBS.writeFile messageFile (encode contents)
                  return contents
   |
50 |       contents <- x ++ [message]
   |                   ^^^^^^^^^^^^^^

/home/jappie/projects/haskell/awesome-project-name/src/Lib.hs:52:7: error:
    • Couldnt match type ‘Message’ with ‘[Message]’
      Expected type: Handler [Message]
        Actual type: Handler Message
    • In a stmt of a 'do' block: return contents
      In the expression:
        do contents <- x ++ [message]
           liftIO $ LBS.writeFile messageFile (encode contents)
           return contents
      In a case alternative:
          Just x
            -> do contents <- x ++ [message]
                  liftIO $ LBS.writeFile messageFile (encode contents)
                  return contents
   |
52 |       return contents
   |       ^^^^^^^^^^^^^^^

In the first error the compiler says that a list is not a Handler container. Which we expect because the return type of this function is Handler [Message].

The second error assumes contents is of the correct type, since <- unwraps contents would be of type Message. The return type does not fit in this case either, we would get Handler Message instead of Handler [Message].

Write

      liftIO $ LBS.writeFile messageFile (encode contents)

We encode the contents, then we write it to the message file in an IO effect. Type safety ensures encoding always succeeds.

Return

      return contents

Finally we wrap the contents in a Handler type.

Adding the handler to the routes map

server :: Server UserAPI
server = (pure users) :<|> messages

This is the implementation of the UserAPI type described before. We use the same operator to also add the messages handler into the server. We don’t need to put messages in a container with pure because the functions’ return type is already a Handler.

Something worth pointing out is that this construction is in order, if we pull it out of order we would get a type level. Changing the line into:

server =  messages :<|> (pure users)

Will cause an error:

/home/jappie/projects/haskell/awesome-project-name/src/Lib.hs:55:11: error:
    • Could not match type[User]’ with ‘Handler [Message]’
      Expected type: Server UserAPI
        Actual type: (Message -> Handler [Message])
                     :<|> (Message -> [User])
    • In the expression: messages :<|> (pure users)
      In an equation for ‘server’: server = messages :<|> (pure users)
   |
55 | server =  messages :<|> (pure users)
   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^

Execute it!

curl --header "Content-Type: application/json" \ 
  --request POST \
  --data '{"from":{"email":"d","name":"xyz"}, "content": "does it word?"}' \
  http://localhost:6868/message

Worked on this machine…

Worked on my machine, lol, now we use <abbr>IO</abbr> everything is over

Perhaps that’s why the theorists avoid IO 🤔.

In conclusion

Without going into much theory, we dealt with IO. For example we saw that haskell is about dealing with containers, to put certain functions in IO rather than the return type, one uses liftIO. do notation was also encountered, which makes working with monads easier. Now we can affect the world with our programs trough IO!

The complete code can be found here. In the future we shall attach this simple web server to a database.

haskell programming

Recent stuff

Tags