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 |
|---|---|---|
|
|
List all books |
|
|
Create a book (with validation) |
|
|
Get a book by ID |
Prerequisites
-
The
paradoxCLI on yourPATH -
paradox-servant(the Haskell library or standalone executable) -
GHC with
servant,servant-server, andwarp
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 runsvalidCreateBookRequeston 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
-
Reference — Full API reference, library usage, and endpoint extraction
-
URI Types — URI literal syntax and structure
-
Haskell Code Generation — Details on generated types and validation
-
Express Tutorial — The same API in TypeScript
-
Gloo-net Tutorial — Generate a Rust/WASM client for this API