@@ -1,30 +1,33 @@
1module Servant.QuickCheck.Internal.Predicates where
1module Servant.QuickCheck.Internal.Predicates where
3−import Control.Exception (catch, SomeException, throw)
3+import Control.Exception (catch, throw)
4−import Control.Monad (liftM2, guard, ap)
4+import Control.Monad (liftM2, unless, when)
5−import Control.Monad.Reader
6import Data.Aeson (Object, decode)
5import Data.Aeson (Object, decode)
7−import Data.Bifunctor (Bifunctor (..))
6+import Data.Bifunctor (first)
9import qualified Data.ByteString as SBS
7import qualified Data.ByteString as SBS
10import qualified Data.ByteString.Char8 as SBSC
8import qualified Data.ByteString.Char8 as SBSC
11import qualified Data.ByteString.Lazy as LBS
9import qualified Data.ByteString.Lazy as LBS
12−import Data.CaseInsensitive (mk)
10+import Data.CaseInsensitive (foldCase, foldedCase, mk)
13import Data.Either (isRight)
11import Data.Either (isRight)
14import Data.List.Split (wordsBy)
12import Data.List.Split (wordsBy)
15import Data.Maybe (fromMaybe, isJust)
13import Data.Maybe (fromMaybe, isJust)
16−import Data.Monoid ((<>))
14+import Data.Semigroup (Semigroup (..))
17−import Data.Text (Text)
15+import qualified Data.Text as T
16+import Data.Time (UTCTime, defaultTimeLocale, parseTimeM,
18import GHC.Generics (Generic)
18import GHC.Generics (Generic)
19import Network.HTTP.Client (Manager, Request, Response, httpLbs,
19import Network.HTTP.Client (Manager, Request, Response, httpLbs,
20− method, parseUrl, requestHeaders,
20+ method, parseRequest, requestHeaders,
21 responseBody, responseHeaders,
21 responseBody, responseHeaders,
22 responseStatus)
22 responseStatus)
23import Network.HTTP.Media (matchAccept)
23import Network.HTTP.Media (matchAccept)
24import Network.HTTP.Types (methodGet, methodHead, parseMethod,
24import Network.HTTP.Types (methodGet, methodHead, parseMethod,
25− renderStdMethod, status200, status201,
25+ renderStdMethod, status100, status200,
26− status300, status401, status405,
26+ status201, status300, status401,
27− status500, status100)
27+ status405, status500)
29+import System.Clock (Clock (Monotonic), diffTimeSpec,
29import Servant.QuickCheck.Internal.ErrorTypes
32import Servant.QuickCheck.Internal.ErrorTypes
@@ -40,7 +43,23 @@ import Servant.QuickCheck.Internal.ErrorTypes
40-- /Since 0.0.0.0/
43-- /Since 0.0.0.0/
41not500 :: ResponsePredicate
44not500 :: ResponsePredicate
42not500 = ResponsePredicate $ \resp ->
45not500 = ResponsePredicate $ \resp ->
43− when (responseStatus resp == status500) $ fail "not500"
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
45-- | [__Best Practice__]
64-- | [__Best Practice__]
@@ -65,9 +84,13 @@ not500 = ResponsePredicate $ \resp ->
65-- /Since 0.0.0.0/
84-- /Since 0.0.0.0/
66onlyJsonObjects :: ResponsePredicate
85onlyJsonObjects :: ResponsePredicate
67onlyJsonObjects
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
68− = ResponsePredicate (\resp -> case decode (responseBody resp) of
91+ case (decode (responseBody resp) :: Maybe Object) of
69− Nothing -> throw $ PredicateFailure "onlyJsonObjects" Nothing resp
92+ Nothing -> throw $ PredicateFailure "onlyJsonObjects" Nothing resp
70− Just (_ :: Object) -> return ())
93+ Just _ -> return ())
72-- | __Optional__
95-- | __Optional__
@@ -95,21 +118,52 @@ createContainsValidLocation
95 resp <- httpLbs req mgr
118 resp <- httpLbs req mgr
96 if responseStatus resp == status201
119 if responseStatus resp == status201
97 then case lookup "Location" $ responseHeaders resp of
120 then case lookup "Location" $ responseHeaders resp of
98− Nothing -> fail n
121+ Nothing -> throw $ PredicateFailure n (Just req) resp
99− Just l -> case parseUrl $ SBSC.unpack l of
122+ Just l -> case parseRequest $ SBSC.unpack l of
100− Nothing -> fail n
123+ Nothing -> throw $ PredicateFailure n (Just req) resp
101 Just x -> do
124 Just x -> do
102 resp2 <- httpLbs x mgr
125 resp2 <- httpLbs x mgr
103− status2XX resp2 n
126+ status2XX (Just req) resp2 n
104 return [resp, resp2]
127 return [resp, resp2]
105 else return [resp]
128 else return [resp]
107−{-
130+-- | [__Optional__]
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>
108−getsHaveLastModifiedHeader :: ResponsePredicate
155+getsHaveLastModifiedHeader :: RequestPredicate
109getsHaveLastModifiedHeader
156getsHaveLastModifiedHeader
110− = ResponsePredicate "getsHaveLastModifiedHeader" (\resp ->
157+ = RequestPredicate $ \req mgr ->
158+ if (method req == methodGet)
160+ resp <- httpLbs req mgr
161+ unless (hasValidHeader "Last-Modified" isRFC822Date resp) $ do
162+ throw $ PredicateFailure "getsHaveLastModifiedHeader" (Just req) resp
114-- | [__RFC Compliance__]
168-- | [__RFC Compliance__]
@@ -121,23 +175,27 @@ getsHaveLastModifiedHeader
121-- This function checks that every @405 Method Not Allowed@ response contains
175-- This function checks that every @405 Method Not Allowed@ response contains
122-- 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.
124-- __References__:
180-- __References__:
126-- * @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>
127-- * 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>
129-- /Since 0.0.0.0/
186-- /Since 0.0.0.0/
130notAllowedContainsAllowHeader :: RequestPredicate
187notAllowedContainsAllowHeader :: RequestPredicate
131notAllowedContainsAllowHeader
188notAllowedContainsAllowHeader
132 = RequestPredicate $ \req mgr -> do
189 = RequestPredicate $ \req mgr -> do
133− resp <- mapM (flip httpLbs mgr) $ [ req { method = renderStdMethod m }
190+ let reqs = [ req { method = renderStdMethod m } | m <- [minBound .. maxBound]
134− | m <- [minBound .. maxBound ]
135− , renderStdMethod m /= method req ]
191+ , renderStdMethod m /= method req ]
192+ resp <- mapM (flip httpLbs mgr) reqs
136− case filter pred' resp of
194+ case filter pred' (zip reqs resp) of
137− (x:xs) -> throw $ PredicateFailure "notAllowedContainsAllowHeader" (Just req) x
195+ (x:_) -> throw $ PredicateFailure "notAllowedContainsAllowHeader" (Just $ fst x) (snd x)
138 [] -> return resp
196 [] -> return resp
140− pred' resp = responseStatus resp == status405 && not (hasValidHeader "Allow" go resp)
198+ pred' (_, resp) = responseStatus resp == status405 && not (hasValidHeader "Allow" go resp)
142 go x = all (\y -> isRight $ parseMethod $ SBSC.pack y)
200 go x = all (\y -> isRight $ parseMethod $ SBSC.pack y)
143 $ wordsBy (`elem` (", " :: [Char])) (SBSC.unpack x)
201 $ wordsBy (`elem` (", " :: [Char])) (SBSC.unpack x)
@@ -167,7 +225,7 @@ honoursAcceptHeader
167 sacc = fromMaybe "*/*" $ lookup "Accept" (requestHeaders req)
225 sacc = fromMaybe "*/*" $ lookup "Accept" (requestHeaders req)
168 if status100 < scode && scode < status300
226 if status100 < scode && scode < status300
169 then if isJust $ sctype >>= \x -> matchAccept [x] sacc
227 then if isJust $ sctype >>= \x -> matchAccept [x] sacc
170− then fail "honoursAcceptHeader"
228+ then throw $ PredicateFailure "honoursAcceptHeader" (Just req) resp
171 else return [resp]
229 else return [resp]
172 else return [resp]
230 else return [resp]
@@ -278,7 +336,29 @@ unauthorizedContainsWWWAuthenticate
278 = ResponsePredicate $ \resp ->
336 = ResponsePredicate $ \resp ->
279 if responseStatus resp == status401
337 if responseStatus resp == status401
280 then unless (hasValidHeader "WWW-Authenticate" (const True) resp) $
338 then unless (hasValidHeader "WWW-Authenticate" (const True) resp) $
281− fail "unauthorizedContainsWWWAuthenticate"
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
282 else return ()
362 else return ()
284-- * Predicate logic
364-- * Predicate logic
@@ -292,37 +372,47 @@ unauthorizedContainsWWWAuthenticate
292-- | A predicate that depends only on the response.
372-- | A predicate that depends only on the response.
294-- /Since 0.0.0.0/
374-- /Since 0.0.0.0/
295−data ResponsePredicate = ResponsePredicate
375+newtype ResponsePredicate = ResponsePredicate
296 { getResponsePredicate :: Response LBS.ByteString -> IO ()
376 { getResponsePredicate :: Response LBS.ByteString -> IO ()
297 } deriving (Generic)
377 } deriving (Generic)
379+instance Semigroup ResponsePredicate where
380+ ResponsePredicate a <> ResponsePredicate b = ResponsePredicate $ \x -> a x >> b x
299instance Monoid ResponsePredicate where
382instance Monoid ResponsePredicate where
300 mempty = ResponsePredicate $ const $ return ()
383 mempty = ResponsePredicate $ const $ return ()
301− ResponsePredicate a `mappend` ResponsePredicate b = ResponsePredicate $ \x -> a x >> b x
384+ mappend = (<>)
303-- | A predicate that depends on both the request and the response.
386-- | A predicate that depends on both the request and the response.
305-- /Since 0.0.0.0/
388-- /Since 0.0.0.0/
306−data RequestPredicate = RequestPredicate
389+newtype RequestPredicate = RequestPredicate
307− { getRequestPredicate :: Request -> Manager -> IO [Response LBS.ByteString]
390+ { getRequestPredicate :: Request -> Manager -> IO [Response LBS.ByteString]
308 } deriving (Generic)
391 } deriving (Generic)
310-- TODO: This isn't actually a monoid
393-- TODO: This isn't actually a monoid
311instance Monoid RequestPredicate where
394instance Monoid RequestPredicate where
312 mempty = RequestPredicate (\r m -> httpLbs r m >>= \x -> return ([x]))
395 mempty = RequestPredicate (\r m -> httpLbs r m >>= \x -> return ([x]))
398+-- TODO: This isn't actually a monoid
399+instance Semigroup RequestPredicate where
313− RequestPredicate a `mappend` RequestPredicate b = RequestPredicate $ \r mgr ->
400+ RequestPredicate a <> RequestPredicate b = RequestPredicate $ \r mgr ->
314 liftM2 (<>) (a r mgr) (b r mgr)
401 liftM2 (<>) (a r mgr) (b r mgr)
316-- | A set of predicates. Construct one with 'mempty' and '<%>'.
403-- | A set of predicates. Construct one with 'mempty' and '<%>'.
317data Predicates = Predicates
404data Predicates = Predicates
318− { requestPredicates :: RequestPredicate
405+ { requestPredicates :: RequestPredicate
319 , responsePredicates :: ResponsePredicate
406 , responsePredicates :: ResponsePredicate
320 } deriving (Generic)
407 } deriving (Generic)
409+instance Semigroup Predicates where
410+ a <> b = Predicates (requestPredicates a <> requestPredicates b)
411+ (responsePredicates a <> responsePredicates b)
322instance Monoid Predicates where
413instance Monoid Predicates where
323 mempty = Predicates mempty mempty
414 mempty = Predicates mempty mempty
324− a `mappend` b = Predicates (requestPredicates a <> requestPredicates b)
415+ mappend = (<>)
325− (responsePredicates a <> responsePredicates b)
327class JoinPreds a where
417class JoinPreds a where
328 joinPreds :: a -> Predicates -> Predicates
418 joinPreds :: a -> Predicates -> Predicates
@@ -358,8 +448,14 @@ hasValidHeader hdr p r = case lookup (mk hdr) (responseHeaders r) of
358 Nothing -> False
448 Nothing -> False
359 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
361−status2XX :: Monad m => Response b -> String -> m ()
457+status2XX :: Monad m => Maybe Request -> Response LBS.ByteString -> T.Text -> m ()
362−status2XX r t
458+status2XX mreq resp t
363− | status200 <= responseStatus r && responseStatus r < status300
459+ | status200 <= responseStatus resp && responseStatus resp < status300
364 = return ()
460 = return ()
365− | otherwise = fail t
461+ | otherwise = throw $ PredicateFailure t mreq resp