Practical Haskell - Building a JSON API
This is part of a tutorial series intended to introduce Haskell by coding things that work. In this article we will be building a simple JSON API using Scotty and Aeson.
Before you Begin
This article assumes you have a project set up with stack, that you know how to import a 3rd party module, how to write a simple IO program, and how to write a simple function.
We recommend you work through all 3 previous tutorials. In Getting Started we teach you how to build an run Haskell projects, in Importing Code we show you how to import other modules, and in Using Monads we teach you how to use do
notation.
Installing Scotty
Scotty is a haskell library that lets you make JSON APIs. It’s a lot like Express or Sinatra. You define routes and it makes a web server that will respond to HTTP requests.
Use the info from Importing Code to install the scotty
package, then run stack build
to get stack to install it into your project. If this is the first time you’ve added a dependency, it might take a while, but next time it’ll be faster.
$ stack build
...
Completed all 59 actions.
A simple web server
Let’s start with a simple IO program. Replace src/Main.hs
with the following
module Main where
main = do
putStrLn "Starting Server..."
Before we can use it, we need to import scotty. Add this line to the top:
module Main where
import Web.Scotty
main = do
...
Now, let’s use the scotty
function in main
to start a scotty server:
module Main where
import Web.Scotty
main = do
putStrLn "Starting Server..."
scotty 3000 $ do
get "/hello" $ do
text "hello world!"
Finally we need a GHC feature called OverloadedStrings that let’s use string literals for other things (like routes, or text).
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Web.Scotty
main = do
putStrLn "Starting Server..."
scotty 3000 $ do
get "/hello" $ do
text "hello world!"
Let’s run this just like last time. Fire up stack ghci
, load our code, and run main
$ stack ghci
Prelude> :load Main
*Main> main
Starting Server...
Setting phasers to stun... (port 3000) (ctrl-c to quit)
We can test it using our web browser: navigate to http://localhost:3000/hello and you should see the result:
hello world!
What’s with the nested do blocks?
Remember from Using Monads that each do block can only contain one type of action. main
always has IO actions, so both putStrLn
and scotty
must return an IO action.
scotty :: Port -> ScottyM () -> IO ()
But it looks like in order to return an IO
action, we need to pass a ScottyM
action as the second parameter. Our program is equivalent to this:
main = do
putStrLn "Starting Server..."
scotty 3000 someScottyMAction
That nested do block after the port number is just a ScottyM
action. We could have done this instead:
routes :: ScottyM ()
routes = do
get "/hello" $ do
text "hello world!"
main = do
putStrLn "Starting Server..."
scotty 3000 routes
The same thing goes for the handlers. Look at the type of get
:
get :: RoutePattern -> ActionM () -> ScottyM ()
Same thing again, we pass a ActionM ()
as the last parameter. We could have written routes like this:
routes :: ScottyM ()
routes = do
get "/hello" hello
hello :: ActionM ()
hello = do
text "hello world!"
More Features: Route Parameters
We can add route parameters with :xxx
in the url, just like Sinatra and express. To read it in our handler, we use the param
function. Edit the /hello
route to be this:
get "/hello/:name" $ do
name <- param "name"
text ("hello " <> name <> "!")
To get this to work we also need to import <>
, which concatenates things that aren’t strings. Add this to the top:
import Data.Monoid ((<>))
Let’s test! Go over to GHCI and type Ctrl-C to stop main
. Then reload with :r
and type main
again. Check it out: http://localhost:3000/hello/bob. Try out a few different names for that last parameter
hello bob!
If you get stuck, be sure to check out the source
Define Types for your API
We can make data types that describe our API. Here we have a User
object with two fields. Add these to src/Main.hs
at the top level.
data User = User { userId :: Int, userName :: String } deriving (Show)
We can define some hard-coded users
bob :: User
bob = User { userId = 1, userName = "bob" }
jenny :: User
jenny = User { userId = 2, userName = "jenny" }
Or a list of users
allUsers :: [User]
allUsers = [bob, jenny]
Let’s see if it works! Reload in GHCI.
*Main> :r
*Main> bob
User { userId = 1, userName = "bob" }
Let’s do JSON with Aeson
The Aeson library lets you serialize data objects to JSON and vice versa. Let’s install it the same way we did with scotty. Add it to build-depends
and run stack build
again. Then import it in your code:
import Data.Aeson (FromJSON, ToJSON)
You can tell Aeson how to convert your objects to JSON manually, but it’s easier to use some fancy GHC features. One of them, called Generics, lets you automatically do things based on the data type. Add these to the top:
{-# LANGUAGE DeriveGeneric #-}
...
import GHC.Generics
Now we can have our data type “derive” Generic
data User = User { userId :: Int, userName :: String } deriving (Show, Generic)
Then add this below to make any User
JSON serializable.
instance ToJSON User
instance FromJSON User
Now User
can be encoded or decoded. Let’s see if it works. Reload in GHCI and test:
*Main> :r
*Main> import Data.Aeson (encode)
*Main Data.Aeson> encode bob
"{\"userName\":\"bob\",\"userId\":1}"
Return Users from our API
Let’s add a new route /users
that will return a list of users. We’ll use scotty’s json
function instead of text
. It will automatically call encode
for us.
get "/users" $ do
json allUsers
Let’s test it in the browser: http://localhost:3000/users
[{"userName":"bob","userId":1},{"userName":"jenny","userId":2}]
Or we can return all users with a given id. First, let’s make a pure function that returns true if an id matches. Put this at the top level
matchesId :: Int -> User -> Bool
matchesId id user = userId user == id
Now add a new route that uses that function:
get "/users/:id" $ do
id <- param "id"
json (filter (matchesId id) allUsers)
Test it out: http://localhost:3000/users/1
[{"userName":"bob","userId":1}]
The complete program
Here’s what src/Main.hs
should look like when you’re done. Check out the full source here
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
module Main where
import Data.Monoid ((<>))
import Data.Aeson (FromJSON, ToJSON)
import GHC.Generics
import Web.Scotty
data User = User { userId :: Int, userName :: String } deriving (Show, Generic)
instance ToJSON User
instance FromJSON User
bob :: User
bob = User { userId = 1, userName = "bob" }
jenny :: User
jenny = User { userId = 2, userName = "jenny" }
allUsers :: [User]
allUsers = [bob, jenny]
matchesId :: Int -> User -> Bool
matchesId id user = userId user == id
main = do
putStrLn "Starting Server..."
scotty 3000 $ do
get "/hello/:name" $ do
name <- param "name"
text ("hello " <> name <> "!")
get "/users" $ do
json allUsers
get "/users/:id" $ do
id <- param "id"
json (filter (matchesId id) allUsers)
Playing around
Check out the Scotty docs to see what else you can do. Remember, anything that returns ScottyM a
can be used in the routes do
block, and anything that returns an ActionM a
can be used in the handlers.
Assignment
Create a route that accepts a User
via POST
, and prints it back out.
What’s next
Feel free to suggest another tutorial in the comments!