Test.hs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969
  1. {-# LANGUAGE OverloadedStrings #-}
  2. {-# LANGUAGE CPP #-}
  3. {-# LANGUAGE FlexibleContexts #-}
  4. {-# LANGUAGE RecordWildCards #-}
  5. {-# LANGUAGE FlexibleInstances #-}
  6. {-# LANGUAGE TypeFamilies #-}
  7. {-|
  8. Yesod.Test is a pragmatic framework for testing web applications built
  9. using wai and persistent.
  10. By pragmatic I may also mean 'dirty'. Its main goal is to encourage integration
  11. and system testing of web applications by making everything /easy to test/.
  12. Your tests are like browser sessions that keep track of cookies and the last
  13. visited page. You can perform assertions on the content of HTML responses,
  14. using CSS selectors to explore the document more easily.
  15. You can also easily build requests using forms present in the current page.
  16. This is very useful for testing web applications built in yesod, for example,
  17. where your forms may have field names generated by the framework or a randomly
  18. generated CSRF token input.
  19. Your database is also directly available so you can use 'runDB' to set up
  20. backend pre-conditions, or to assert that your session is having the desired effect.
  21. -}
  22. module Yesod.Test
  23. ( -- * Declaring and running your test suite
  24. yesodSpec
  25. , YesodSpec
  26. , yesodSpecWithSiteGenerator
  27. , yesodSpecApp
  28. , YesodExample
  29. , YesodExampleData(..)
  30. , TestApp
  31. , YSpec
  32. , testApp
  33. , YesodSpecTree (..)
  34. , ydescribe
  35. , yit
  36. -- * Making requests
  37. -- | You can construct requests with the 'RequestBuilder' monad, which lets you
  38. -- set the URL and add parameters, headers, and files. Helper functions are provided to
  39. -- lookup fields by label and to add the current CSRF token from your forms.
  40. -- Once built, the request can be executed with the 'request' method.
  41. --
  42. -- Convenience functions like 'get' and 'post' build and execute common requests.
  43. , get
  44. , post
  45. , postBody
  46. , followRedirect
  47. , request
  48. , addRequestHeader
  49. , setMethod
  50. , addPostParam
  51. , addGetParam
  52. , addFile
  53. , setRequestBody
  54. , RequestBuilder
  55. , setUrl
  56. -- *** Adding fields by label
  57. -- | Yesod can auto generate field names, so you are never sure what
  58. -- the argument name should be for each one of your inputs when constructing
  59. -- your requests. What you do know is the /label/ of the field.
  60. -- These functions let you add parameters to your request based
  61. -- on currently displayed label names.
  62. , byLabel
  63. , fileByLabel
  64. -- *** CSRF Tokens
  65. -- | In order to prevent CSRF exploits, yesod-form adds a hidden input
  66. -- to your forms with the name "_token". This token is a randomly generated,
  67. -- per-session value.
  68. --
  69. -- In order to prevent your forms from being rejected in tests, use one of
  70. -- these functions to add the token to your request.
  71. , addToken
  72. , addToken_
  73. , addTokenFromCookie
  74. , addTokenFromCookieNamedToHeaderNamed
  75. -- * Assertions
  76. , assertEqual
  77. , assertHeader
  78. , assertNoHeader
  79. , statusIs
  80. , bodyEquals
  81. , bodyContains
  82. , htmlAllContain
  83. , htmlAnyContain
  84. , htmlNoneContain
  85. , htmlCount
  86. -- * Grab information
  87. , getTestYesod
  88. , getResponse
  89. , getRequestCookies
  90. -- * Debug output
  91. , printBody
  92. , printMatches
  93. -- * Utils for building your own assertions
  94. -- | Please consider generalizing and contributing the assertions you write.
  95. , htmlQuery
  96. , parseHTML
  97. , withResponse
  98. ) where
  99. import qualified Test.Hspec.Core.Spec as Hspec
  100. import qualified Data.List as DL
  101. import qualified Data.ByteString.Char8 as BS8
  102. import Data.ByteString (ByteString)
  103. import qualified Data.Text as T
  104. import qualified Data.Text.Encoding as TE
  105. import qualified Data.ByteString.Lazy.Char8 as BSL8
  106. import qualified Test.HUnit as HUnit
  107. import qualified Network.HTTP.Types as H
  108. import qualified Network.Socket.Internal as Sock
  109. import Data.CaseInsensitive (CI)
  110. import Network.Wai
  111. import Network.Wai.Test hiding (assertHeader, assertNoHeader, request)
  112. import qualified Control.Monad.Trans.State as ST
  113. import Control.Monad.IO.Class
  114. import System.IO
  115. import Yesod.Test.TransversingCSS
  116. import Yesod.Core
  117. import qualified Data.Text.Lazy as TL
  118. import Data.Text.Lazy.Encoding (encodeUtf8, decodeUtf8)
  119. import Text.XML.Cursor hiding (element)
  120. import qualified Text.XML.Cursor as C
  121. import qualified Text.HTML.DOM as HD
  122. import Control.Monad.Trans.Writer
  123. import qualified Data.Map as M
  124. import qualified Web.Cookie as Cookie
  125. import qualified Blaze.ByteString.Builder as Builder
  126. import Data.Time.Clock (getCurrentTime)
  127. import Control.Applicative ((<$>))
  128. -- | The state used in a single test case defined using 'yit'
  129. --
  130. -- Since 1.2.4
  131. data YesodExampleData site = YesodExampleData
  132. { yedApp :: !Application
  133. , yedSite :: !site
  134. , yedCookies :: !Cookies
  135. , yedResponse :: !(Maybe SResponse)
  136. }
  137. -- | A single test case, to be run with 'yit'.
  138. --
  139. -- Since 1.2.0
  140. type YesodExample site = ST.StateT (YesodExampleData site) IO
  141. -- | Mapping from cookie name to value.
  142. --
  143. -- Since 1.2.0
  144. type Cookies = M.Map ByteString Cookie.SetCookie
  145. -- | Corresponds to hspec\'s 'Spec'.
  146. --
  147. -- Since 1.2.0
  148. type YesodSpec site = Writer [YesodSpecTree site] ()
  149. -- | Internal data structure, corresponding to hspec\'s 'YesodSpecTree'.
  150. --
  151. -- Since 1.2.0
  152. data YesodSpecTree site
  153. = YesodSpecGroup String [YesodSpecTree site]
  154. | YesodSpecItem String (YesodExample site ())
  155. -- | Get the foundation value used for the current test.
  156. --
  157. -- Since 1.2.0
  158. getTestYesod :: YesodExample site site
  159. getTestYesod = fmap yedSite ST.get
  160. -- | Get the most recently provided response value, if available.
  161. --
  162. -- Since 1.2.0
  163. getResponse :: YesodExample site (Maybe SResponse)
  164. getResponse = fmap yedResponse ST.get
  165. data RequestBuilderData site = RequestBuilderData
  166. { rbdPostData :: RBDPostData
  167. , rbdResponse :: (Maybe SResponse)
  168. , rbdMethod :: H.Method
  169. , rbdSite :: site
  170. , rbdPath :: [T.Text]
  171. , rbdGets :: H.Query
  172. , rbdHeaders :: H.RequestHeaders
  173. }
  174. data RBDPostData = MultipleItemsPostData [RequestPart]
  175. | BinaryPostData BSL8.ByteString
  176. -- | Request parts let us discern regular key/values from files sent in the request.
  177. data RequestPart
  178. = ReqKvPart T.Text T.Text
  179. | ReqFilePart T.Text FilePath BSL8.ByteString T.Text
  180. -- | The 'RequestBuilder' state monad constructs a URL encoded string of arguments
  181. -- to send with your requests. Some of the functions that run on it use the current
  182. -- response to analyze the forms that the server is expecting to receive.
  183. type RequestBuilder site = ST.StateT (RequestBuilderData site) IO
  184. -- | Start describing a Tests suite keeping cookies and a reference to the tested 'Application'
  185. -- and 'ConnectionPool'
  186. ydescribe :: String -> YesodSpec site -> YesodSpec site
  187. ydescribe label yspecs = tell [YesodSpecGroup label $ execWriter yspecs]
  188. yesodSpec :: YesodDispatch site
  189. => site
  190. -> YesodSpec site
  191. -> Hspec.Spec
  192. yesodSpec site yspecs =
  193. Hspec.fromSpecList $ map unYesod $ execWriter yspecs
  194. where
  195. unYesod (YesodSpecGroup x y) = Hspec.specGroup x $ map unYesod y
  196. unYesod (YesodSpecItem x y) = Hspec.specItem x $ do
  197. app <- toWaiAppPlain site
  198. ST.evalStateT y YesodExampleData
  199. { yedApp = app
  200. , yedSite = site
  201. , yedCookies = M.empty
  202. , yedResponse = Nothing
  203. }
  204. -- | Same as yesodSpec, but instead of taking already built site it
  205. -- takes an action which produces site for each test.
  206. yesodSpecWithSiteGenerator :: YesodDispatch site
  207. => IO site
  208. -> YesodSpec site
  209. -> Hspec.Spec
  210. yesodSpecWithSiteGenerator getSiteAction yspecs =
  211. Hspec.fromSpecList $ map (unYesod getSiteAction) $ execWriter yspecs
  212. where
  213. unYesod getSiteAction' (YesodSpecGroup x y) = Hspec.specGroup x $ map (unYesod getSiteAction') y
  214. unYesod getSiteAction' (YesodSpecItem x y) = Hspec.specItem x $ do
  215. site <- getSiteAction'
  216. app <- toWaiAppPlain site
  217. ST.evalStateT y YesodExampleData
  218. { yedApp = app
  219. , yedSite = site
  220. , yedCookies = M.empty
  221. , yedResponse = Nothing
  222. }
  223. -- | Same as yesodSpec, but instead of taking a site it
  224. -- takes an action which produces the 'Application' for each test.
  225. -- This lets you use your middleware from makeApplication
  226. yesodSpecApp :: YesodDispatch site
  227. => site
  228. -> IO Application
  229. -> YesodSpec site
  230. -> Hspec.Spec
  231. yesodSpecApp site getApp yspecs =
  232. Hspec.fromSpecList $ map unYesod $ execWriter yspecs
  233. where
  234. unYesod (YesodSpecGroup x y) = Hspec.specGroup x $ map unYesod y
  235. unYesod (YesodSpecItem x y) = Hspec.specItem x $ do
  236. app <- getApp
  237. ST.evalStateT y YesodExampleData
  238. { yedApp = app
  239. , yedSite = site
  240. , yedCookies = M.empty
  241. , yedResponse = Nothing
  242. }
  243. -- | Describe a single test that keeps cookies, and a reference to the last response.
  244. yit :: String -> YesodExample site () -> YesodSpec site
  245. yit label example = tell [YesodSpecItem label example]
  246. -- Performs a given action using the last response. Use this to create
  247. -- response-level assertions
  248. withResponse' :: MonadIO m
  249. => (state -> Maybe SResponse)
  250. -> [T.Text]
  251. -> (SResponse -> ST.StateT state m a)
  252. -> ST.StateT state m a
  253. withResponse' getter errTrace f = maybe err f . getter =<< ST.get
  254. where err = failure msg
  255. msg = if null errTrace
  256. then "There was no response, you should make a request."
  257. else
  258. "There was no response, you should make a request. A response was needed because: \n - "
  259. <> T.intercalate "\n - " errTrace
  260. -- | Performs a given action using the last response. Use this to create
  261. -- response-level assertions
  262. withResponse :: (SResponse -> YesodExample site a) -> YesodExample site a
  263. withResponse = withResponse' yedResponse []
  264. -- | Use HXT to parse a value from an HTML tag.
  265. -- Check for usage examples in this module's source.
  266. parseHTML :: HtmlLBS -> Cursor
  267. parseHTML html = fromDocument $ HD.parseLBS html
  268. -- | Query the last response using CSS selectors, returns a list of matched fragments
  269. htmlQuery' :: MonadIO m
  270. => (state -> Maybe SResponse)
  271. -> [T.Text]
  272. -> Query
  273. -> ST.StateT state m [HtmlLBS]
  274. htmlQuery' getter errTrace query = withResponse' getter ("Tried to invoke htmlQuery' in order to read HTML of a previous response." : errTrace) $ \ res ->
  275. case findBySelector (simpleBody res) query of
  276. Left err -> failure $ query <> " did not parse: " <> T.pack (show err)
  277. Right matches -> return $ map (encodeUtf8 . TL.pack) matches
  278. -- | Query the last response using CSS selectors, returns a list of matched fragments
  279. htmlQuery :: Query -> YesodExample site [HtmlLBS]
  280. htmlQuery = htmlQuery' yedResponse []
  281. -- | Asserts that the two given values are equal.
  282. assertEqual :: (Eq a) => String -> a -> a -> YesodExample site ()
  283. assertEqual msg a b = liftIO $ HUnit.assertBool msg (a == b)
  284. -- | Assert the last response status is as expected.
  285. statusIs :: Int -> YesodExample site ()
  286. statusIs number = withResponse $ \ SResponse { simpleStatus = s } ->
  287. liftIO $ flip HUnit.assertBool (H.statusCode s == number) $ concat
  288. [ "Expected status was ", show number
  289. , " but received status was ", show $ H.statusCode s
  290. ]
  291. -- | Assert the given header key/value pair was returned.
  292. assertHeader :: CI BS8.ByteString -> BS8.ByteString -> YesodExample site ()
  293. assertHeader header value = withResponse $ \ SResponse { simpleHeaders = h } ->
  294. case lookup header h of
  295. Nothing -> failure $ T.pack $ concat
  296. [ "Expected header "
  297. , show header
  298. , " to be "
  299. , show value
  300. , ", but it was not present"
  301. ]
  302. Just value' -> liftIO $ flip HUnit.assertBool (value == value') $ concat
  303. [ "Expected header "
  304. , show header
  305. , " to be "
  306. , show value
  307. , ", but received "
  308. , show value'
  309. ]
  310. -- | Assert the given header was not included in the response.
  311. assertNoHeader :: CI BS8.ByteString -> YesodExample site ()
  312. assertNoHeader header = withResponse $ \ SResponse { simpleHeaders = h } ->
  313. case lookup header h of
  314. Nothing -> return ()
  315. Just s -> failure $ T.pack $ concat
  316. [ "Unexpected header "
  317. , show header
  318. , " containing "
  319. , show s
  320. ]
  321. -- | Assert the last response is exactly equal to the given text. This is
  322. -- useful for testing API responses.
  323. bodyEquals :: String -> YesodExample site ()
  324. bodyEquals text = withResponse $ \ res ->
  325. liftIO $ HUnit.assertBool ("Expected body to equal " ++ text) $
  326. (simpleBody res) == encodeUtf8 (TL.pack text)
  327. -- | Assert the last response has the given text. The check is performed using the response
  328. -- body in full text form.
  329. bodyContains :: String -> YesodExample site ()
  330. bodyContains text = withResponse $ \ res ->
  331. liftIO $ HUnit.assertBool ("Expected body to contain " ++ text) $
  332. (simpleBody res) `contains` text
  333. contains :: BSL8.ByteString -> String -> Bool
  334. contains a b = DL.isInfixOf b (TL.unpack $ decodeUtf8 a)
  335. -- | Queries the HTML using a CSS selector, and all matched elements must contain
  336. -- the given string.
  337. htmlAllContain :: Query -> String -> YesodExample site ()
  338. htmlAllContain query search = do
  339. matches <- htmlQuery query
  340. case matches of
  341. [] -> failure $ "Nothing matched css query: " <> query
  342. _ -> liftIO $ HUnit.assertBool ("Not all "++T.unpack query++" contain "++search) $
  343. DL.all (DL.isInfixOf search) (map (TL.unpack . decodeUtf8) matches)
  344. -- | Queries the HTML using a CSS selector, and passes if any matched
  345. -- element contains the given string.
  346. --
  347. -- Since 0.3.5
  348. htmlAnyContain :: Query -> String -> YesodExample site ()
  349. htmlAnyContain query search = do
  350. matches <- htmlQuery query
  351. case matches of
  352. [] -> failure $ "Nothing matched css query: " <> query
  353. _ -> liftIO $ HUnit.assertBool ("None of "++T.unpack query++" contain "++search) $
  354. DL.any (DL.isInfixOf search) (map (TL.unpack . decodeUtf8) matches)
  355. -- | Queries the HTML using a CSS selector, and fails if any matched
  356. -- element contains the given string (in other words, it is the logical
  357. -- inverse of htmlAnyContains).
  358. --
  359. -- Since 1.2.2
  360. htmlNoneContain :: Query -> String -> YesodExample site ()
  361. htmlNoneContain query search = do
  362. matches <- htmlQuery query
  363. case DL.filter (DL.isInfixOf search) (map (TL.unpack . decodeUtf8) matches) of
  364. [] -> return ()
  365. found -> failure $ "Found " <> T.pack (show $ length found) <>
  366. " instances of " <> T.pack search <> " in " <> query <> " elements"
  367. -- | Performs a CSS query on the last response and asserts the matched elements
  368. -- are as many as expected.
  369. htmlCount :: Query -> Int -> YesodExample site ()
  370. htmlCount query count = do
  371. matches <- fmap DL.length $ htmlQuery query
  372. liftIO $ flip HUnit.assertBool (matches == count)
  373. ("Expected "++(show count)++" elements to match "++T.unpack query++", found "++(show matches))
  374. -- | Outputs the last response body to stderr (So it doesn't get captured by HSpec)
  375. printBody :: YesodExample site ()
  376. printBody = withResponse $ \ SResponse { simpleBody = b } ->
  377. liftIO $ BSL8.hPutStrLn stderr b
  378. -- | Performs a CSS query and print the matches to stderr.
  379. printMatches :: Query -> YesodExample site ()
  380. printMatches query = do
  381. matches <- htmlQuery query
  382. liftIO $ hPutStrLn stderr $ show matches
  383. -- | Add a parameter with the given name and value to the request body.
  384. addPostParam :: T.Text -> T.Text -> RequestBuilder site ()
  385. addPostParam name value =
  386. ST.modify $ \rbd -> rbd { rbdPostData = (addPostData (rbdPostData rbd)) }
  387. where addPostData (BinaryPostData _) = error "Trying to add post param to binary content."
  388. addPostData (MultipleItemsPostData posts) =
  389. MultipleItemsPostData $ ReqKvPart name value : posts
  390. -- | Add a parameter with the given name and value to the query string.
  391. addGetParam :: T.Text -> T.Text -> RequestBuilder site ()
  392. addGetParam name value = ST.modify $ \rbd -> rbd
  393. { rbdGets = (TE.encodeUtf8 name, Just $ TE.encodeUtf8 value)
  394. : rbdGets rbd
  395. }
  396. -- | Add a file to be posted with the current request.
  397. --
  398. -- Adding a file will automatically change your request content-type to be multipart/form-data.
  399. --
  400. -- ==== __Examples__
  401. --
  402. -- > request $ do
  403. -- > addFile "profile_picture" "static/img/picture.png" "img/png"
  404. addFile :: T.Text -- ^ The parameter name for the file.
  405. -> FilePath -- ^ The path to the file.
  406. -> T.Text -- ^ The MIME type of the file, e.g. "image/png".
  407. -> RequestBuilder site ()
  408. addFile name path mimetype = do
  409. contents <- liftIO $ BSL8.readFile path
  410. ST.modify $ \rbd -> rbd { rbdPostData = (addPostData (rbdPostData rbd) contents) }
  411. where addPostData (BinaryPostData _) _ = error "Trying to add file after setting binary content."
  412. addPostData (MultipleItemsPostData posts) contents =
  413. MultipleItemsPostData $ ReqFilePart name path contents mimetype : posts
  414. -- This looks up the name of a field based on the contents of the label pointing to it.
  415. nameFromLabel :: T.Text -> RequestBuilder site T.Text
  416. nameFromLabel label = do
  417. mres <- fmap rbdResponse ST.get
  418. res <-
  419. case mres of
  420. Nothing -> failure "nameFromLabel: No response available"
  421. Just res -> return res
  422. let
  423. body = simpleBody res
  424. mlabel = parseHTML body
  425. $// C.element "label"
  426. >=> contentContains label
  427. mfor = mlabel >>= attribute "for"
  428. contentContains x c
  429. | x `T.isInfixOf` T.concat (c $// content) = [c]
  430. | otherwise = []
  431. case mfor of
  432. for:[] -> do
  433. let mname = parseHTML body
  434. $// attributeIs "id" for
  435. >=> attribute "name"
  436. case mname of
  437. "":_ -> failure $ T.concat
  438. [ "Label "
  439. , label
  440. , " resolved to id "
  441. , for
  442. , " which was not found. "
  443. ]
  444. name:_ -> return name
  445. [] -> failure $ "No input with id " <> for
  446. [] ->
  447. case filter (/= "") $ mlabel >>= (child >=> C.element "input" >=> attribute "name") of
  448. [] -> failure $ "No label contained: " <> label
  449. name:_ -> return name
  450. _ -> failure $ "More than one label contained " <> label
  451. (<>) :: T.Text -> T.Text -> T.Text
  452. (<>) = T.append
  453. -- How does this work for the alternate <label><input></label> syntax?
  454. -- | Finds the @\<label>@ with the given value, finds its corresponding @\<input>@, then adds a parameter
  455. -- for that input to the request body.
  456. --
  457. -- ==== __Examples__
  458. --
  459. -- Given this HTML, we want to submit @f1=Michael@ to the server:
  460. --
  461. -- > <form method="POST">
  462. -- > <label for="user">Username</label>
  463. -- > <input id="user" name="f1" />
  464. -- > </form>
  465. --
  466. -- You can set this parameter like so:
  467. --
  468. -- > request $ do
  469. -- > byLabel "Username" "Michael"
  470. --
  471. -- This function also supports the implicit label syntax, in which
  472. -- the @\<input>@ is nested inside the @\<label>@ rather than specified with @for@:
  473. --
  474. -- > <form method="POST">
  475. -- > <label>Username <input name="f1"> </label>
  476. -- > </form>
  477. byLabel :: T.Text -- ^ The text contained in the @\<label>@.
  478. -> T.Text -- ^ The value to set the parameter to.
  479. -> RequestBuilder site ()
  480. byLabel label value = do
  481. name <- nameFromLabel label
  482. addPostParam name value
  483. -- | Finds the @\<label>@ with the given value, finds its corresponding @\<input>@, then adds a file for that input to the request body.
  484. --
  485. -- ==== __Examples__
  486. --
  487. -- Given this HTML, we want to submit a file with the parameter name @f1@ to the server:
  488. --
  489. -- > <form method="POST">
  490. -- > <label for="imageInput">Please submit an image</label>
  491. -- > <input id="imageInput" type="file" name="f1" accept="image/*">
  492. -- > </form>
  493. --
  494. -- You can set this parameter like so:
  495. --
  496. -- > request $ do
  497. -- > fileByLabel "Please submit an image" "static/img/picture.png" "img/png"
  498. --
  499. -- This function also supports the implicit label syntax, in which
  500. -- the @\<input>@ is nested inside the @\<label>@ rather than specified with @for@:
  501. --
  502. -- > <form method="POST">
  503. -- > <label>Please submit an image <input type="file" name="f1"> </label>
  504. -- > </form>
  505. fileByLabel :: T.Text -- ^ The text contained in the @\<label>@.
  506. -> FilePath -- ^ The path to the file.
  507. -> T.Text -- ^ The MIME type of the file, e.g. "image/png".
  508. -> RequestBuilder site ()
  509. fileByLabel label path mime = do
  510. name <- nameFromLabel label
  511. addFile name path mime
  512. -- | Lookups the hidden input named "_token" and adds its value to the params.
  513. -- Receives a CSS selector that should resolve to the form element containing the token.
  514. --
  515. -- ==== __Examples__
  516. --
  517. -- > request $ do
  518. -- > addToken_ "#formID"
  519. addToken_ :: Query -> RequestBuilder site ()
  520. addToken_ scope = do
  521. matches <- htmlQuery' rbdResponse ["Tried to get CSRF token with addToken'"] $ scope <> " input[name=_token][type=hidden][value]"
  522. case matches of
  523. [] -> failure $ "No CSRF token found in the current page"
  524. element:[] -> addPostParam "_token" $ head $ attribute "value" $ parseHTML element
  525. _ -> failure $ "More than one CSRF token found in the page"
  526. -- | For responses that display a single form, just lookup the only CSRF token available.
  527. --
  528. -- ==== __Examples__
  529. --
  530. -- > request $ do
  531. -- > addToken
  532. addToken :: RequestBuilder site ()
  533. addToken = addToken_ ""
  534. -- | Calls 'addTokenFromCookieNamedToHeaderNamed' with the 'defaultCsrfCookieName' and 'defaultCsrfHeaderName'.
  535. --
  536. -- Use this function if you're using the CSRF middleware from "Yesod.Core" and haven't customized the cookie or header name.
  537. --
  538. -- ==== __Examples__
  539. --
  540. -- > request $ do
  541. -- > addTokenFromCookie
  542. --
  543. -- Since 1.4.3.2
  544. addTokenFromCookie :: RequestBuilder site ()
  545. addTokenFromCookie = addTokenFromCookieNamedToHeaderNamed defaultCsrfCookieName defaultCsrfHeaderName
  546. -- | Looks up the CSRF token stored in the cookie with the given name and adds it to the request headers. An error is thrown if the cookie can't be found.
  547. --
  548. -- Use this function if you're using the CSRF middleware from "Yesod.Core" and have customized the cookie or header name.
  549. --
  550. -- See "Yesod.Core.Handler" for details on this approach to CSRF protection.
  551. --
  552. -- ==== __Examples__
  553. --
  554. -- > import Data.CaseInsensitive (CI)
  555. -- > request $ do
  556. -- > addTokenFromCookieNamedToHeaderNamed "cookieName" (CI "headerName")
  557. --
  558. -- Since 1.4.3.2
  559. addTokenFromCookieNamedToHeaderNamed :: ByteString -- ^ The name of the cookie
  560. -> CI ByteString -- ^ The name of the header
  561. -> RequestBuilder site ()
  562. addTokenFromCookieNamedToHeaderNamed cookieName headerName = do
  563. cookies <- getRequestCookies
  564. case M.lookup cookieName cookies of
  565. Just csrfCookie -> addRequestHeader (headerName, Cookie.setCookieValue csrfCookie)
  566. Nothing -> failure $ T.concat
  567. [ "addTokenFromCookieNamedToHeaderNamed failed to lookup CSRF cookie with name: "
  568. , T.pack $ show cookieName
  569. , ". Cookies were: "
  570. , T.pack $ show cookies
  571. ]
  572. -- | Returns the 'Cookies' from the most recent request. If a request hasn't been made, an error is raised.
  573. --
  574. -- ==== __Examples__
  575. --
  576. -- > request $ do
  577. -- > cookies <- getRequestCookies
  578. -- > liftIO $ putStrLn $ "Cookies are: " ++ show cookies
  579. --
  580. -- Since 1.4.3.2
  581. getRequestCookies :: RequestBuilder site Cookies
  582. getRequestCookies = do
  583. requestBuilderData <- ST.get
  584. headers <- case simpleHeaders <$> rbdResponse requestBuilderData of
  585. Just h -> return h
  586. Nothing -> failure "getRequestCookies: No request has been made yet; the cookies can't be looked up."
  587. return $ M.fromList $ map (\c -> (Cookie.setCookieName c, c)) (parseSetCookies headers)
  588. -- | Perform a POST request to @url@.
  589. --
  590. -- ==== __Examples__
  591. --
  592. -- > post HomeR
  593. post :: (Yesod site, RedirectUrl site url)
  594. => url
  595. -> YesodExample site ()
  596. post url = request $ do
  597. setMethod "POST"
  598. setUrl url
  599. -- | Perform a POST request to @url@ with the given body.
  600. --
  601. -- ==== __Examples__
  602. --
  603. -- > postBody HomeR "foobar"
  604. --
  605. -- > import Data.Aeson
  606. -- > postBody HomeR (encode $ object ["age" .= (1 :: Integer)])
  607. postBody :: (Yesod site, RedirectUrl site url)
  608. => url
  609. -> BSL8.ByteString
  610. -> YesodExample site ()
  611. postBody url body = request $ do
  612. setMethod "POST"
  613. setUrl url
  614. setRequestBody body
  615. -- | Perform a GET request to @url@.
  616. --
  617. -- ==== __Examples__
  618. --
  619. -- > get HomeR
  620. --
  621. -- > get ("http://google.com" :: Text)
  622. get :: (Yesod site, RedirectUrl site url)
  623. => url
  624. -> YesodExample site ()
  625. get url = request $ do
  626. setMethod "GET"
  627. setUrl url
  628. -- | Follow a redirect, if the last response was a redirect.
  629. -- (We consider a request a redirect if the status is
  630. -- 301, 302, 303, 307 or 308, and the Location header is set.)
  631. --
  632. -- ==== __Examples__
  633. --
  634. -- > get HomeR
  635. -- > followRedirect
  636. followRedirect :: Yesod site
  637. => YesodExample site (Either T.Text T.Text) -- ^ 'Left' with an error message if not a redirect, 'Right' with the redirected URL if it was
  638. followRedirect = do
  639. mr <- getResponse
  640. case mr of
  641. Nothing -> return $ Left "followRedirect called, but there was no previous response, so no redirect to follow"
  642. Just r -> do
  643. if not ((H.statusCode $ simpleStatus r) `elem` [301, 302, 303, 307, 308])
  644. then return $ Left "followRedirect called, but previous request was not a redirect"
  645. else do
  646. case lookup "Location" (simpleHeaders r) of
  647. Nothing -> return $ Left "followRedirect called, but no location header set"
  648. Just h -> let url = TE.decodeUtf8 h in
  649. get url >> return (Right url)
  650. -- | Sets the HTTP method used by the request.
  651. --
  652. -- ==== __Examples__
  653. --
  654. -- > request $ do
  655. -- > setMethod "POST"
  656. --
  657. -- > import Network.HTTP.Types.Method
  658. -- > request $ do
  659. -- > setMethod methodPut
  660. setMethod :: H.Method -> RequestBuilder site ()
  661. setMethod m = ST.modify $ \rbd -> rbd { rbdMethod = m }
  662. -- | Sets the URL used by the request.
  663. --
  664. -- ==== __Examples__
  665. --
  666. -- > request $ do
  667. -- > setUrl HomeR
  668. --
  669. -- > request $ do
  670. -- > setUrl ("http://google.com/" :: Text)
  671. setUrl :: (Yesod site, RedirectUrl site url)
  672. => url
  673. -> RequestBuilder site ()
  674. setUrl url' = do
  675. site <- fmap rbdSite ST.get
  676. eurl <- runFakeHandler
  677. M.empty
  678. (const $ error "Yesod.Test: No logger available")
  679. site
  680. (toTextUrl url')
  681. url <- either (error . show) return eurl
  682. let (urlPath, urlQuery) = T.break (== '?') url
  683. ST.modify $ \rbd -> rbd
  684. { rbdPath =
  685. case DL.filter (/="") $ H.decodePathSegments $ TE.encodeUtf8 urlPath of
  686. ("http:":_:rest) -> rest
  687. ("https:":_:rest) -> rest
  688. x -> x
  689. , rbdGets = rbdGets rbd ++ H.parseQuery (TE.encodeUtf8 urlQuery)
  690. }
  691. -- | Simple way to set HTTP request body
  692. --
  693. -- ==== __ Examples__
  694. --
  695. -- > request $ do
  696. -- > setRequestBody "foobar"
  697. --
  698. -- > import Data.Aeson
  699. -- > request $ do
  700. -- > setRequestBody $ encode $ object ["age" .= (1 :: Integer)]
  701. setRequestBody :: (Yesod site)
  702. => BSL8.ByteString
  703. -> RequestBuilder site ()
  704. setRequestBody body = ST.modify $ \rbd -> rbd { rbdPostData = BinaryPostData body }
  705. -- | Adds the given header to the request; see "Network.HTTP.Types.Header" for creating 'Header's.
  706. --
  707. -- ==== __Examples__
  708. --
  709. -- > import Network.HTTP.Types.Header
  710. -- > request $ do
  711. -- > addRequestHeader (hUserAgent, "Chrome/41.0.2228.0")
  712. addRequestHeader :: H.Header -> RequestBuilder site ()
  713. addRequestHeader header = ST.modify $ \rbd -> rbd
  714. { rbdHeaders = header : rbdHeaders rbd
  715. }
  716. -- | The general interface for performing requests. 'request' takes a 'RequestBuilder',
  717. -- constructs a request, and executes it.
  718. --
  719. -- The 'RequestBuilder' allows you to build up attributes of the request, like the
  720. -- headers, parameters, and URL of the request.
  721. --
  722. -- ==== __Examples__
  723. --
  724. -- > request $ do
  725. -- > addToken
  726. -- > byLabel "First Name" "Felipe"
  727. -- > setMethod "PUT"
  728. -- > setUrl NameR
  729. request :: Yesod site
  730. => RequestBuilder site ()
  731. -> YesodExample site ()
  732. request reqBuilder = do
  733. YesodExampleData app site oldCookies mRes <- ST.get
  734. RequestBuilderData {..} <- liftIO $ ST.execStateT reqBuilder RequestBuilderData
  735. { rbdPostData = MultipleItemsPostData []
  736. , rbdResponse = mRes
  737. , rbdMethod = "GET"
  738. , rbdSite = site
  739. , rbdPath = []
  740. , rbdGets = []
  741. , rbdHeaders = []
  742. }
  743. let path
  744. | null rbdPath = "/"
  745. | otherwise = TE.decodeUtf8 $ Builder.toByteString $ H.encodePathSegments rbdPath
  746. -- expire cookies and filter them for the current path. TODO: support max age
  747. currentUtc <- liftIO getCurrentTime
  748. let cookies = M.filter (checkCookieTime currentUtc) oldCookies
  749. cookiesForPath = M.filter (checkCookiePath path) cookies
  750. let req = case rbdPostData of
  751. MultipleItemsPostData x ->
  752. if DL.any isFile x
  753. then (multipart x)
  754. else singlepart
  755. BinaryPostData _ -> singlepart
  756. where singlepart = makeSinglepart cookiesForPath rbdPostData rbdMethod rbdHeaders path rbdGets
  757. multipart x = makeMultipart cookiesForPath x rbdMethod rbdHeaders path rbdGets
  758. -- let maker = case rbdPostData of
  759. -- MultipleItemsPostData x ->
  760. -- if DL.any isFile x
  761. -- then makeMultipart
  762. -- else makeSinglepart
  763. -- BinaryPostData _ -> makeSinglepart
  764. -- let req = maker cookiesForPath rbdPostData rbdMethod rbdHeaders path rbdGets
  765. response <- liftIO $ runSession (srequest req
  766. { simpleRequest = (simpleRequest req)
  767. { httpVersion = H.http11
  768. }
  769. }) app
  770. let newCookies = parseSetCookies $ simpleHeaders response
  771. cookies' = M.fromList [(Cookie.setCookieName c, c) | c <- newCookies] `M.union` cookies
  772. ST.put $ YesodExampleData app site cookies' (Just response)
  773. where
  774. isFile (ReqFilePart _ _ _ _) = True
  775. isFile _ = False
  776. checkCookieTime t c = case Cookie.setCookieExpires c of
  777. Nothing -> True
  778. Just t' -> t < t'
  779. checkCookiePath url c =
  780. case Cookie.setCookiePath c of
  781. Nothing -> True
  782. Just x -> x `BS8.isPrefixOf` TE.encodeUtf8 url
  783. -- For building the multi-part requests
  784. boundary :: String
  785. boundary = "*******noneedtomakethisrandom"
  786. separator = BS8.concat ["--", BS8.pack boundary, "\r\n"]
  787. makeMultipart :: M.Map a0 Cookie.SetCookie
  788. -> [RequestPart]
  789. -> H.Method
  790. -> [H.Header]
  791. -> T.Text
  792. -> H.Query
  793. -> SRequest
  794. makeMultipart cookies parts method extraHeaders urlPath urlQuery =
  795. SRequest simpleRequest' (simpleRequestBody' parts)
  796. where simpleRequestBody' x =
  797. BSL8.fromChunks [multiPartBody x]
  798. simpleRequest' = mkRequest
  799. [ ("Cookie", cookieValue)
  800. , ("Content-Type", contentTypeValue)]
  801. method extraHeaders urlPath urlQuery
  802. cookieValue = Builder.toByteString $ Cookie.renderCookies cookiePairs
  803. cookiePairs = [ (Cookie.setCookieName c, Cookie.setCookieValue c)
  804. | c <- map snd $ M.toList cookies ]
  805. contentTypeValue = BS8.pack $ "multipart/form-data; boundary=" ++ boundary
  806. multiPartBody parts =
  807. BS8.concat $ separator : [BS8.concat [multipartPart p, separator] | p <- parts]
  808. multipartPart (ReqKvPart k v) = BS8.concat
  809. [ "Content-Disposition: form-data; "
  810. , "name=\"", TE.encodeUtf8 k, "\"\r\n\r\n"
  811. , TE.encodeUtf8 v, "\r\n"]
  812. multipartPart (ReqFilePart k v bytes mime) = BS8.concat
  813. [ "Content-Disposition: form-data; "
  814. , "name=\"", TE.encodeUtf8 k, "\"; "
  815. , "filename=\"", BS8.pack v, "\"\r\n"
  816. , "Content-Type: ", TE.encodeUtf8 mime, "\r\n\r\n"
  817. , BS8.concat $ BSL8.toChunks bytes, "\r\n"]
  818. -- For building the regular non-multipart requests
  819. makeSinglepart :: M.Map a0 Cookie.SetCookie
  820. -> RBDPostData
  821. -> H.Method
  822. -> [H.Header]
  823. -> T.Text
  824. -> H.Query
  825. -> SRequest
  826. makeSinglepart cookies rbdPostData method extraHeaders urlPath urlQuery =
  827. SRequest simpleRequest' (simpleRequestBody' rbdPostData)
  828. where
  829. simpleRequest' = (mkRequest
  830. ([ ("Cookie", cookieValue) ] ++ headersForPostData rbdPostData)
  831. method extraHeaders urlPath urlQuery)
  832. simpleRequestBody' (MultipleItemsPostData x) =
  833. BSL8.fromChunks $ return $ TE.encodeUtf8 $ T.intercalate "&"
  834. $ map singlepartPart x
  835. simpleRequestBody' (BinaryPostData x) = x
  836. cookieValue = Builder.toByteString $ Cookie.renderCookies cookiePairs
  837. cookiePairs = [ (Cookie.setCookieName c, Cookie.setCookieValue c)
  838. | c <- map snd $ M.toList cookies ]
  839. singlepartPart (ReqFilePart _ _ _ _) = ""
  840. singlepartPart (ReqKvPart k v) = T.concat [k,"=",v]
  841. -- If the request appears to be submitting a form (has key-value pairs) give it the form-urlencoded Content-Type.
  842. -- The previous behavior was to always use the form-urlencoded Content-Type https://github.com/yesodweb/yesod/issues/1063
  843. headersForPostData (MultipleItemsPostData []) = []
  844. headersForPostData (MultipleItemsPostData _ ) = [("Content-Type", "application/x-www-form-urlencoded")]
  845. headersForPostData (BinaryPostData _ ) = []
  846. -- General request making
  847. mkRequest headers method extraHeaders urlPath urlQuery = defaultRequest
  848. { requestMethod = method
  849. , remoteHost = Sock.SockAddrInet 1 2
  850. , requestHeaders = headers ++ extraHeaders
  851. , rawPathInfo = TE.encodeUtf8 urlPath
  852. , pathInfo = H.decodePathSegments $ TE.encodeUtf8 urlPath
  853. , rawQueryString = H.renderQuery False urlQuery
  854. , queryString = urlQuery
  855. }
  856. parseSetCookies :: [H.Header] -> [Cookie.SetCookie]
  857. parseSetCookies headers = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ headers
  858. -- Yes, just a shortcut
  859. failure :: (MonadIO a) => T.Text -> a b
  860. failure reason = (liftIO $ HUnit.assertFailure $ T.unpack reason) >> error ""
  861. type TestApp site = (site, Middleware)
  862. testApp :: site -> Middleware -> TestApp site
  863. testApp site middleware = (site, middleware)
  864. type YSpec site = Hspec.SpecWith (TestApp site)
  865. instance YesodDispatch site => Hspec.Example (ST.StateT (YesodExampleData site) IO a) where
  866. type Arg (ST.StateT (YesodExampleData site) IO a) = TestApp site
  867. evaluateExample example params action =
  868. Hspec.evaluateExample
  869. (action $ \(site, middleware) -> do
  870. app <- toWaiAppPlain site
  871. _ <- ST.evalStateT example YesodExampleData
  872. { yedApp = middleware app
  873. , yedSite = site
  874. , yedCookies = M.empty
  875. , yedResponse = Nothing
  876. }
  877. return ())
  878. params
  879. ($ ())