RequestBuilder overhaul

This commit is contained in:
Michael Snoyman 2013-04-02 19:41:52 +03:00
parent 9f97de6519
commit 51eb7d4ba2
2 changed files with 113 additions and 82 deletions

View File

@ -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

View File

@ -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 ()