Tutorial: Building a Bookshelf API

Build a type-safe Haskell Servant API from a Paradox specification. You will define types and endpoints in .dox, generate Haskell types and a Servant API module with validation middleware, and wire it up with Warp.

What You Will Build

A REST API with three endpoints:

Method Path Description

GET

/api/books

List all books

POST

/api/books

Create a book (with validation)

GET

/api/books/:id

Get a book by ID

Prerequisites

  • The paradox CLI on your PATH

  • paradox-servant (the Haskell library or standalone executable)

  • GHC with servant, servant-server, and warp

Step 1: Write the Specification

Create bookshelf.dox:

import std

type Book
  title: Text
  author: Text
  year: Integer

type CreateBookRequest
  title: Text
  author: Text
  year: Integer

valid CreateBookRequest
  title != "" | Title is required
  author != "" | Author is required
  year > 0 | Year must be positive

listBooks: URI
  http://localhost/api/books GET (Unit. [Book])

createBook: URI
  http://localhost/api/books POST (CreateBookRequest. Book)

getBook: URI
  http://localhost/api/books/${Integer} GET (Unit. Book)

Each URI constant declares an endpoint with its HTTP method and input/output types. The valid block adds runtime validation rules to CreateBookRequest.

Step 2: Generate Haskell Types

paradox generate --haskell

This produces .dox/Dox.hs with your data types and validation:

data Book = Book
  { title :: Text
  , author :: Text
  , year :: Integer
  }
  deriving stock (Eq, Ord, Show, Read, Generic)
  deriving anyclass (FromJSON, ToJSON)

data CreateBookRequest = CreateBookRequest
  { title :: Text
  , author :: Text
  , year :: Integer
  }
  deriving stock (Eq, Ord, Show, Read, Generic)
  deriving anyclass (FromJSON, ToJSON)

validCreateBookRequest :: CreateBookRequest -> P.Either Text CreateBookRequest
validCreateBookRequest x =
  let errors = catMaybes
        [ if x.title == "" then P.Just "Title is required" else P.Nothing
        , if x.author == "" then P.Just "Author is required" else P.Nothing
        , if x.year <= 0 then P.Just "Year must be positive" else P.Nothing
        ]
  in if null errors then P.Right x else P.Left (T.intercalate "; " errors)

The validation rules from your .dox spec are compiled into validCreateBookRequest.

Step 3: Generate the Servant API

paradox-servant \
  --path bookshelf.dox \
  --module Bookshelf.API \
  --api-name BookshelfAPI
Because import std brings in additional interface URIs from the standard library, the actual output will include extra routes beyond your bookshelf endpoints. The output shown here is trimmed to the bookshelf routes for clarity.

This produces (trimmed):

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

module Bookshelf.API (BookshelfAPI, withValidation) where

import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString.Lazy as BSL
import Servant
import Dox (Book, CreateBookRequest, validCreateBookRequest)

type BookshelfAPI =
       "api" :> "books" :> Get '[JSON] [Book]
  :<|> "api" :> "books" :> ReqBody '[JSON] CreateBookRequest :> Post '[JSON] Book
  :<|> "api" :> "books" :> Capture "integer" Integer :> Get '[JSON] Book

validate400 :: (a -> Either Text a) -> a -> Handler a
validate400 f x = case f x of
  Right a  -> pure a
  Left err -> throwError err400 { errBody = BSL.fromStrict (TE.encodeUtf8 err) }

withValidation :: Server BookshelfAPI -> Server BookshelfAPI
withValidation (h1 :<|> h2 :<|> h3) =
       h1
  :<|> (\req -> validate400 validCreateBookRequest req >>= h2)
  :<|> h3

The generated module exports:

  • BookshelfAPI — the Servant type-level API

  • withValidation — middleware that runs validCreateBookRequest on POST bodies, returning 400 on failure

Path parameters like ${Integer} become Capture "integer" Integer in the Servant type.

Step 4: Wire It Up

Create Main.hs:

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

module Main where

import Data.IORef
import Network.Wai.Handler.Warp (run)
import Servant

import Bookshelf.API (BookshelfAPI, withValidation)
import Bookshelf.Types (Book(..), CreateBookRequest(..))

server :: IORef [Book] -> IORef Integer -> Server BookshelfAPI
server booksRef nextIdRef =
       listBooksH
  :<|> createBookH
  :<|> getBookH
  where
    listBooksH = liftIO $ readIORef booksRef

    createBookH req = liftIO $ do
      books <- readIORef booksRef
      let book = Book
            { title = req.title
            , author = req.author
            , year = req.year
            }
      writeIORef booksRef (books ++ [book])
      modifyIORef' nextIdRef (+1)
      pure book

    getBookH bookId = do
      books <- liftIO $ readIORef booksRef
      let idx = fromIntegral bookId - 1
      if idx >= 0 && idx < length books
        then pure (books !! idx)
        else throwError err404

app :: IORef [Book] -> IORef Integer -> Application
app booksRef nextIdRef =
  serve (Proxy :: Proxy BookshelfAPI) $
    withValidation (server booksRef nextIdRef)

main :: IO ()
main = do
  booksRef <- newIORef []
  nextIdRef <- newIORef 1
  putStrLn "Bookshelf API running on :3000"
  run 3000 (app booksRef nextIdRef)

The withValidation wrapper ensures CreateBookRequest bodies are validated before reaching createBookH. Invalid requests get a 400 response automatically.

Step 5: Test It

# Create a book
curl -X POST http://localhost:3000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'

# List all books
curl http://localhost:3000/api/books

# Get book by ID
curl http://localhost:3000/api/books/1

# Validation error -- empty title
curl -X POST http://localhost:3000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title": "", "author": "Nobody", "year": 2024}'
# => 400 Bad Request: Title is required

Next Steps