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.
The finished database example and tests are maintained in the Spock repository.
Project Setup
Install GHC 9.14.1 and Stack 3.11.1 with GHCup,
then create a project with the Cabal-only simple template:
stack new spock-rest simple --no-init
cd spock-rest
The explicit template ensures that spock-rest.cabal owns your dependencies.
Stack’s default template uses Hpack: if your existing project contains
package.yaml, edit its dependencies instead of the generated .cabal file.
Dependencies
Create stack.yaml with the same tested package set and source revision used
by the getting started guide:
resolver: nightly-2026-09-01
compiler: ghc-9.14.1
packages:
- .
extra-deps:
- git: https://github.com/agrafix/Spock.git
commit: 1d9bd1d6c89a2ad1bf590f53cc182d68e0ca8fd1
subdirs:
- Spock
- Spock-core
- rerouteIn spock-rest.cabal, replace build-depends under executable spock-rest with:
build-depends: base >= 4.12 && < 5, Spock >= 0.16 && < 0.17, aeson, text
Add -threaded to the executable’s ghc-options; Warp requires the threaded
runtime to serve requests.
Build with stack build --fast --pedantic and commit the generated
stack.yaml.lock with your project. Later sections add database dependencies
to this same .cabal file.
Imports
Let’s start by adding a couple of language extensions and imports. Open
src/Main.hs and replace the content with:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Web.Spock
import Web.Spock.Config
import Data.Aeson
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 aGive the result a type so GHC knows which parser to use. 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 route registration. 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
[1 of 1] Compiling Main
Ok, modules loaded: 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
The complete database example uses Persistent 2.18 and SQLite. It keeps the same JSON fields, adds stable numeric IDs, and returns appropriate HTTP status codes. The Persistent book explains the schema declaration and generated types in more detail.
From the repository you can run the finished application directly:
cabal run spock-rest-example --project-file=cabal.project.tutorials --builddir=dist-newstyle-tutorials -- api.db 8080
Or continue in the spock-rest project created above. Replace its executable
dependencies with:
build-depends: base >= 4.12 && < 5, Spock >= 0.16 && < 0.17, aeson, text, http-types, monad-logger, persistent >= 2.18.1 && < 2.19, persistent-sqlite >= 2.13.3 && < 2.14, transformers, wai, warp
Add other-modules: People to that executable stanza. Database.Persist.TH
is included in persistent; the former persistent-template dependency is
unnecessary.
Keep using the stack.yaml above: its fixed snapshot includes Persistent and
SQLite. Stack trusts the snapshot’s dependency versions, so no allow-newer
setting is needed. For Cabal users, the repository’s optional
cabal.project.tutorials relaxes only Persistent 2.18.1’s Aeson and Template
Haskell upper bounds, which predate Aeson 2.3 and GHC 9.14. Both builds are tested.
The database application
Create src/People.hs with this complete module. The code below is copied
automatically from the compiled and tested repository source.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE EmptyDataDecls #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
module People (withPeopleApp) where
import Control.Monad (void, when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Logger (NoLoggingT, runNoLoggingT)
import Data.Aeson (FromJSON, Value, object, (.=))
import Data.Int (Int64)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Database.Persist as P
import Database.Persist.Sqlite
( SqlBackend, SqlPersistT, fromSqlKey, toSqlKey, runSqlConn, runSqlPool,
runMigrationQuiet, withSqlitePool )
import Database.Persist.TH
import GHC.Generics (Generic)
import qualified Network.Wai as Wai
import Network.HTTP.Types
import Web.Spock
import Web.Spock.Config
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person
name Text
age Int
deriving Show
|]
-- Keep the HTTP representation independent of generated database records.
data PersonInput = PersonInput { name :: Text, age :: Int }
deriving stock (Show, Generic)
instance FromJSON PersonInput
type ApiAction a = SpockAction SqlBackend () () a
withPeopleApp :: Text -> (Wai.Application -> IO a) -> IO a
withPeopleApp database use = runNoLoggingT $ withSqlitePool database 1 $ \pool -> do
-- Suitable for this new demo database. Review production migrations before
-- applying them, and run them once as part of deployment.
void $ runSqlPool (runMigrationQuiet migrateAll) pool
cfg <- liftIO $ defaultSpockCfg () (PCPool pool) ()
let sessions = (spc_sessionCfg cfg) { sc_sessionMode = SessionsDisabled }
application <- liftIO $ spockAsApp $ spock (cfg
{ spc_sessionCfg = sessions,
spc_maxRequestSize = Just (16 * 1024),
spc_errorHandler = \status -> errorJson status "Request failed" }) routes
liftIO $ use application
routes :: SpockM SqlBackend () () ()
routes = do
post "people" $ do
person <- readPerson
key <- runSQL $ P.insert person
setStatus status201
setHeader "Location" $ "/people/" <> T.pack (show $ fromSqlKey key)
json $ object ["result" .= ("success" :: Text), "id" .= fromSqlKey key]
get "people" $ do
people <- runSQL $ P.selectList [] [P.Asc PersonId]
json $ map (\(P.Entity key person) -> personJSON (fromSqlKey key) person) people
get ("people" <//> var) $ \(personId :: Int64) -> do
found <- runSQL $ P.get (toSqlKey personId)
maybe (errorJson status404 "Person not found") (json . personJSON personId) found
put ("people" <//> var) $ \(personId :: Int64) -> do
person <- readPerson
changed <- runSQL $ do
let key = toSqlKey personId
found <- P.get key
case found of
Nothing -> pure False
Just (_ :: Person) -> P.replace key person >> pure True
if changed then json $ personJSON personId person else errorJson status404 "Person not found"
delete ("people" <//> var) $ \(personId :: Int64) -> do
removed <- runSQL $ do
let key = toSqlKey personId
found <- P.get key
case found of
Nothing -> pure False
Just (_ :: Person) -> P.delete key >> pure True
if removed then setStatus status204 >> text "" else errorJson status404 "Person not found"
readPerson :: ApiAction Person
readPerson = do
parsed <- jsonBody :: ApiAction (Maybe PersonInput)
case parsed of
Nothing -> errorJson status400 "Expected a person with name and age"
Just input -> do
when (T.null (T.strip $ name input) || T.length (name input) > 100 || age input < 0 || age input > 130) $
errorJson status400 "Name must have 1 to 100 characters and age must be 0 to 130"
pure $ Person (name input) (age input)
runSQL :: SqlPersistT (NoLoggingT IO) a -> ApiAction a
runSQL query = runQuery $ \connection -> runNoLoggingT $ runSqlConn query connection
personJSON :: Int64 -> Person -> Value
personJSON personId person = object
["id" .= personId, "name" .= personName person, "age" .= personAge person]
errorJson :: MonadIO m => Status -> Text -> ActionCtxT ctx m a
errorJson status message = do
setStatus status
json $ object ["error" .= object ["status" .= statusCode status, "message" .= message]]PersonInput describes the incoming JSON; Persistent generates Person and
its database field accessors from the schema declaration. Keeping the HTTP
representation explicit avoids coupling clients to generated record names.
Typed route captures parse Int64 IDs, and toSqlKey turns them into database
keys. Invalid captures and missing rows both produce JSON 404 responses.
withPeopleApp brackets a one-connection pool, migrates the demo database, and
closes the pool when its caller finishes. runSQL borrows a connection through
Spock’s runQuery and runs a Persistent transaction on it. The update and
delete handlers check existence and modify the row within that transaction.
The example suppresses SQL debug logging, which may contain application data.
Use a new database file while learning. Review migrations and run them once during deployment before changing a production schema. This example has no authentication and disables sessions; add the authorization appropriate to your application before exposing private data. See the browser security guide when introducing cookie authentication.
Start the server
Replace src/Main.hs with:
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import qualified Data.Text as T
import qualified Network.Wai.Handler.Warp as Warp
import People (withPeopleApp)
import System.Environment (getArgs)
import Text.Read (readMaybe)
main :: IO ()
main = do
args <- getArgs
case args of
[database, value] | Just port <- readMaybe value, port > 0 && port <= 65535 ->
withPeopleApp (T.pack database) $
Warp.runSettings (Warp.setHost "127.0.0.1" $ Warp.setPort port Warp.defaultSettings)
_ -> fail "Usage: spock-rest-example DATABASE PORT"The executable takes a database file and port, and binds to loopback:
stack build --fast --pedantic
stack exec spock-rest -- api.db 8080
Reusing api.db after restarting preserves its people. Commit your source and
stack.yaml.lock, not the local database file.
Exercise the API
curl -i -H 'Content-Type: application/json' \
-d '{"name":"Walter","age":50}' http://localhost:8080/people
curl -i http://localhost:8080/people
curl -i http://localhost:8080/people/1
curl -i -X PUT -H 'Content-Type: application/json' \
-d '{"name":"Walter","age":51}' http://localhost:8080/people/1
curl -i -X DELETE http://localhost:8080/people/1
curl -i http://localhost:8080/people/1
| Request | Result |
|---|---|
| Create a valid person | 201, Location: /people/1, and {"result":"success","id":1} for the first row in a new database |
| List people or get one | 200 with JSON including id, name, and age |
| Update an existing person | 200 with the updated JSON representation |
| Delete an existing person | 204 with an empty body |
| Malformed JSON, blank name, or invalid age | 400 with a JSON error |
| Missing person or unmatched route | 404 with a JSON error |
| Consumed body larger than 16 KiB | 413 with a JSON error |
For example, after deleting person 1, the final GET responds with 404 rather
than returning an error object under a success status. errorJson sets the
HTTP status before calling json; a numeric code inside JSON alone does not
change the response status.
The configured spc_errorHandler receives a Status for generic framework
errors. It does not receive an exception or your database/session state. Use
error logging for diagnostics, and return an appropriate generic response to
the caller. Errors sent explicitly by a route are already responses; they do
not automatically pass through the generic error handler.
Continue learning
- Follow the request cookbook for headers, form bodies, uploads, middleware, CORS, and structured logging.
- Add HTTP tests with the testing guide. The database example tests cover CRUD, validation, error statuses, limits, and persistence across reopen.
- Read the security guide before adding authentication or private data.