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 = "[email protected]"
, 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 #-}
NamedFieldPuns
will let us destructure records conveniently.DeriveAnyClass
andDeriveGeneric
are used turned on so the compiler can generate the JSON conversion functions for us automatically.OverloadedStrings
is a here so Redditors don't yell at me for usingString
instead ofText
.CPP
...ignore that...
#ifndef _CLIENT_IS_MAIN_
module Client where
#endif
Not the drones...
import Wrecker (defaultMain, Environment)
defaultMain
is one of two entry pointswrecker
provides (the other isrun
).defaultMain
performs command line argument parsing for us, and runs the benchmarks with the provided options.Environment
contains the necessary state to record the times of requests and it has a preallocated TLS context.
import Data.Aeson
We need JSON, so of course we are using aeson
.
import Network.Wreq (Response)
import Network.Wreq.Wrecker (Session)
import qualified Network.Wreq.Wrecker as WW
wrecker
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 x
In 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.
-
get
takes aRef a
and returns ana
. Thea
could 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
-
insert
takes aRef
to 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
-
rpc
unpacks the URL for the RPC endpoint andPOST
s 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 Root
Since 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 Product
Calling 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 Cart
Calling 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 User
The only additional type that we need is the input for the login
RPC, mainly the Credentials
type.
{ "password" : "password"
, "userid" : "[email protected]"
}
data Credentials = Credentials
{ password :: Text
, userid :: Text
} deriving (Eq, Show, Generic)
instance ToJSON Credentials
We can now easily write our first script!
testScript :: Int -> Environment -> IO ()
testScript port = WW.withWreq $ \sess -> do
Bootstrap 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 products
Login and get the user's ref.
userRef <- rpc sess login
( Credentials
{ userid = "[email protected]"
, password = "password"
}
)
Get the user and unpack the user's cart.
User { cart } <- get sess userRef
Get the cart and unpack the items.
Cart { items } <- get sess cart
Add the first product to the user's cart's items.
insert sess items firstProduct
Checkout.
rpc sess checkout cart
Port 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