Generating mock curl calls¶
In this example we will generate curl requests with mock post data from a servant API. This may be useful for testing and development purposes. Especially post requests with a request body are tedious to send manually.
Also, we will learn how to use the servant-foreign library to generate stuff from servant APIs.
Language extensions and imports:
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
import Control.Lens ((^.))
import Data.Aeson
import Data.Aeson.Text
import Data.Proxy (Proxy (Proxy))
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8)
import qualified Data.Text.IO as T.IO
import qualified Data.Text.Lazy as LazyT
import GHC.Generics
import Servant ((:<|>), (:>), Get, JSON,
Post, ReqBody)
import Servant.Foreign (Foreign, GenerateList,
HasForeign, HasForeignType, Req,
Segment, SegmentType (Cap, Static),
argName, listFromAPI, path,
reqBody, reqMethod, reqUrl, typeFor,
unPathSegment, unSegment,)
import Test.QuickCheck.Arbitrary
import Test.QuickCheck.Arbitrary.Generic
import Test.QuickCheck.Gen (generate)
import qualified Data.Text as T
Let’s define our API:
type UserAPI = "users" :> Get '[JSON] [User]
:<|> "new" :> "user" :> ReqBody '[JSON] User :> Post '[JSON] ()
data User = User
{ name :: String
, age :: Int
, email :: String
} deriving (Eq, Show, Generic)
instance Arbitrary User where
arbitrary = genericArbitrary
shrink = genericShrink
instance ToJSON User
instance FromJSON User
Notice the Arbitrary User
instance which we will later need to create mock data.
Also, the obligatory servant boilerplate:
api :: Proxy UserAPI
api = Proxy
servant-foreign and the HasForeignType Class¶
Servant-foreign allows us to look into the API we designed.
The entry point is listFromAPI
which takes three types and returns a list of endpoints:
listFromAPI :: (HasForeign lang ftype api, GenerateList ftype (Foreign ftype api)) => Proxy lang -> Proxy ftype -> Proxy api -> [Req ftype]
This looks a bit confusing…
Here is the documentation for the HasForeign
typeclass.
We will not go into details here, but this allows us to create a value of type ftype
for any type a
in our API.
In our case we want to create a mock of every type a
.
We create a new datatype that holds our mocked value. Well, not the mocked value itself. To mock it we need IO (random). So the promise of a mocked value after some IO is performed:
data NoLang
data Mocked = Mocked (IO Text)
Now, we create an instance of HasForeignType
for NoLang
and Mocked
for every a
that implements ToJSON
and Arbitrary
:
instance (ToJSON a, Arbitrary a) => HasForeignType NoLang Mocked a where
typeFor _ _ _ =
Mocked (genText (Proxy :: Proxy a))
What does genText
do? It generates an arbitrary value of type a
and encodes it as text. (And does some lazy to non-lazy text transformation we do not care about):
genText :: (ToJSON a, Arbitrary a) => Proxy a -> IO Text
genText p =
fmap (\v -> LazyT.toStrict $ encodeToLazyText v) (genArb p)
genArb :: Arbitrary a => Proxy a -> IO a
genArb _ =
generate arbitrary
Generating curl calls for every endpoint¶
Everything is prepared now and we can start generating some curl calls.
generateCurl :: (GenerateList Mocked (Foreign Mocked api), HasForeign NoLang Mocked api)
=> Proxy api
-> Text
-> IO Text
generateCurl p host =
fmap T.unlines body
where
body = mapM (generateEndpoint host)
$ listFromAPI (Proxy :: Proxy NoLang) (Proxy :: Proxy Mocked) p
First, listFromAPI
gives us a list of Req Mocked
. Each Req
describes one endpoint from the API type.
We generate a curl call for each of them using the following helper.
generateEndpoint :: Text -> Req Mocked -> IO Text
generateEndpoint host req =
case maybeBody of
Just body ->
body >>= \b -> return $ T.intercalate " " [ "curl", "-X", method, "-d", "'" <> b <> "'"
, "-H 'Content-Type: application/json'", host <> "/" <> url ]
Nothing ->
return $ T.intercalate " " [ "curl", "-X", method, host <> "/" <> url ]
where
method = decodeUtf8 $ req ^. reqMethod
url = T.intercalate "/" $ map segment (req ^. reqUrl . path)
maybeBody = fmap (\(Mocked io) -> io) (req ^. reqBody)
servant-foreign
offers a multitude of lenses to be used with Req
-values.
reqMethod
gives us a straigthforward Network.HTTP.Types.Method
, reqUrl
the url part and so on.
Just take a look at the docs.
But how do we get our mocked json string? This seems to be a bit to short to be true:
maybeBody = fmap (\(Mocked io) -> io) (req ^. reqBody)
But it is that simple!
The docs say reqBody
gives us a Maybe f
. What is f
, you ask? As defined in generateCurl
, f
is Mocked
and contains a IO Text
. How is this Mocked
value created? The HasForeignType::typeFor
does it!
Of course only if the endpoint has a request body.
Some (incomplete) code for url segments:
segment :: Segment Mocked -> Text
segment seg =
case unSegment seg of
Static p ->
unPathSegment p
Cap arg ->
-- Left as exercise for the reader: Mock args in the url
unPathSegment $ arg ^. argName
And now, lets hook it all up in our main function:
main :: IO ()
main =
generateCurl api "localhost:8081" >>= T.IO.putStrLn
Done:
curl -X GET localhost:8081/users
curl -X POST -d '{"email":"wV_b:z!(3DM V","age":10,"name":"=|W"}' -H 'Content-Type: application/json' localhost:8081/new/user
This is of course no complete curl call mock generator, many things including path arguments are missing. But it correctly generates mock calls for simple POST requests.
Also, we now know how to use HasForeignType
and listFromAPI
to generate anything we want.