Lesson 21 / 25

JSON and Web Services

Encode JSON with aeson and build a small HTTP API.

Haskell on the server

aeson is the standard JSON library: types implement ToJSON and FromJSON, usually derived via Generic (deriving (Generic, ToJSON, FromJSON) with DeriveGeneric and DeriveAnyClass), or written by hand for custom formats; encode produces a lazy ByteString and eitherDecode returns an error message on bad input. For web services there are several styles: Scotty is a small, Sinatra-like framework, good for learning and simple APIs; Servant describes an API as a type, from which it derives the server, type-safe clients and OpenAPI documentation, so the compiler checks that handlers match the specification; IHP and Yesod are full-stack frameworks. Most run on the high-performance Warp server and the WAI interface. Database libraries include persistent, postgresql-simple, hasql and beam. A typical service keeps validated domain types and pure business logic in a library, uses a ReaderT Env IO application monad for handlers, and uses aeson types at the boundary.

A JSON API with Scotty and aeson

Generic JSON instances and two routes.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric, DeriveAnyClass #-}
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (FromJSON, ToJSON)
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import GHC.Generics (Generic)
import Network.HTTP.Types.Status (status201, status400)
import Web.Scotty

data Product = Product { sku :: String, pricePaise :: Int }
  deriving (Show, Generic, ToJSON, FromJSON)

main :: IO ()
main = do
  store <- newIORef [Product "pen" 4950, Product "ink" 19900]
  scotty 3000 $ do
    get "/products" $ do
      products <- liftIO (readIORef store)
      json products

    post "/products" $ do
      p <- jsonData :: ActionM Product        -- malformed JSON is rejected with an error response
      if pricePaise p <= 0
        then status status400 >> json ("price must be positive" :: String)
        else do
          liftIO (atomicModifyIORef' store (\ps -> (p : ps, ())))
          status status201
          json p

-- curl -X POST localhost:3000/products -d '{"sku":"pad","pricePaise":9900}' 

Consider Servant for larger APIs

With Servant, the API type is the single source of truth: change a route's type and every handler and client that disagrees fails to compile, and documentation is generated from the same type.

Quick check: How are aeson JSON instances most commonly created for record types?

  • They cannot be created
  • By writing JSON strings manually
  • Derived via Generic with deriving (Generic, ToJSON, FromJSON)
  • From a database schema
Answer

Derived via Generic with deriving (Generic, ToJSON, FromJSON) — Generic deriving creates instances that follow the record's field names.