- Overview
- Project Setup
- API Type
- Serving JSON
- Parsing JSON
- Adding a Database
- Running Queries
- Finishing Up: Exercises
Overview
In this tutorial, we’re going to use Spock to build a simple RESTful API that will let us add people to a database and then retrieve a list of who we’ve added. For example:
$ curl -H "Content-Type: application/json" -d '{ "name": "Walter", "age": 50 }' localhost:8080/people
{"result":"success","id":1}
$ curl -H "Content-Type: application/json" -d '{ "name": "Jesse", "age": 22 }' localhost:8080/people
{"result":"success","id":2}
$ curl localhost:8080/people
[{"age":50,"name":"Walter","id":1},{"age":22,"name":"Jesse","id":2}]
We’ll be using curl to interact with our API so you should have
that or another way to perform HTTP requests. curl examples are provided throughout
the tutorial.
You can find the finished code here. “part1” covers up to Adding a Database; “part2” covers up to Finishing up.
Project Setup
Before using Spock, you’ll need to install the Haskell toolchain on your
machine. We recommend using the stack tool to
quickly get started! Our guide will be stack based, but you can easily
translate this to cabal. Next, you can prepare a directory for your first
Spock powered application:
- Create a new project using
stack new Spock-rest - Jump into the directory
cd Spock-rest
Dependencies
To make sure your dependencies will match those used in this tutorial, set
your project to use Stackage LTS 16.18: Open your stack.yaml file, find the
resolver key and make sure it is configured correctly:
resolver:
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/16/18.yaml
Next, you’re going to add the packages you’ll be using: Spock, aeson
and text. To do this, open your Spock-rest.cabal file, find the
executable Spock-rest-exe section and add aeson, Spock and text
to the build-depends key. There should also be a Spock-rest entry which
you can remove or just ignore. The result should look something like this:
build-depends: base
, aeson
, Spock
, text
During the first build via stack build --fast --pedantic, stack may ask you to add further entries
to extra-deps. Follow these instructions.
Imports
Let’s start by adding a couple of language extensions and imports. Open
app/Main.hs and replace the content with:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
import Web.Spock
import Web.Spock.Config
import Data.Aeson hiding (json)
import Data.Monoid ((<>))
import Data.Text (Text, pack)
import GHC.GenericsWe’ll be using the DeriveGeneric extension along with GHC.Generics
to create FromJSON and ToJSON instances of our API type.
The Data.Aeson library provides our JSON type conversion; you can view its
documentation
to learn more.
API Type
Now we’ll create a data type for our API. Add the following to your Main.hs,
below the import statements:
data Person = Person
{ name :: Text
, age :: Int
} deriving (Generic, Show)
instance ToJSON Person
instance FromJSON PersonWe can try out our new Person type in GHCi: run stack ghci in your
project root directory to build your project and start a REPL session. Try
using encode and decode to serialise and deserialise Persons into
ByteStrings like so:
λ> encode Person { name = "Leela", age = 25 }
"{\"age\":25,\"name\":\"Leela\"}"
-- So our string literal can inferred as a ByteString
λ> :set -XOverloadedStrings
λ> decode "{ \"name\": \"Amy\", \"age\": 30 }" :: Maybe Person
Just (Person {name = "Amy", age = 30})
Aeson’s decode type signature is:
FromJSON a => ByteString -> Maybe aSo we have to tell GHCi what we want type we want it to try decode our bytestring as,
otherwise it will default to (). We can also decode it as Aeson’s generic Value type:
λ> decode "{ \"name\": \"Amy\", \"age\": 30 }" :: Maybe Value
Just (Object (fromList [("age",Number 30.0),("name",String "Amy")]))
Serving JSON
Now we’ll add a basic Spock application to serve our JSON. Add the
following to your Main.hs:
type Api = SpockM () () () ()
type ApiAction a = SpockAction () () () a
main :: IO ()
main = do
spockCfg <- defaultSpockCfg () PCNoDatabase ()
runSpock 8080 (spock spockCfg app)
app :: Api
app = do
get "people" $ do
json $ Person { name = "Fry", age = 25 }Our Api type represents our application’s configuration. In the second
part we’ll be modifying it to add a database backend, but for now we’ll
leave all the types as Unit. Our ApiAction type is similar and represents
actions in our application which are functions performed by route matches
(e.g. get "people"). We’ll be using ApiAction later to explicitly declare
some types used in actions.
Spock includes a json function for serving any type that implements the
ToJSON typeclass, which means you can pass your Person and it will encode
it as JSON and set the HTTP Content-Type header to application/json for
you. You can start the server in GHCi by first reloading the project using
the :reload command then running your main function:
λ> :reload
[2 of 2] Compiling Main
Ok, modules loaded: Lib, Main.
λ> main
Spock is running on port 8080
Go to localhost:8080/people and you should
see your Person object in JSON.
REST APIs also need to serve lists of items; since aeson includes a ToJSON
a => ToJSON [a] instance, we can easily serve a list of Persons. Change
your get function to the following:
get "people" $ do
json [Person { name = "Fry", age = 25 }, Person { name = "Bender", age = 4 }]Enter ctrl-c in GHCi to interrupt the server and get back to the prompt. Then,
reload your project and start the server again. Refresh your browser to see
your two Persons in a JSON array.
Parsing JSON
Now we’ll write a second route that will attempt to parse a POST body into our
Person type. Add the following to the bottom of your app declaration:
post "people" $ do
thePerson <- jsonBody' :: ApiAction Person
text $ "Parsed: " <> pack (show thePerson)
Reload your project and start the server again. We’ll need to make a POST
request to try out our new code. Here’s a way of doing so using curl:
$ curl -H "Content-Type: application/json" -d '{ "name": "Bart", "age": 10 }' localhost:8080/people
Parsed: Person {name = "Bart", age = 10}
You can also try adding extra keys to your JSON object or removing name and age and seeing what happens.
Adding a Database
Now that we’ve seen how to do some simple REST-style requests, we’ll add a database to our application so we can provide proper API functionality. We’re going to be using the Persistent library and SQLite. The Persistent chapter in the Yesod Book covers a lot of what we’ll be using Persistent for and much more, so make sure to refer to it while following this section.
To use Persistent, we’re first going to add its dependencies to our
cabal file. Add these entries to your build-depends key:
, monad-logger
, persistent
, persistent-sqlite
, persistent-template
You’ll have to restart GHCi so it can build these new dependencies.
Exit GHCi with :quit and run stack ghci again. Stack should
rebuild your project and show you the GHCi prompt.
We’ll have to add quite a few more language extensions and imports.
At the top of your Main.hs, add the following lines below your current
extensions:
{-# LANGUAGE EmptyDataDecls #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}And these imports below your existing ones:
import Control.Monad.Logger (LoggingT, runStdoutLoggingT)
import Database.Persist hiding (get) -- To avoid a naming clash with Web.Spock.get
import qualified Database.Persist as P -- We'll be using P.get later for GET /people/<id>.
import Database.Persist.Sqlite hiding (get)
import Database.Persist.THNext, we’re going to replace our existing Person declaration with a
Persistent-specific one. This new one will generate code at compile time
that will allow us to serialise our datatype to SQLite with ease. Find and
remove your data Person = ... declaration along with instance FromJSON
and instance ToJSON and replace them with the following:
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person json -- The json keyword will make Persistent generate sensible ToJSON and FromJSON instances for us.
name Text
age Int
deriving Show
|]This code will (among other things) generate record names for Person
that are slightly different to our initial ones: name becomes personName
and age becomes personAge. If you change your Person definitions in
your get function accordingly:
json
[ Person {personName = "Fry", personAge = 25}
, Person {personName = "Bender", personAge = 4}
]
Then your code should still compile and behave similarly to how it did before the change.
Creating the Connection Pool
Spock’s configuration type includes
a database helper that manages connection pools. Let’s create a SQLite
connection pool and add it to our Spock configuration. Replace the spockCfg
assignment (the spockCfg <- ... line) in your main function with the
following:
pool <- runStdoutLoggingT $ createSqlitePool "api.db" 5
spockCfg <- defaultSpockCfg () (PCPool pool) ()If we look at createSqlitePool’s
type signature, we can see the MonadLogger m constraint. This means that
createSqlitePool needs to be in a monad that provides logging, so we run
it inside runStdoutLoggingT which will print the function’s log messages to
standard output, allowing us to see its debug messages in our console.
Because we’ve changed the structure of our application to contain a
connection pool, we’ll have to change our Spock application’s types to
reflect this. Change your Api type:
type Api = SpockM SqlBackend () () ()and your ApiAction type:
type ApiAction a = SpockAction SqlBackend () () aCreating the Schema
Our new Person declaration also includes functionality for Persistent to
migrate our schema. We’ll call Persistent’s runMigration function which
will create our schema for us. Insert this line right below the spockCfg
definition:
runStdoutLoggingT $ runSqlPool (do runMigration migrateAll) poolThe runSqlPool
function, which also needs to be in a MonadLogger monad, takes two arguments:
a function that performs actions using a connection from a pool (which here
is wrapped in parentheses), and the pool itself.
Running Queries
We’ll use a small helper function for running queries in our server.
Add the following function to your Main.hs:
runSQL
:: (HasSpock m, SpockConn m ~ SqlBackend)
=> SqlPersistT (LoggingT IO) a -> m a
runSQL action = runQuery $ \conn -> runStdoutLoggingT $ runSqlConn action connIf you compare the right side of the runSQL definition to the migration code in the last
section, you’ll see some similarities:
runQuery $ \conn -> runStdoutLoggingT $ runSqlConn action conn -- runSQL
runStdoutLoggingT $ runSqlPool (do runMigration migrateAll) pool -- migrationSo, our runSQL function really just calls runQuery and uses the connection it provides
to perform some database actions.
Adding People
Now we’re ready to use our database in our application. Let’s change our
POST /people function to insert the parsed Person into our database and
show an appropriate JSON response. First though, add one one more helper
function to generate simple JSON errors:
errorJson :: Int -> Text -> ApiAction ()
errorJson code message =
json $
object
[ "result" .= String "failure"
, "error" .= object ["code" .= code, "message" .= message]
]Because it’s good practice for an API to always respond with JSON (or your preferred data format), we’ll use this instead of just plain text when reporting errors. Returning error codes is another good practice that allows users to troubleshoot issues more easily.
Now change your post action to use the errorJson function and insert
a person into the database:
post "people" $ do
maybePerson <- jsonBody :: ApiAction (Maybe Person)
case maybePerson of
Nothing -> errorJson 1 "Failed to parse request body as Person"
Just thePerson -> do
newId <- runSQL $ insert thePerson
json $ object ["result" .= String "success", "id" .= newId]Reload your project and start the server again. Try adding a couple of people:
$ curl -H "Content-Type: application/json" -d '{ "name": "Walter", "age": 50 }' localhost:8080/people
{"result":"success","id":1}
$ curl -H "Content-Type: application/json" -d '{ "name": "Jesse", "age": 22 }' localhost:8080/people
{"result":"success","id":2}
Listing People
Now we’ll change our get action to return a list of all the people in our table:
get "people" $ do
allPeople <- runSQL $ selectList [] [Asc PersonId]
json allPeopleReload and start the server and go to localhost:8080/people to see your list.
Getting a Specific Person
If you look at the JSON response for
localhost:8080/people, you should see that your
Person objects now have id keys. These values are automatically inserted
by Persistent: we’ll use them to get a person by their id. Add the following
route function to your app:
get ("people" <//> var) $ \personId -> do
maybePerson <- runSQL $ P.get personId :: ApiAction (Maybe Person)
case maybePerson of
Nothing -> errorJson 2 "Could not find a person with matching id"
Just thePerson -> json thePerson
And again, reload and try it out: localhost:8080/people/1.
Finishing Up: Exercises
We’ve implemented the foundation of our API and some of the basic functionality. Practice adding some features yourself by trying out these exercises:
- Try adding PUT and DELETE routes yourself. Note that we hid
Database.Persist.deleteto avoid a naming clash withWeb.Spock.delete, so you’ll have to use thePnamespace qualifier.Click for hints
For the PUT action you'll want to useWeb.Spock.putandDatabase.Persist.Class.replace. The Persistent chapter in the Yesod Book has an example of using bothreplaceanddelete. For the DELETE action, you may have to explicitly declare your route variable as aPersonIdso that GHC knows which type of database object you want to delete. We've explicitly declared values a couple of times in our tutorial, such as decoding toPersonandValueand when using Spock actions. - It is good practice to set appropriate HTTP status codes, for example:
201 Createdfor successful POST and PUT actions400 Bad Requestfor failed POST and PUT actions404 Not FoundforGET /people/<id>route
Try setting appropriate HTTP status codes for your actions
Click for hints
Look at the type ofWeb.Spock.Action.setStatus: it takes a single argument of typeStatusand needs to be called in a Spock action context. You'll have to addhttp-typesto your project dependencies and import its Status module to use theStatusdefinitions. - Spock has a error handler for generic errors such missing routes,
runtime exceptions, etc. By default, this returns generic HTML pages. Use
Web.Spock.Config.spc_errorHandlerto change your application to return generic JSON error responses. You can try any other route like localhost:8080/ to see the default 404 error response.Click for hints
Your current config datatype is being constructed by passing arguments todefaultSpockCfg. You'll want to change this to use the records listed inWeb.Spock.Configdocumentation.
Thespc_errorHandlertype signature shows that it takes aStatusand returns a Spock action, so you'll need write a function that pattern matches onStatustypes introduced in the last exercise and returns JSON errors. If you want to useerrorJson, you'll have to change its type signature to allow bothIOandWebStateM(SpockAction's) monads.