RequestBuilder overhaul
This commit is contained in:
parent
9f97de6519
commit
51eb7d4ba2
@ -1,6 +1,7 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE CPP #-}
|
{-# LANGUAGE CPP #-}
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
{-|
|
{-|
|
||||||
Yesod.Test is a pragmatic framework for testing web applications built
|
Yesod.Test is a pragmatic framework for testing web applications built
|
||||||
using wai and persistent.
|
using wai and persistent.
|
||||||
@ -37,14 +38,16 @@ module Yesod.Test
|
|||||||
-- add values, add files, lookup fields by label and find the current
|
-- add values, add files, lookup fields by label and find the current
|
||||||
-- nonce value and add it to your request too.
|
-- nonce value and add it to your request too.
|
||||||
--
|
--
|
||||||
, post
|
|
||||||
, post_
|
|
||||||
, get
|
, get
|
||||||
, get_
|
, post
|
||||||
, doRequest
|
, request
|
||||||
, doRequestHeaders
|
, addHeader
|
||||||
, byName
|
, setMethod
|
||||||
, fileByName
|
, addPostParam
|
||||||
|
, addGetParam
|
||||||
|
, addFile
|
||||||
|
, RequestBuilder
|
||||||
|
, setUrl
|
||||||
|
|
||||||
-- | Yesod can auto generate field ids, so you are never sure what
|
-- | Yesod can auto generate field ids, so you are never sure what
|
||||||
-- the argument name should be for each one of your args when constructing
|
-- the argument name should be for each one of your args when constructing
|
||||||
@ -98,7 +101,7 @@ import qualified Network.HTTP.Types as H
|
|||||||
import qualified Network.Socket.Internal as Sock
|
import qualified Network.Socket.Internal as Sock
|
||||||
import Data.CaseInsensitive (CI)
|
import Data.CaseInsensitive (CI)
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
import Network.Wai.Test hiding (assertHeader, assertNoHeader)
|
import Network.Wai.Test hiding (assertHeader, assertNoHeader, request)
|
||||||
import qualified Control.Monad.Trans.State as ST
|
import qualified Control.Monad.Trans.State as ST
|
||||||
import Control.Monad.IO.Class
|
import Control.Monad.IO.Class
|
||||||
import System.IO
|
import System.IO
|
||||||
@ -159,9 +162,14 @@ getTestYesod = fmap yedSite ST.get
|
|||||||
getResponse :: YesodExample site (Maybe SResponse)
|
getResponse :: YesodExample site (Maybe SResponse)
|
||||||
getResponse = fmap yedResponse ST.get
|
getResponse = fmap yedResponse ST.get
|
||||||
|
|
||||||
data RequestBuilderData = RequestBuilderData
|
data RequestBuilderData site = RequestBuilderData
|
||||||
{ rbdPosts :: [RequestPart]
|
{ rbdPosts :: [RequestPart]
|
||||||
, rbdResponse :: (Maybe SResponse)
|
, rbdResponse :: (Maybe SResponse)
|
||||||
|
, rbdMethod :: H.Method
|
||||||
|
, rbdSite :: site
|
||||||
|
, rbdPath :: [T.Text]
|
||||||
|
, rbdGets :: H.Query
|
||||||
|
, rbdHeaders :: H.RequestHeaders
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Request parts let us discern regular key/values from files sent in the request.
|
-- | Request parts let us discern regular key/values from files sent in the request.
|
||||||
@ -172,7 +180,7 @@ data RequestPart
|
|||||||
-- | The RequestBuilder state monad constructs an url encoded string of arguments
|
-- | The RequestBuilder state monad constructs an url encoded string of arguments
|
||||||
-- to send with your requests. Some of the functions that run on it use the current
|
-- to send with your requests. Some of the functions that run on it use the current
|
||||||
-- response to analize the forms that the server is expecting to receive.
|
-- response to analize the forms that the server is expecting to receive.
|
||||||
type RequestBuilder = ST.StateT RequestBuilderData IO
|
type RequestBuilder site = ST.StateT (RequestBuilderData site) IO
|
||||||
|
|
||||||
-- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application'
|
-- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application'
|
||||||
-- and 'ConnectionPool'
|
-- and 'ConnectionPool'
|
||||||
@ -336,22 +344,30 @@ printMatches query = do
|
|||||||
liftIO $ hPutStrLn stderr $ show matches
|
liftIO $ hPutStrLn stderr $ show matches
|
||||||
|
|
||||||
-- | Add a parameter with the given name and value.
|
-- | Add a parameter with the given name and value.
|
||||||
byName :: T.Text -> T.Text -> RequestBuilder ()
|
addPostParam :: T.Text -> T.Text -> RequestBuilder site ()
|
||||||
byName name value = do
|
addPostParam name value =
|
||||||
RequestBuilderData parts r <- ST.get
|
ST.modify $ \rbd -> rbd
|
||||||
ST.put $ RequestBuilderData ((ReqPlainPart name value):parts) r
|
{ rbdPosts = ReqPlainPart name value : rbdPosts rbd
|
||||||
|
}
|
||||||
|
|
||||||
|
addGetParam :: T.Text -> T.Text -> RequestBuilder site ()
|
||||||
|
addGetParam name value = ST.modify $ \rbd -> rbd
|
||||||
|
{ rbdGets = (TE.encodeUtf8 name, Just $ TE.encodeUtf8 value)
|
||||||
|
: rbdGets rbd
|
||||||
|
}
|
||||||
|
|
||||||
-- | Add a file to be posted with the current request
|
-- | Add a file to be posted with the current request
|
||||||
--
|
--
|
||||||
-- Adding a file will automatically change your request content-type to be multipart/form-data
|
-- Adding a file will automatically change your request content-type to be multipart/form-data
|
||||||
fileByName :: T.Text -> FilePath -> T.Text -> RequestBuilder ()
|
addFile :: T.Text -> FilePath -> T.Text -> RequestBuilder site ()
|
||||||
fileByName name path mimetype = do
|
addFile name path mimetype = do
|
||||||
RequestBuilderData parts r <- ST.get
|
|
||||||
contents <- liftIO $ BSL8.readFile path
|
contents <- liftIO $ BSL8.readFile path
|
||||||
ST.put $ RequestBuilderData ((ReqFilePart name path contents mimetype):parts) r
|
ST.modify $ \rbd -> rbd
|
||||||
|
{ rbdPosts = ReqFilePart name path contents mimetype : rbdPosts rbd
|
||||||
|
}
|
||||||
|
|
||||||
-- This looks up the name of a field based on the contents of the label pointing to it.
|
-- This looks up the name of a field based on the contents of the label pointing to it.
|
||||||
nameFromLabel :: T.Text -> RequestBuilder T.Text
|
nameFromLabel :: T.Text -> RequestBuilder site T.Text
|
||||||
nameFromLabel label = do
|
nameFromLabel label = do
|
||||||
mres <- fmap rbdResponse ST.get
|
mres <- fmap rbdResponse ST.get
|
||||||
res <-
|
res <-
|
||||||
@ -390,94 +406,109 @@ nameFromLabel label = do
|
|||||||
(<>) :: T.Text -> T.Text -> T.Text
|
(<>) :: T.Text -> T.Text -> T.Text
|
||||||
(<>) = T.append
|
(<>) = T.append
|
||||||
|
|
||||||
byLabel :: T.Text -> T.Text -> RequestBuilder ()
|
byLabel :: T.Text -> T.Text -> RequestBuilder site ()
|
||||||
byLabel label value = do
|
byLabel label value = do
|
||||||
name <- nameFromLabel label
|
name <- nameFromLabel label
|
||||||
byName name value
|
addPostParam name value
|
||||||
|
|
||||||
fileByLabel :: T.Text -> FilePath -> T.Text -> RequestBuilder ()
|
fileByLabel :: T.Text -> FilePath -> T.Text -> RequestBuilder site ()
|
||||||
fileByLabel label path mime = do
|
fileByLabel label path mime = do
|
||||||
name <- nameFromLabel label
|
name <- nameFromLabel label
|
||||||
fileByName name path mime
|
addFile name path mime
|
||||||
|
|
||||||
-- | Lookup a _nonce form field and add it's value to the params.
|
-- | Lookup a _nonce form field and add it's value to the params.
|
||||||
-- Receives a CSS selector that should resolve to the form element containing the nonce.
|
-- Receives a CSS selector that should resolve to the form element containing the nonce.
|
||||||
addNonce_ :: Query -> RequestBuilder ()
|
addNonce_ :: Query -> RequestBuilder site ()
|
||||||
addNonce_ scope = do
|
addNonce_ scope = do
|
||||||
matches <- htmlQuery' rbdResponse $ scope <> "input[name=_token][type=hidden][value]"
|
matches <- htmlQuery' rbdResponse $ scope <> "input[name=_token][type=hidden][value]"
|
||||||
case matches of
|
case matches of
|
||||||
[] -> failure $ "No nonce found in the current page"
|
[] -> failure $ "No nonce found in the current page"
|
||||||
element:[] -> byName "_token" $ head $ attribute "value" $ parseHTML element
|
element:[] -> addPostParam "_token" $ head $ attribute "value" $ parseHTML element
|
||||||
_ -> failure $ "More than one nonce found in the page"
|
_ -> failure $ "More than one nonce found in the page"
|
||||||
|
|
||||||
-- | For responses that display a single form, just lookup the only nonce available.
|
-- | For responses that display a single form, just lookup the only nonce available.
|
||||||
addNonce :: RequestBuilder ()
|
addNonce :: RequestBuilder site ()
|
||||||
addNonce = addNonce_ ""
|
addNonce = addNonce_ ""
|
||||||
|
|
||||||
-- | Perform a POST request to url, using params
|
-- | Perform a POST request to url
|
||||||
post :: (Yesod site, RedirectUrl site url)
|
post :: (Yesod site, RedirectUrl site url)
|
||||||
=> url -> RequestBuilder () -> YesodExample site ()
|
=> url
|
||||||
post url paramsBuild = do
|
-> YesodExample site ()
|
||||||
doRequest "POST" url paramsBuild
|
post url = request $ do
|
||||||
|
setMethod "POST"
|
||||||
-- | Perform a POST request without params
|
setUrl url
|
||||||
post_ :: (Yesod site, RedirectUrl site url) => url -> YesodExample site ()
|
|
||||||
post_ = flip post $ return ()
|
|
||||||
|
|
||||||
-- | Perform a GET request to url, using params
|
-- | Perform a GET request to url, using params
|
||||||
get :: (Yesod site, RedirectUrl site url) => url -> RequestBuilder () -> YesodExample site ()
|
get :: (Yesod site, RedirectUrl site url)
|
||||||
get url paramsBuild = doRequest "GET" url paramsBuild
|
=> url
|
||||||
|
-> YesodExample site ()
|
||||||
|
get url = request $ do
|
||||||
|
setMethod "GET"
|
||||||
|
setUrl url
|
||||||
|
|
||||||
-- | Perform a GET request without params
|
setMethod :: H.Method -> RequestBuilder site ()
|
||||||
get_ :: (Yesod site, RedirectUrl site url) => url -> YesodExample site ()
|
setMethod m = ST.modify $ \rbd -> rbd { rbdMethod = m }
|
||||||
get_ = flip get $ return ()
|
|
||||||
|
|
||||||
-- | General interface to performing requests, letting you specify the request method
|
setUrl :: (Yesod site, RedirectUrl site url)
|
||||||
doRequest :: (Yesod site, RedirectUrl site url)
|
=> url
|
||||||
=> H.Method -> url -> RequestBuilder a -> YesodExample site ()
|
-> RequestBuilder site ()
|
||||||
doRequest method url paramsBuild = doRequestHeaders method url [] paramsBuild
|
setUrl url' = do
|
||||||
|
site <- fmap rbdSite ST.get
|
||||||
fromHandler :: Yesod site => HandlerT site IO a -> YesodExample site a
|
eurl <- runFakeHandler
|
||||||
fromHandler h = do
|
|
||||||
site <- fmap yedSite ST.get
|
|
||||||
eres <- runFakeHandler
|
|
||||||
M.empty
|
M.empty
|
||||||
(const $ error "Yesod.Test: No logger available")
|
(const $ error "Yesod.Test: No logger available")
|
||||||
site
|
site
|
||||||
h
|
(toTextUrl url')
|
||||||
either (error . show) return eres
|
url <- either (error . show) return eurl
|
||||||
|
-- FIXME deal with complete URLs
|
||||||
|
let (urlPath, urlQuery) = T.break (== '?') url
|
||||||
|
ST.modify $ \rbd -> rbd
|
||||||
|
{ rbdPath =
|
||||||
|
case DL.filter (/="") $ T.split (== '/') urlPath of
|
||||||
|
("http":_:rest) -> rest
|
||||||
|
("https":_:rest) -> rest
|
||||||
|
x -> x
|
||||||
|
, rbdGets = rbdGets rbd ++ H.parseQuery (TE.encodeUtf8 urlQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
addHeader :: H.Header -> RequestBuilder site ()
|
||||||
|
addHeader header = ST.modify $ \rbd -> rbd
|
||||||
|
{ rbdHeaders = header : rbdHeaders rbd
|
||||||
|
}
|
||||||
|
|
||||||
-- | General interface to performing requests, allowing you to add extra
|
-- | General interface to performing requests, allowing you to add extra
|
||||||
-- headers as well as letting you specify the request method.
|
-- headers as well as letting you specify the request method.
|
||||||
doRequestHeaders :: (Yesod site, RedirectUrl site url)
|
request :: Yesod site
|
||||||
=> H.Method
|
=> RequestBuilder site ()
|
||||||
-> url
|
-> YesodExample site ()
|
||||||
-> [H.Header]
|
request reqBuilder = do
|
||||||
-> RequestBuilder a
|
YesodExampleData app site oldCookies mRes <- ST.get
|
||||||
-> YesodExample site ()
|
|
||||||
doRequestHeaders method url' extrahead paramsBuild = do
|
RequestBuilderData {..} <- liftIO $ ST.execStateT reqBuilder RequestBuilderData
|
||||||
url <- fromHandler $ toTextUrl url'
|
{ rbdPosts = []
|
||||||
let (urlPath, urlQuery) = T.break (== '?') url
|
, rbdResponse = mRes
|
||||||
YesodExampleData app conn oldCookies mRes <- ST.get
|
, rbdMethod = "GET"
|
||||||
|
, rbdSite = site
|
||||||
|
, rbdPath = []
|
||||||
|
, rbdGets = []
|
||||||
|
, rbdHeaders = []
|
||||||
|
}
|
||||||
|
let path = T.cons '/' $ T.intercalate "/" rbdPath
|
||||||
|
|
||||||
-- expire cookies and filter them for the current path. TODO: support max age
|
-- expire cookies and filter them for the current path. TODO: support max age
|
||||||
currentUtc <- liftIO getCurrentTime
|
currentUtc <- liftIO getCurrentTime
|
||||||
let cookies = M.filter (checkCookieTime currentUtc) oldCookies
|
let cookies = M.filter (checkCookieTime currentUtc) oldCookies
|
||||||
cookiesForPath = M.filter (checkCookiePath url) cookies
|
cookiesForPath = M.filter (checkCookiePath path) cookies
|
||||||
|
|
||||||
RequestBuilderData parts _ <- liftIO $ ST.execStateT paramsBuild RequestBuilderData
|
|
||||||
{ rbdPosts = []
|
|
||||||
, rbdResponse = mRes
|
|
||||||
}
|
|
||||||
let maker
|
let maker
|
||||||
| DL.any isFile parts = makeMultipart
|
| DL.any isFile rbdPosts = makeMultipart
|
||||||
| otherwise = makeSinglepart
|
| otherwise = makeSinglepart
|
||||||
req = maker cookiesForPath parts urlPath urlQuery
|
req = maker cookiesForPath rbdPosts rbdMethod rbdHeaders path rbdGets
|
||||||
|
|
||||||
response <- liftIO $ runSession (srequest req) app
|
response <- liftIO $ runSession (srequest req) app
|
||||||
let newCookies = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ simpleHeaders response
|
let newCookies = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ simpleHeaders response
|
||||||
cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies
|
cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies
|
||||||
ST.put $ YesodExampleData app conn cookies' (Just response)
|
ST.put $ YesodExampleData app site cookies' (Just response)
|
||||||
where
|
where
|
||||||
isFile (ReqFilePart _ _ _ _) = True
|
isFile (ReqFilePart _ _ _ _) = True
|
||||||
isFile _ = False
|
isFile _ = False
|
||||||
@ -494,12 +525,12 @@ doRequestHeaders method url' extrahead paramsBuild = do
|
|||||||
boundary :: String
|
boundary :: String
|
||||||
boundary = "*******noneedtomakethisrandom"
|
boundary = "*******noneedtomakethisrandom"
|
||||||
separator = BS8.concat ["--", BS8.pack boundary, "\r\n"]
|
separator = BS8.concat ["--", BS8.pack boundary, "\r\n"]
|
||||||
makeMultipart cookies parts urlPath urlQuery =
|
makeMultipart cookies parts method extraHeaders urlPath urlQuery =
|
||||||
flip SRequest (BSL8.fromChunks [multiPartBody parts]) $ mkRequest
|
flip SRequest (BSL8.fromChunks [multiPartBody parts]) $ mkRequest
|
||||||
([ ("Cookie", Builder.toByteString $ Cookie.renderCookies
|
[ ("Cookie", Builder.toByteString $ Cookie.renderCookies
|
||||||
[(Cookie.setCookieName c, Cookie.setCookieValue c) | c <- map snd $ M.toList cookies])
|
[(Cookie.setCookieName c, Cookie.setCookieValue c) | c <- map snd $ M.toList cookies])
|
||||||
, ("Content-Type", BS8.pack $ "multipart/form-data; boundary=" ++ boundary)
|
, ("Content-Type", BS8.pack $ "multipart/form-data; boundary=" ++ boundary)
|
||||||
] ++ extrahead) urlPath urlQuery
|
] method extraHeaders urlPath urlQuery
|
||||||
multiPartBody parts =
|
multiPartBody parts =
|
||||||
BS8.concat $ separator : [BS8.concat [multipartPart p, separator] | p <- parts]
|
BS8.concat $ separator : [BS8.concat [multipartPart p, separator] | p <- parts]
|
||||||
multipartPart (ReqPlainPart k v) = BS8.concat
|
multipartPart (ReqPlainPart k v) = BS8.concat
|
||||||
@ -514,25 +545,25 @@ doRequestHeaders method url' extrahead paramsBuild = do
|
|||||||
, BS8.concat $ BSL8.toChunks bytes, "\r\n"]
|
, BS8.concat $ BSL8.toChunks bytes, "\r\n"]
|
||||||
|
|
||||||
-- For building the regular non-multipart requests
|
-- For building the regular non-multipart requests
|
||||||
makeSinglepart cookies parts urlPath urlQuery = SRequest (mkRequest
|
makeSinglepart cookies parts method extraHeaders urlPath urlQuery = SRequest (mkRequest
|
||||||
([ ("Cookie", Builder.toByteString $ Cookie.renderCookies
|
[ ("Cookie", Builder.toByteString $ Cookie.renderCookies
|
||||||
[(Cookie.setCookieName c, Cookie.setCookieValue c) | c <- map snd $ M.toList cookies])
|
[(Cookie.setCookieName c, Cookie.setCookieValue c) | c <- map snd $ M.toList cookies])
|
||||||
, ("Content-Type", "application/x-www-form-urlencoded")
|
, ("Content-Type", "application/x-www-form-urlencoded")
|
||||||
] ++ extrahead) urlPath urlQuery) $
|
] method extraHeaders urlPath urlQuery) $
|
||||||
BSL8.fromChunks $ return $ TE.encodeUtf8 $ T.intercalate "&" $ map singlepartPart parts
|
BSL8.fromChunks $ return $ TE.encodeUtf8 $ T.intercalate "&" $ map singlepartPart parts
|
||||||
|
|
||||||
singlepartPart (ReqFilePart _ _ _ _) = ""
|
singlepartPart (ReqFilePart _ _ _ _) = ""
|
||||||
singlepartPart (ReqPlainPart k v) = T.concat [k,"=",v]
|
singlepartPart (ReqPlainPart k v) = T.concat [k,"=",v]
|
||||||
|
|
||||||
-- General request making
|
-- General request making
|
||||||
mkRequest headers urlPath urlQuery = defaultRequest
|
mkRequest headers method extraHeaders urlPath urlQuery = defaultRequest
|
||||||
{ requestMethod = method
|
{ requestMethod = method
|
||||||
, remoteHost = Sock.SockAddrInet 1 2
|
, remoteHost = Sock.SockAddrInet 1 2
|
||||||
, requestHeaders = headers
|
, requestHeaders = headers ++ extraHeaders
|
||||||
, rawPathInfo = TE.encodeUtf8 urlPath
|
, rawPathInfo = TE.encodeUtf8 urlPath
|
||||||
, pathInfo = DL.filter (/="") $ T.split (== '/') urlPath
|
, pathInfo = DL.filter (/="") $ T.split (== '/') urlPath
|
||||||
, rawQueryString = TE.encodeUtf8 urlQuery
|
, rawQueryString = H.renderQuery False urlQuery
|
||||||
, queryString = H.parseQuery $ TE.encodeUtf8 urlQuery
|
, queryString = urlQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Yes, just a shortcut
|
-- Yes, just a shortcut
|
||||||
|
|||||||
@ -72,18 +72,18 @@ main = hspec $ do
|
|||||||
describe "basic usage" $ yesodSpec app $ do
|
describe "basic usage" $ yesodSpec app $ do
|
||||||
ydescribe "tests1" $ do
|
ydescribe "tests1" $ do
|
||||||
yit "tests1a" $ do
|
yit "tests1a" $ do
|
||||||
get_ ("/" :: Text)
|
get ("/" :: Text)
|
||||||
statusIs 200
|
statusIs 200
|
||||||
bodyEquals "Hello world!"
|
bodyEquals "Hello world!"
|
||||||
yit "tests1b" $ do
|
yit "tests1b" $ do
|
||||||
get_ ("/foo" :: Text)
|
get ("/foo" :: Text)
|
||||||
statusIs 404
|
statusIs 404
|
||||||
ydescribe "tests2" $ do
|
ydescribe "tests2" $ do
|
||||||
yit "type-safe URLs" $ do
|
yit "type-safe URLs" $ do
|
||||||
get_ $ LiteAppRoute []
|
get $ LiteAppRoute []
|
||||||
statusIs 200
|
statusIs 200
|
||||||
yit "type-safe URLs with query-string" $ do
|
yit "type-safe URLs with query-string" $ do
|
||||||
get_ (LiteAppRoute [], [("foo", "bar")])
|
get (LiteAppRoute [], [("foo", "bar")])
|
||||||
statusIs 200
|
statusIs 200
|
||||||
bodyEquals "foo=bar"
|
bodyEquals "foo=bar"
|
||||||
yit "tests2b" $ return ()
|
yit "tests2b" $ return ()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user