@@ -1,28 +1,36 @@
1module Servant.QuickCheck.Internal.Predicates where
1module Servant.QuickCheck.Internal.Predicates where
3−import Control.Monad (liftM2)
3+import Control.Exception (catch, throw)
4+import Control.Monad (liftM2, unless, when)
4import Data.Aeson (Object, decode)
5import Data.Aeson (Object, decode)
5−import Data.Bifunctor (Bifunctor (..))
6+import Data.Bifunctor (first)
7import qualified Data.ByteString as SBS
7import qualified Data.ByteString as SBS
8import qualified Data.ByteString.Char8 as SBSC
8import qualified Data.ByteString.Char8 as SBSC
9import qualified Data.ByteString.Lazy as LBS
9import qualified Data.ByteString.Lazy as LBS
10−import Data.CaseInsensitive (mk)
10+import Data.CaseInsensitive (foldCase, foldedCase, mk)
11import Data.Either (isRight)
11import Data.Either (isRight)
12import Data.List.Split (wordsBy)
12import Data.List.Split (wordsBy)
13import Data.Maybe (fromMaybe, isJust)
13import Data.Maybe (fromMaybe, isJust)
14−import Data.Monoid ((<>))
14+import Data.Semigroup (Semigroup (..))
15−import Data.Text (Text)
15+import qualified Data.Text as T
16+import Data.Time (UTCTime, defaultTimeLocale, parseTimeM,
16import GHC.Generics (Generic)
18import GHC.Generics (Generic)
17import Network.HTTP.Client (Manager, Request, Response, httpLbs,
19import Network.HTTP.Client (Manager, Request, Response, httpLbs,
18− method, parseUrl, requestHeaders,
20+ method, parseRequest, requestHeaders,
19 responseBody, responseHeaders,
21 responseBody, responseHeaders,
20 responseStatus)
22 responseStatus)
21import Network.HTTP.Media (matchAccept)
23import Network.HTTP.Media (matchAccept)
22import Network.HTTP.Types (methodGet, methodHead, parseMethod,
24import Network.HTTP.Types (methodGet, methodHead, parseMethod,
23− renderStdMethod, status200, status201,
25+ renderStdMethod, status100, status200,
24− status300, status401, status405,
26+ status201, status300, status401,
25− status500, status100)
27+ status405, status500)
29+import System.Clock (Clock (Monotonic), diffTimeSpec,
32+import Servant.QuickCheck.Internal.ErrorTypes
27-- | [__Best Practice__]
35-- | [__Best Practice__]
@@ -33,8 +41,25 @@ import Network.HTTP.Types (methodGet, methodHead, parseMethod,
33-- This function checks that the response code is not 500.
41-- This function checks that the response code is not 500.
35-- /Since 0.0.0.0/
43-- /Since 0.0.0.0/
36−not500 :: ResponsePredicate Text Bool
44+not500 :: ResponsePredicate
37−not500 = ResponsePredicate "not500" (\resp -> not $ responseStatus resp == status500)
45+not500 = ResponsePredicate $ \resp ->
46+ when (responseStatus resp == status500) $ throw $ PredicateFailure "not500" Nothing resp
50+-- This function checks that the response from the server does not take longer
51+-- than the specified number of nanoseconds.
54+notLongerThan :: Integer -> RequestPredicate
55+notLongerThan maxAllowed
56+ = RequestPredicate $ \req mgr -> do
57+ start <- getTime Monotonic
58+ resp <- httpLbs req mgr
59+ end <- getTime Monotonic
60+ when (toNanoSecs (end `diffTimeSpec` start) > maxAllowed) $
61+ throw $ PredicateFailure "notLongerThan" (Just req) resp
39-- | [__Best Practice__]
64-- | [__Best Practice__]
@@ -57,11 +82,15 @@ not500 = ResponsePredicate "not500" (\resp -> not $ responseStatus resp == statu
57-- * JSON Grammar: <https://tools.ietf.org/html/rfc4627#section-2 RFC 4627 Section 2>
82-- * JSON Grammar: <https://tools.ietf.org/html/rfc4627#section-2 RFC 4627 Section 2>
59-- /Since 0.0.0.0/
84-- /Since 0.0.0.0/
60−onlyJsonObjects :: ResponsePredicate Text Bool
85+onlyJsonObjects :: ResponsePredicate
61onlyJsonObjects
86onlyJsonObjects
87+ = ResponsePredicate (\resp -> do
88+ case lookup "content-type" (first foldedCase <$> responseHeaders resp) of
90+ Just ctype -> when ("application/json" `SBS.isPrefixOf` ctype) $ do
62− = ResponsePredicate "onlyJsonObjects" (\resp -> case decode (responseBody resp) of
91+ case (decode (responseBody resp) :: Maybe Object) of
63− Nothing -> False
92+ Nothing -> throw $ PredicateFailure "onlyJsonObjects" Nothing resp
64− Just (_ :: Object) -> True)
93+ Just _ -> return ())
66-- | __Optional__
95-- | __Optional__
@@ -82,29 +111,59 @@ onlyJsonObjects
82-- * Location header: <https://tools.ietf.org/html/rfc7231#section-7.1.2 RFC 7231 Section 7.1.2>
111-- * Location header: <https://tools.ietf.org/html/rfc7231#section-7.1.2 RFC 7231 Section 7.1.2>
84-- /Since 0.0.0.0/
113-- /Since 0.0.0.0/
85−createContainsValidLocation :: RequestPredicate Text Bool
114+createContainsValidLocation :: RequestPredicate
86createContainsValidLocation
115createContainsValidLocation
87− = RequestPredicate
116+ = RequestPredicate $ \req mgr -> do
88− { reqPredName = "createContainsValidLocation"
117+ let n = "createContainsValidLocation"
89− , reqResps = \req mgr -> do
118+ resp <- httpLbs req mgr
119+ if responseStatus resp == status201
120+ then case lookup "Location" $ responseHeaders resp of
121+ Nothing -> throw $ PredicateFailure n (Just req) resp
122+ Just l -> case parseRequest $ SBSC.unpack l of
123+ Nothing -> throw $ PredicateFailure n (Just req) resp
125+ resp2 <- httpLbs x mgr
126+ status2XX (Just req) resp2 n
127+ return [resp, resp2]
132+-- The @Last-Modified@ header represents the time a resource was last
133+-- modified. It is used to drive caching and conditional requests.
135+-- When using this mechanism, the server adds the @Last-Modified@ header to
136+-- responses. Clients may then make requests with the @If-Modified-Since@
137+-- header to conditionally request resources. If the resource has not
138+-- changed since that date, the server responds with a status code of 304
139+-- (@Not Modified@) without a response body.
141+-- The @Last-Modified@ header can also be used in conjunction with the
142+-- @If-Unmodified-Since@ header to drive optimistic concurrency.
144+-- The @Last-Modified@ date must be in RFC 822 format.
148+-- * 304 Not Modified: <https://tools.ietf.org/html/rfc7232#section-4.1 RFC 7232 Section 4.1>
149+-- * Last-Modified header: <https://tools.ietf.org/html/rfc7232#section-2.2 RFC 7232 Section 2.2>
150+-- * If-Modified-Since header: <https://tools.ietf.org/html/rfc7232#section-3.3 RFC 7232 Section 3.3>
151+-- * If-Unmodified-Since header: <https://tools.ietf.org/html/rfc7232#section-3.4 RFC 7232 Section 3.4>
152+-- * Date format: <https://tools.ietf.org/html/rfc2616#section-3.3 RFC 2616 Section 3.3>
155+getsHaveLastModifiedHeader :: RequestPredicate
156+getsHaveLastModifiedHeader
157+ = RequestPredicate $ \req mgr ->
158+ if (method req == methodGet)
90 resp <- httpLbs req mgr
160 resp <- httpLbs req mgr
91− if responseStatus resp == status201
161+ unless (hasValidHeader "Last-Modified" isRFC822Date resp) $ do
92− then case lookup "Location" $ responseHeaders resp of
162+ throw $ PredicateFailure "getsHaveLastModifiedHeader" (Just req) resp
93− Nothing -> return (False, [resp])
94− Just l -> case parseUrl $ SBSC.unpack l of
95− Nothing -> return (False, [resp])
97− resp2 <- httpLbs x mgr
98− return (status2XX resp2, [resp, resp2])
163+ return [resp]
99− else return (True, [resp])
164+ else return []
103−getsHaveLastModifiedHeader :: ResponsePredicate Text Bool
104−getsHaveLastModifiedHeader
105− = ResponsePredicate "getsHaveLastModifiedHeader" (\resp ->
109-- | [__RFC Compliance__]
168-- | [__RFC Compliance__]
@@ -116,24 +175,27 @@ getsHaveLastModifiedHeader
116-- This function checks that every @405 Method Not Allowed@ response contains
175-- This function checks that every @405 Method Not Allowed@ response contains
117-- an @Allow@ header with a list of standard HTTP methods.
176-- an @Allow@ header with a list of standard HTTP methods.
178+-- Note that 'servant' itself does not currently set the @Allow@ headers.
119-- __References__:
180-- __References__:
121-- * @Allow@ header: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html RFC 2616 Section 14.7>
182-- * @Allow@ header: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html RFC 2616 Section 14.7>
122-- * Status 405: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html RFC 2616 Section 10.4.6>
183-- * Status 405: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html RFC 2616 Section 10.4.6>
184+-- * Servant Allow header issue: <https://github.com/haskell-servant/servant/issues/489 Issue #489>
124-- /Since 0.0.0.0/
186-- /Since 0.0.0.0/
125−notAllowedContainsAllowHeader :: RequestPredicate Text Bool
187+notAllowedContainsAllowHeader :: RequestPredicate
126notAllowedContainsAllowHeader
188notAllowedContainsAllowHeader
127− = RequestPredicate
189+ = RequestPredicate $ \req mgr -> do
128− { reqPredName = "notAllowedContainsAllowHeader"
129− , reqResps = \req mgr -> do
130− resp <- mapM (flip httpLbs mgr) $ [ req { method = renderStdMethod m }
190+ let reqs = [ req { method = renderStdMethod m } | m <- [minBound .. maxBound]
131− | m <- [minBound .. maxBound ]
132− , renderStdMethod m /= method req ]
191+ , renderStdMethod m /= method req ]
133− return (all pred' resp, resp)
192+ resp <- mapM (flip httpLbs mgr) reqs
194+ case filter pred' (zip reqs resp) of
195+ (x:_) -> throw $ PredicateFailure "notAllowedContainsAllowHeader" (Just $ fst x) (snd x)
136− pred' resp = responseStatus resp /= status405 || hasValidHeader "Allow" go resp
198+ pred' (_, resp) = responseStatus resp == status405 && not (hasValidHeader "Allow" go resp)
138 go x = all (\y -> isRight $ parseMethod $ SBSC.pack y)
200 go x = all (\y -> isRight $ parseMethod $ SBSC.pack y)
139 $ wordsBy (`elem` (", " :: [Char])) (SBSC.unpack x)
201 $ wordsBy (`elem` (", " :: [Char])) (SBSC.unpack x)
@@ -154,19 +216,19 @@ notAllowedContainsAllowHeader
154-- * @Accept@ header: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html RFC 2616 Section 14.1>
216-- * @Accept@ header: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html RFC 2616 Section 14.1>
156-- /Since 0.0.0.0/
218-- /Since 0.0.0.0/
157−honoursAcceptHeader :: RequestPredicate Text Bool
219+honoursAcceptHeader :: RequestPredicate
158honoursAcceptHeader
220honoursAcceptHeader
159− = RequestPredicate
221+ = RequestPredicate $ \req mgr -> do
160− { reqPredName = "honoursAcceptHeader"
161− , reqResps = \req mgr -> do
162− resp <- httpLbs req mgr
222+ resp <- httpLbs req mgr
163− let scode = responseStatus resp
223+ let scode = responseStatus resp
164− sctype = lookup "Content-Type" $ responseHeaders resp
224+ sctype = lookup "Content-Type" $ responseHeaders resp
165− sacc = fromMaybe "*/*" $ lookup "Accept" (requestHeaders req)
225+ sacc = fromMaybe "*/*" $ lookup "Accept" (requestHeaders req)
166− if status100 < scode && scode < status300
226+ if status100 < scode && scode < status300
167− then return (isJust $ sctype >>= \x -> matchAccept [x] sacc, [resp])
227+ then if isJust $ sctype >>= \x -> matchAccept [x] sacc
228+ then throw $ PredicateFailure "honoursAcceptHeader" (Just req) resp
168− else return (True, [resp])
229+ else return [resp]
169− }
230+ else return [resp]
171-- | [__Best Practice__]
233-- | [__Best Practice__]
@@ -182,34 +244,32 @@ honoursAcceptHeader
182-- * @Cache-Control@ header: <https://tools.ietf.org/html/rfc7234#section-5.2 RFC 7234 Section 5.2>
244-- * @Cache-Control@ header: <https://tools.ietf.org/html/rfc7234#section-5.2 RFC 7234 Section 5.2>
184-- /Since 0.0.0.0/
246-- /Since 0.0.0.0/
185−getsHaveCacheControlHeader :: RequestPredicate Text Bool
247+getsHaveCacheControlHeader :: RequestPredicate
186getsHaveCacheControlHeader
248getsHaveCacheControlHeader
187− = RequestPredicate
249+ = RequestPredicate $ \req mgr ->
188− { reqPredName = "getsHaveCacheControlHeader"
189− , reqResps = \req mgr -> if method req == methodGet
250+ if (method req == methodGet)
191− resp <- httpLbs req mgr
252+ resp <- httpLbs req mgr
192− let good = isJust $ lookup "Cache-Control" $ responseHeaders resp
253+ unless (hasValidHeader "Cache-Control" (const True) resp) $ do
193− return (good, [resp])
254+ throw $ PredicateFailure "getsHaveCacheControlHeader" (Just req) resp
194− else return (True, [])
255+ return [resp]
195− }
256+ else return []
197-- | [__Best Practice__]
258-- | [__Best Practice__]
199-- Like 'getsHaveCacheControlHeader', but for @HEAD@ requests.
260-- Like 'getsHaveCacheControlHeader', but for @HEAD@ requests.
201-- /Since 0.0.0.0/
262-- /Since 0.0.0.0/
202−headsHaveCacheControlHeader :: RequestPredicate Text Bool
263+headsHaveCacheControlHeader :: RequestPredicate
203headsHaveCacheControlHeader
264headsHaveCacheControlHeader
204− = RequestPredicate
265+ = RequestPredicate $ \req mgr ->
205− { reqPredName = "headsHaveCacheControlHeader"
206− , reqResps = \req mgr -> if method req == methodHead
266+ if (method req == methodHead)
208− resp <- httpLbs req mgr
268+ resp <- httpLbs req mgr
209− let good = hasValidHeader "Cache-Control" (const True) resp
269+ unless (hasValidHeader "Cache-Control" (const True) resp) $
210− return (good, [resp])
270+ throw $ PredicateFailure "headsHaveCacheControlHeader" (Just req) resp
211− else return (True, [])
272+ else return []
@@ -271,12 +331,35 @@ linkHeadersAreValid
271-- * @WWW-Authenticate@ header: <https://tools.ietf.org/html/rfc7235#section-4.1 RFC 7235 Section 4.1>
331-- * @WWW-Authenticate@ header: <https://tools.ietf.org/html/rfc7235#section-4.1 RFC 7235 Section 4.1>
273-- /Since 0.0.0.0/
333-- /Since 0.0.0.0/
274−unauthorizedContainsWWWAuthenticate :: ResponsePredicate Text Bool
334+unauthorizedContainsWWWAuthenticate :: ResponsePredicate
275unauthorizedContainsWWWAuthenticate
335unauthorizedContainsWWWAuthenticate
276− = ResponsePredicate "unauthorizedContainsWWWAuthenticate" (\resp ->
336+ = ResponsePredicate $ \resp ->
277 if responseStatus resp == status401
337 if responseStatus resp == status401
278− then hasValidHeader "WWW-Authenticate" (const True) resp
338+ then unless (hasValidHeader "WWW-Authenticate" (const True) resp) $
339+ throw $ PredicateFailure "unauthorizedContainsWWWAuthenticate" Nothing resp
343+-- | [__RFC Compliance__]
345+-- [An HTML] document will start with exactly this string: <!DOCTYPE html>
347+-- This function checks that HTML documents (those with `Content-Type: text/html...`)
348+-- include a DOCTYPE declaration at the top. We do not enforce capital case for the string `DOCTYPE`.
352+-- * HTML5 Doctype: <https://tools.ietf.org/html/rfc7992#section-6.1 RFC 7992 Section 6.1>
354+htmlIncludesDoctype :: ResponsePredicate
356+ = ResponsePredicate $ \resp ->
357+ if hasValidHeader "Content-Type" (SBS.isPrefixOf . foldCase $ "text/html") resp
359+ let htmlContent = foldCase . LBS.take 20 $ responseBody resp
360+ unless (LBS.isPrefixOf (foldCase "<!doctype html>") htmlContent) $
361+ throw $ PredicateFailure "htmlIncludesDoctype" Nothing resp
279− else True)
362+ else return ()
281-- * Predicate logic
364-- * Predicate logic
@@ -289,67 +372,56 @@ unauthorizedContainsWWWAuthenticate
289-- | A predicate that depends only on the response.
372-- | A predicate that depends only on the response.
291-- /Since 0.0.0.0/
374-- /Since 0.0.0.0/
292−data ResponsePredicate n r = ResponsePredicate
375+newtype ResponsePredicate = ResponsePredicate
294− , respPred :: Response LBS.ByteString -> r
376+ { getResponsePredicate :: Response LBS.ByteString -> IO ()
295− } deriving (Functor, Generic)
377+ } deriving (Generic)
297−instance Bifunctor ResponsePredicate where
379+instance Semigroup ResponsePredicate where
298− first f (ResponsePredicate a b) = ResponsePredicate (f a) b
380+ ResponsePredicate a <> ResponsePredicate b = ResponsePredicate $ \x -> a x >> b x
301−instance (Monoid n, Monoid r) => Monoid (ResponsePredicate n r) where
382+instance Monoid ResponsePredicate where
302− mempty = ResponsePredicate mempty mempty
383+ mempty = ResponsePredicate $ const $ return ()
303− a `mappend` b = ResponsePredicate
384+ mappend = (<>)
304− { respPredName = respPredName a <> respPredName b
305− , respPred = respPred a <> respPred b
308-- | A predicate that depends on both the request and the response.
386-- | A predicate that depends on both the request and the response.
310-- /Since 0.0.0.0/
388-- /Since 0.0.0.0/
311−data RequestPredicate n r = RequestPredicate
389+newtype RequestPredicate = RequestPredicate
313− , reqResps :: Request -> Manager -> IO (r, [Response LBS.ByteString])
390+ { getRequestPredicate :: Request -> Manager -> IO [Response LBS.ByteString]
314− } deriving (Generic, Functor)
391+ } deriving (Generic)
393+-- TODO: This isn't actually a monoid
316−instance Bifunctor RequestPredicate where
394+instance Monoid RequestPredicate where
317− first f (RequestPredicate a b) = RequestPredicate (f a) b
395+ mempty = RequestPredicate (\r m -> httpLbs r m >>= \x -> return ([x]))
318− second = fmap
396+ mappend = (<>)
320-- TODO: This isn't actually a monoid
398-- TODO: This isn't actually a monoid
321−instance (Monoid n, Monoid r) => Monoid (RequestPredicate n r) where
399+instance Semigroup RequestPredicate where
322− mempty = RequestPredicate mempty (\r m -> httpLbs r m >>= \x -> return (mempty, [x]))
323− a `mappend` b = RequestPredicate
400+ RequestPredicate a <> RequestPredicate b = RequestPredicate $ \r mgr ->
324− { reqPredName = reqPredName a <> reqPredName b
401+ liftM2 (<>) (a r mgr) (b r mgr)
325− , reqResps = \x m -> liftM2 (<>) (reqResps a x m) (reqResps b x m)
328-- | A set of predicates. Construct one with 'mempty' and '<%>'.
403-- | A set of predicates. Construct one with 'mempty' and '<%>'.
329−data Predicates n r = Predicates
404+data Predicates = Predicates
330− { reqPreds :: RequestPredicate n r
405+ { requestPredicates :: RequestPredicate
331− , respPreds :: ResponsePredicate n r
406+ , responsePredicates :: ResponsePredicate
332− } deriving (Generic, Functor)
407+ } deriving (Generic)
334−instance (Monoid n, Monoid r) => Monoid (Predicates n r) where
335− mempty = Predicates mempty mempty
336− a `mappend` b = Predicates (reqPreds a <> reqPreds b) (respPreds a <> respPreds b)
409+instance Semigroup Predicates where
410+ a <> b = Predicates (requestPredicates a <> requestPredicates b)
411+ (responsePredicates a <> responsePredicates b)
413+instance Monoid Predicates where
414+ mempty = Predicates mempty mempty
340class JoinPreds a where
417class JoinPreds a where
341− joinPreds :: a -> Predicates [Text] [Text] -> Predicates [Text] [Text]
418+ joinPreds :: a -> Predicates -> Predicates
343−instance JoinPreds (RequestPredicate Text Bool) where
344− joinPreds p (Predicates x y) = Predicates (go <> x) y
345− where go = let p' = first return p
346− in fmap (\z -> if z then [] else reqPredName p') p'
348−instance JoinPreds (ResponsePredicate Text Bool) where
420+instance JoinPreds (RequestPredicate ) where
349− joinPreds p (Predicates x y) = Predicates x (go <> y)
421+ joinPreds p (Predicates x y) = Predicates (p <> x) y
350− where go = let p' = first return p
351− in fmap (\z -> if z then [] else respPredName p') p'
423+instance JoinPreds (ResponsePredicate ) where
424+ joinPreds p (Predicates x y) = Predicates x (p <> y)
354-- | Adds a new predicate (either `ResponsePredicate` or `RequestPredicate`) to
426-- | Adds a new predicate (either `ResponsePredicate` or `RequestPredicate`) to
355-- the existing predicates.
427-- the existing predicates.
@@ -357,14 +429,17 @@ instance JoinPreds (ResponsePredicate Text Bool) where
357-- > not500 <%> onlyJsonObjects <%> empty
429-- > not500 <%> onlyJsonObjects <%> empty
359-- /Since 0.0.0.0/
431-- /Since 0.0.0.0/
360−(<%>) :: JoinPreds a => a -> Predicates [Text] [Text] -> Predicates [Text] [Text]
432+(<%>) :: JoinPreds a => a -> Predicates -> Predicates
361(<%>) = joinPreds
433(<%>) = joinPreds
362infixr 6 <%>
434infixr 6 <%>
364−finishPredicates :: Predicates [Text] [Text] -> Request -> Manager -> IO [Text]
436+finishPredicates :: Predicates -> Request -> Manager -> IO (Maybe PredicateFailure)
365−finishPredicates p req mgr = do
437+finishPredicates p req mgr = go `catch` \(e :: PredicateFailure) -> return $ Just e
366− (soFar, resps) <- reqResps (reqPreds p) req mgr
438+ where
367− return $ soFar <> mconcat [respPred (respPreds p) r | r <- resps]
439+ go = do
440+ resps <- getRequestPredicate (requestPredicates p) req mgr
441+ mapM_ (getResponsePredicate $ responsePredicates p) resps
369-- * helpers
444-- * helpers
@@ -373,5 +448,14 @@ hasValidHeader hdr p r = case lookup (mk hdr) (responseHeaders r) of
373 Nothing -> False
448 Nothing -> False
374 Just v -> p v
449 Just v -> p v
451+isRFC822Date :: SBS.ByteString -> Bool
453+ = case parseTimeM True defaultTimeLocale rfc822DateFormat (SBSC.unpack s) of
455+ Just (_ :: UTCTime) -> True
376−status2XX :: Response b -> Bool
457+status2XX :: Monad m => Maybe Request -> Response LBS.ByteString -> T.Text -> m ()
458+status2XX mreq resp t
377−status2XX r = status200 <= responseStatus r && responseStatus r < status300
459+ | status200 <= responseStatus resp && responseStatus resp < status300
461+ | otherwise = throw $ PredicateFailure t mreq resp