Building a REST API

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
      - reroute

In 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.Generics

We’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 Person

We 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 a

Give 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