wrecker is intended to benchmark HTTP calls inline with other forms of
processing. This allows for complex the interactions necessary to benchmark
certain API endpoints.
wrecker lets you build elegant API clients that you can use for profiling.
Here is the the final benchmark utlizing the typed REST client we will build.
testScript :: Int -> ConnectionContext -> Recorder -> IO ()
testScript port cxt rec = withSession cxt rec $ \sess -> do
Root { products
, login
, checkout
} <- get sess (rootRef port)
firstProduct : _ <- get sess products
userRef <- rpc sess login
( Credentials
{ userName = "a@example.com"
, password = "password"
}
)
User { usersCart } <- get sess userRef
Cart { items } <- get sess usersCart
insert sess items firstProduct
rpc sess checkout cart
If this doesn't make sense on inspection, that is okay. This file builds up all the necessary utilities and documents every line.
Most of the code in this file is "generic." It is the type of boilerplate you make once for an API client.
You don't need to make a polished typed API client to use wrecker;
just look at TODO_MAKE_AESON_LENS_EXAMPLE.
- Boring Haskell Prelude
- Make a Somewhat Generic JSON API
- Make a Somewhat Generic REST API
- The Example API
- Profiling Script
This is Haskell, so first we turn on the extensions we would like to use.
{-# LANGUAGE NamedFieldPuns, DeriveGeneric, OverloadedStrings, CPP #-}NamedFieldPunswill let us destructure records conveniently.DeriveAnyClassandDeriveGenericare used turned on so the compiler can generate the JSON conversion functions for us automatically.OverloadedStringsis a here so Redditors don't yell at me for usingStringinstead ofText.CPP...ignore that...
#ifndef _CLIENT_IS_MAIN_
module Client where
#endifNot the drones...
import Wrecker (defaultMain, Environment)defaultMainis one of two entry pointswreckerprovides (the other isrun).defaultMainperforms command line argument parsing for us, and runs the benchmarks with the provided options.Environmentcontains the necessary state to record the times of requests and it has a preallocated TLS context.
import Data.AesonWe need JSON, so of course we are using aeson.
import Network.Wreq (Response)
import Network.Wreq.Wrecker (Session)
import qualified Network.Wreq.Wrecker as WWwrecker provides a wrapped version of Network.Wreq.Session called
Network.Wreq.Wrecker. Importing is the quickest way to write a benchmark with
wrecker
import GHC.Generics
import Data.Text as T
import Network.HTTP.Client (responseBody)wreq is pretty easy to use for JSON APIs, but it could be easier. Here we make
a quick wrapper around wreq, specialized to JSON.
We wrap all JSON sent to and from the server in the envelope.
The envelope is serialized to JSON with the following format
{"value" : RESPONSE_SPECIFIC_OUTPUT }It is represented in Haskell as
data Envelope a = Envelope { value :: a }
deriving (Show, Eq, Generic)
instance FromJSON a => FromJSON (Envelope a)
instance ToJSON a => ToJSON (Envelope a) The Envelope only exists to transmit data between the server and the browser.
-
We wrap values going to the server in an
Envelope.toEnvelope :: ToJSON a => a -> Value toEnvelope = toJSON . Envelope
-
We unwrap values coming from the server in
Envelope.fromEnvelope :: FromJSON a => IO (Response (Envelope a)) -> IO a fromEnvelope x = fmap (value . responseBody) x
-
We wrap inputs and unwrap outputs so we can wrap a whole function.
liftEnvelope :: (ToJSON a, FromJSON b) => (Value -> IO (Response (Envelope b))) -> (a -> IO b) liftEnvelope f = fromEnvelope . f . toEnvelope
We hide the Envelope in JSON specialized get's and post's.
jsonGet :: FromJSON a => Session -> Text -> IO a
jsonGet sess url = fromEnvelope $ WW.getJSON sess (T.unpack url)
jsonPost :: (ToJSON a, FromJSON b) => Session -> Text -> a -> IO b
jsonPost sess url = liftEnvelope $ WW.postJSON sess (T.unpack url)Working with JSON is okay, but this is Haskell so we would rather work with types.
We represent resource URLs using the type Ref.
data Ref a = Ref { unRef :: Text }
deriving (Show, Eq)Ref is nothing more than a Text wrapper (the value there is the URL). Ref
has a phantom type a, which enables us to talk about different types of resources.
Ref a's FromJSON instance wraps a Text value, after scrutinizing the JSON Value to ensure it is Text.
instance FromJSON (Ref a) where
parseJSON = withText "FromJSON (Ref a)" (return . Ref)The ToJSON is just the reverse.
instance ToJSON (Ref a) where
toJSON (Ref x) = toJSON xIn addition to resources, our API has ad-hoc RPC calls. RPC calls are also represented as a URL.
data RPC a b = RPC Text
deriving (Show, Eq)
instance FromJSON (RPC a b) where
parseJSON = withText "FromJSON (Ref a)" (return . RPC)We utilize our jsonGet and jsonPost functions, and make specialized versions
for our more specific REST and RPC calls.
-
gettakes aRef aand returns ana. Theacould be something likeCart, or it could be a list like[Ref a].get :: FromJSON a => Session -> Ref a -> IO a get sess (Ref url) = jsonGet sess url
-
inserttakes aRefto a list and appends an item to it. It returns the reference that you passed in because, why not.insert :: ToJSON a => Session -> Ref [a] -> a -> IO (Ref [a]) insert sess (Ref url) = jsonPost sess url
-
rpcunpacks the URL for the RPC endpoint andPOSTs the input, returning the output.rpc :: (ToJSON a, FromJSON b) => Session -> RPC a b -> a -> IO b rpc sess (RPC url) = jsonPost sess url
The API requires an initial call to the "/root" to obtain the URLs for subsequent calls.
rootRef :: Int -> Ref Root
rootRef port = Ref $ T.pack $ "http://localhost:" ++ show port ++ "/root"Calling GET on "/root" returns the following JSON
{ "products" : "http://localhost:3000/products"
, "carts" : "http://localhost:3000/carts"
, "users" : "http://localhost:3000/users"
, "login" : "http://localhost:3000/login"
, "checkout" : "http://localhost:3000/checkout"
}Which will deserialize to
data Root = Root
{ products :: Ref [Ref Product]
, carts :: Ref [Ref Cart ]
, users :: Ref [Ref User ]
, login :: RPC Credentials (Ref User)
, checkout :: RPC (Ref Cart) ()
} deriving (Eq, Show, Generic)
instance FromJSON RootSince the JSON is so uniform, we can use aeson's generic instances.
Calling GET on a Ref Product or "/products/:id" gives
{ "summary" : "shirt" }Which will deserialize to
data Product = Product
{ summary :: Text
} deriving (Eq, Show, Generic)
instance FromJSON ProductCalling GET on a Ref Cart or "/carts/:id" gives
{ "items" : ["http://localhost:3000/products/0"] }...
data Cart = Cart
{ items :: Ref [Ref Product]
} deriving (Eq, Show, Generic)
instance FromJSON CartCalling GET on a Ref User or "/users/:id" gives
{ "cart" : "http://localhost:3000/carts/0"
, "username" : "example"
}data User = User
{ cart :: Ref Cart
, username :: Text
} deriving (Eq, Show, Generic)
instance FromJSON UserThe only additional type that we need is the input for the login RPC, mainly the Credentials type.
{ "password" : "password"
, "userid" : "a@example.com"
}data Credentials = Credentials
{ password :: Text
, userid :: Text
} deriving (Eq, Show, Generic)
instance ToJSON CredentialsWe can now easily write our first script!
testScript :: Int -> Environment -> IO ()
testScript port = WW.withWreq $ \sess -> doBootstrap the script and get all the URLs for the endpoints. Unpack
products, login and checkout refs for use later down.
Root { products
, login
, checkout
} <- get sess (rootRef port)We get all products and name the first one.
firstProduct : _ <- get sess productsLogin and get the user's ref.
userRef <- rpc sess login
( Credentials
{ userid = "a@example.com"
, password = "password"
}
)
Get the user and unpack the user's cart.
User { cart } <- get sess userRefGet the cart and unpack the items.
Cart { items } <- get sess cartAdd the first product to the user's cart's items.
insert sess items firstProductCheckout.
rpc sess checkout cartPort is hard coded to 3000 for this example.
benchmarks :: Int -> IO [(String, Environment -> IO ())]
benchmarks port = do
-- Create a TLS context once
return [("test0", testScript port)]
main :: IO ()
main = defaultMain =<< benchmarks 3000