@@ -1,24 +1,36 @@
1−{-# LANGUAGE MultiWayIf #-}
2module Servant.QuickCheck.Internal.Predicates where
1module Servant.QuickCheck.Internal.Predicates where
4−import Control.Monad
3+import Control.Exception (catch, throw)
4+import Control.Monad (liftM2, unless, when)
5import Data.Aeson (Object, decode)
5import Data.Aeson (Object, decode)
6−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)
13−import Data.Maybe (isJust)
13+import 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,
20+ method, parseRequest, requestHeaders,
18− method, responseBody, responseHeaders,
21+ responseBody, responseHeaders,
19 responseStatus)
22 responseStatus)
23+import Network.HTTP.Media (matchAccept)
20import Network.HTTP.Types (methodGet, methodHead, parseMethod,
24import Network.HTTP.Types (methodGet, methodHead, parseMethod,
25+ renderStdMethod, status100, status200,
26+ status201, status300, status401,
21− status401, renderStdMethod, status405, status500)
27+ status405, status500)
29+import System.Clock (Clock (Monotonic), diffTimeSpec,
32+import Servant.QuickCheck.Internal.ErrorTypes
23-- | [__Best Practice__]
35-- | [__Best Practice__]
@@ -27,8 +39,27 @@ import Network.HTTP.Types (methodGet, methodHead, parseMethod,
27-- indication of how to proceed or what went wrong.
39-- indication of how to proceed or what went wrong.
29-- This function checks that the response code is not 500.
41-- This function checks that the response code is not 500.
30−not500 :: ResponsePredicate Text Bool
44+not500 :: ResponsePredicate
31−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
33-- | [__Best Practice__]
64-- | [__Best Practice__]
@@ -49,30 +80,90 @@ not500 = ResponsePredicate "not500" (\resp -> not $ responseStatus resp == statu
50-- * JSON Grammar: <https://tools.ietf.org/html/rfc7159#section-2 RFC 7159 Section 2>
81-- * JSON Grammar: <https://tools.ietf.org/html/rfc7159#section-2 RFC 7159 Section 2>
51-- * 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>
52−onlyJsonObjects :: ResponsePredicate Text Bool
85+onlyJsonObjects :: ResponsePredicate
53onlyJsonObjects
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
54− = ResponsePredicate "onlyJsonObjects" (\resp -> case decode (responseBody resp) of
91+ case (decode (responseBody resp) :: Maybe Object) of
55− Nothing -> False
92+ Nothing -> throw $ PredicateFailure "onlyJsonObjects" Nothing resp
56− Just (_ :: Object) -> True)
93+ Just _ -> return ())
58−{-
95+-- | __Optional__
59−-- | When creating a new resource, it is good practice to provide a @Location@
97+-- When creating a new resource, it is good practice to provide a @Location@
60-- header with a link to the created resource.
98-- header with a link to the created resource.
62-- This function checks that every @201 Created@ response contains a @Location@
100-- This function checks that every @201 Created@ response contains a @Location@
63-- header, and that the link in it responds with a 2XX response code to @GET@
101-- header, and that the link in it responds with a 2XX response code to @GET@
64-- requests.
102-- requests.
104+-- This is considered optional because other means of linking to the resource
105+-- (e.g. via the response body) are also acceptable; linking to the resource in
106+-- some way is considered best practice.
110+-- * 201 Created: <https://tools.ietf.org/html/rfc7231#section-6.3.2 RFC 7231 Section 6.3.2>
66−-- References: <RFC 7231, Section 6.3.2 https://tools.ietf.org/html/rfc7231#section-6.3.2>
111+-- * Location header: <https://tools.ietf.org/html/rfc7231#section-7.1.2 RFC 7231 Section 7.1.2>
67−createContainsValidLocation :: ResponsePredicate Text Bool
114+createContainsValidLocation :: RequestPredicate
68createContainsValidLocation
115createContainsValidLocation
116+ = RequestPredicate $ \req mgr -> do
69− = ResponsePredicate "createContainsValidLocation" (\resp ->
117+ let n = "createContainsValidLocation"
70−
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>
71−getsHaveLastModifiedHeader :: ResponsePredicate Text Bool
155+getsHaveLastModifiedHeader :: RequestPredicate
72getsHaveLastModifiedHeader
156getsHaveLastModifiedHeader
73− = 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
77-- | [__RFC Compliance__]
168-- | [__RFC Compliance__]
@@ -84,48 +175,102 @@ getsHaveLastModifiedHeader
84-- This function checks that every @405 Method Not Allowed@ response contains
175-- This function checks that every @405 Method Not Allowed@ response contains
85-- 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.
87-- __References__:
180-- __References__:
89-- * @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>
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>
90−notAllowedContainsAllowHeader :: ResponsePredicate Text Bool
187+notAllowedContainsAllowHeader :: RequestPredicate
91notAllowedContainsAllowHeader
188notAllowedContainsAllowHeader
189+ = RequestPredicate $ \req mgr -> do
190+ let reqs = [ req { method = renderStdMethod m } | m <- [minBound .. maxBound]
191+ , renderStdMethod m /= method req ]
192+ resp <- mapM (flip httpLbs mgr) reqs
194+ case filter pred' (zip reqs resp) of
92− = ResponsePredicate "notAllowedContainsAllowHeader" (\resp ->
195+ (x:_) -> throw $ PredicateFailure "notAllowedContainsAllowHeader" (Just $ fst x) (snd x)
93− if responseStatus resp == status405
198+ pred' (_, resp) = responseStatus resp == status405 && not (hasValidHeader "Allow" go resp)
94− then hasValidHeader "Allow" go resp
97− go x = all (\y -> isRight $ parseMethod $ SBSC.pack y)
200+ go x = all (\y -> isRight $ parseMethod $ SBSC.pack y)
98− $ wordsBy (`elem` (", " :: [Char])) (SBSC.unpack x)
201+ $ wordsBy (`elem` (", " :: [Char])) (SBSC.unpack x)
204+-- | [__RFC Compliance__]
101−-- | When a request contains an @Accept@ header, the server must either return
206+-- When a request contains an @Accept@ header, the server must either return
102-- content in one of the requested representations, or respond with @406 Not
207-- content in one of the requested representations, or respond with @406 Not
103-- Acceptable@.
208-- Acceptable@.
105-- This function checks that every *successful* response has a @Content-Type@
210-- This function checks that every *successful* response has a @Content-Type@
106−-- header that matches the @Accept@ header.
211+-- header that matches the @Accept@ header. It does *not* check that the server
212+-- matches the quality descriptions of the @Accept@ header correctly.
216+-- * @Accept@ header: <https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html RFC 2616 Section 14.1>
107−honoursAcceptHeader :: Predicate b Bool
219+honoursAcceptHeader :: RequestPredicate
108honoursAcceptHeader
220honoursAcceptHeader
109− = RequestPredicate "honoursAcceptHeader" _
221+ = RequestPredicate $ \req mgr -> do
222+ resp <- httpLbs req mgr
223+ let scode = responseStatus resp
224+ sctype = lookup "Content-Type" $ responseHeaders resp
225+ sacc = fromMaybe "*/*" $ lookup "Accept" (requestHeaders req)
226+ if status100 < scode && scode < status300
227+ then if isJust $ sctype >>= \x -> matchAccept [x] sacc
228+ then throw $ PredicateFailure "honoursAcceptHeader" (Just req) resp
233+-- | [__Best Practice__]
111−-- | Whether or not a representation should be cached, it is good practice to
235+-- Whether or not a representation should be cached, it is good practice to
112-- have a @Cache-Control@ header for @GET@ requests. If the representation
236-- have a @Cache-Control@ header for @GET@ requests. If the representation
113-- should not be cached, used @Cache-Control: no-cache@.
237-- should not be cached, used @Cache-Control: no-cache@.
115−-- This function checks that @GET@ responses have a valid @Cache-Control@
239+-- This function checks that @GET@ responses have @Cache-Control@ header.
116−-- header.
240+-- It does NOT currently check that the header is valid.
244+-- * @Cache-Control@ header: <https://tools.ietf.org/html/rfc7234#section-5.2 RFC 7234 Section 5.2>
118−-- References: RFC 7234 Section 5.2
246+-- /Since 0.0.0.0/
119−-- https://tools.ietf.org/html/rfc7234#section-5.2
120−getsHaveCacheControlHeader :: Predicate b Bool
247+getsHaveCacheControlHeader :: RequestPredicate
121getsHaveCacheControlHeader
248getsHaveCacheControlHeader
249+ = RequestPredicate $ \req mgr ->
250+ if (method req == methodGet)
252+ resp <- httpLbs req mgr
253+ unless (hasValidHeader "Cache-Control" (const True) resp) $ do
122− = ResponsePredicate "getsHaveCacheControlHeader" _
254+ throw $ PredicateFailure "getsHaveCacheControlHeader" (Just req) resp
258+-- | [__Best Practice__]
124−-- | Like 'getsHaveCacheControlHeader', but for @HEAD@ requests.
260+-- Like 'getsHaveCacheControlHeader', but for @HEAD@ requests.
125−headsHaveCacheControlHeader :: Predicate b Bool
263+headsHaveCacheControlHeader :: RequestPredicate
126headsHaveCacheControlHeader
264headsHaveCacheControlHeader
265+ = RequestPredicate $ \req mgr ->
266+ if (method req == methodHead)
268+ resp <- httpLbs req mgr
269+ unless (hasValidHeader "Cache-Control" (const True) resp) $
127− = ResponsePredicate "headsHaveCacheControlHeader" _
270+ throw $ PredicateFailure "headsHaveCacheControlHeader" (Just req) resp
131-- If the original request modifies the resource, this function makes two
276-- If the original request modifies the resource, this function makes two
@@ -184,12 +329,37 @@ linkHeadersAreValid
184-- __References__:
329-- __References__:
186-- * @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>
187−unauthorizedContainsWWWAuthenticate :: ResponsePredicate Text Bool
334+unauthorizedContainsWWWAuthenticate :: ResponsePredicate
188unauthorizedContainsWWWAuthenticate
335unauthorizedContainsWWWAuthenticate
189− = ResponsePredicate "unauthorizedContainsWWWAuthenticate" (\resp ->
336+ = ResponsePredicate $ \resp ->
190 if responseStatus resp == status401
337 if responseStatus resp == status401
191− 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
192− else True)
362+ else return ()
194-- * Predicate logic
364-- * Predicate logic
@@ -199,79 +369,77 @@ unauthorizedContainsWWWAuthenticate
200-- Still, this is all kind of ugly.
370-- Still, this is all kind of ugly.
372+-- | A predicate that depends only on the response.
202−data ResponsePredicate n r = ResponsePredicate
375+newtype ResponsePredicate = ResponsePredicate
204− , respPred :: Response LBS.ByteString -> r
376+ { getResponsePredicate :: Response LBS.ByteString -> IO ()
205− } deriving (Functor, Generic)
377+ } deriving (Generic)
207−instance Bifunctor ResponsePredicate where
379+instance Semigroup ResponsePredicate where
208− first f (ResponsePredicate a b) = ResponsePredicate (f a) b
380+ ResponsePredicate a <> ResponsePredicate b = ResponsePredicate $ \x -> a x >> b x
211−instance (Monoid n, Monoid r) => Monoid (ResponsePredicate n r) where
382+instance Monoid ResponsePredicate where
212− mempty = ResponsePredicate mempty mempty
383+ mempty = ResponsePredicate $ const $ return ()
213− a `mappend` b = ResponsePredicate
384+ mappend = (<>)
214− { respPredName = respPredName a <> respPredName b
215− , respPred = respPred a <> respPred b
386+-- | A predicate that depends on both the request and the response.
218−data RequestPredicate n r = RequestPredicate
389+newtype RequestPredicate = RequestPredicate
220− , reqResps :: Request -> Manager -> IO [Response LBS.ByteString]
390+ { getRequestPredicate :: Request -> Manager -> IO [Response LBS.ByteString]
221− , reqPred :: ResponsePredicate n r
222− } deriving (Generic, Functor)
391+ } deriving (Generic)
393+-- TODO: This isn't actually a monoid
224−instance Bifunctor RequestPredicate where
394+instance Monoid RequestPredicate where
225− first f (RequestPredicate a b c) = RequestPredicate (f a) b (first f c)
395+ mempty = RequestPredicate (\r m -> httpLbs r m >>= \x -> return ([x]))
226− second = fmap
396+ mappend = (<>)
228-- TODO: This isn't actually a monoid
398-- TODO: This isn't actually a monoid
229−instance (Monoid n, Monoid r) => Monoid (RequestPredicate n r) where
399+instance Semigroup RequestPredicate where
230− mempty = RequestPredicate mempty (\r m -> return <$> httpLbs r m) mempty
400+ RequestPredicate a <> RequestPredicate b = RequestPredicate $ \r mgr ->
231− a `mappend` b = RequestPredicate
401+ liftM2 (<>) (a r mgr) (b r mgr)
232− { reqPredName = reqPredName a <> reqPredName b
233− , reqResps = \x m -> liftM2 (<>) (reqResps a x m) (reqResps b x m)
234− , reqPred = reqPred a <> reqPred b
237−data Predicates n r = Predicates
238− { reqPreds :: RequestPredicate n r
239− , respPreds :: ResponsePredicate n r
240− } deriving (Generic, Functor)
242−instance (Monoid n, Monoid r) => Monoid (Predicates n r) where
243− mempty = Predicates mempty mempty
244− a `mappend` b = Predicates (reqPreds a <> reqPreds b) (respPreds a <> respPreds b)
403+-- | A set of predicates. Construct one with 'mempty' and '<%>'.
404+data Predicates = Predicates
405+ { requestPredicates :: RequestPredicate
406+ , responsePredicates :: ResponsePredicate
407+ } deriving (Generic)
409+instance Semigroup Predicates where
410+ a <> b = Predicates (requestPredicates a <> requestPredicates b)
411+ (responsePredicates a <> responsePredicates b)
248−class JoinPreds a where
413+instance Monoid Predicates where
249− joinPreds :: a -> Predicates [Text] [Text] -> Predicates [Text] [Text]
414+ mempty = Predicates mempty mempty
251−instance JoinPreds (RequestPredicate Text Bool) where
417+class JoinPreds a where
252− joinPreds p (Predicates x y) = Predicates (go <> x) y
418+ joinPreds :: a -> Predicates -> Predicates
253− where go = let p' = first return p
254− in fmap (\z -> if z then [] else reqPredName p') p'
256−instance JoinPreds (ResponsePredicate Text Bool) where
420+instance JoinPreds (RequestPredicate ) where
257− joinPreds p (Predicates x y) = Predicates x (go <> y)
421+ joinPreds p (Predicates x y) = Predicates (p <> x) y
258− where go = let p' = first return p
259− 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)
262-- | Adds a new predicate (either `ResponsePredicate` or `RequestPredicate`) to
426-- | Adds a new predicate (either `ResponsePredicate` or `RequestPredicate`) to
263-- the existing predicates.
427-- the existing predicates.
265-- > not500 <%> onlyJsonObjects <%> empty
429-- > not500 <%> onlyJsonObjects <%> empty
266−(<%>) :: JoinPreds a => a -> Predicates [Text] [Text] -> Predicates [Text] [Text]
432+(<%>) :: JoinPreds a => a -> Predicates -> Predicates
267(<%>) = joinPreds
433(<%>) = joinPreds
268infixr 6 <%>
434infixr 6 <%>
270−finishPredicates :: Predicates [Text] [Text] -> Request -> Manager -> IO [Text]
436+finishPredicates :: Predicates -> Request -> Manager -> IO (Maybe PredicateFailure)
271−finishPredicates p req mgr = do
437+finishPredicates p req mgr = go `catch` \(e :: PredicateFailure) -> return $ Just e
272− resps <- reqResps (reqPreds p) req mgr
438+ where
273− let preds = reqPred (reqPreds p) <> respPreds p
439+ go = do
274− return $ mconcat [respPred preds r | r <- resps ]
440+ resps <- getRequestPredicate (requestPredicates p) req mgr
441+ mapM_ (getResponsePredicate $ responsePredicates p) resps
276-- * helpers
444-- * helpers
@@ -279,3 +447,15 @@ hasValidHeader :: SBS.ByteString -> (SBS.ByteString -> Bool) -> Response b -> Bo
279hasValidHeader hdr p r = case lookup (mk hdr) (responseHeaders r) of
447hasValidHeader hdr p r = case lookup (mk hdr) (responseHeaders r) of
280 Nothing -> False
448 Nothing -> False
281 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
457+status2XX :: Monad m => Maybe Request -> Response LBS.ByteString -> T.Text -> m ()
458+status2XX mreq resp t
459+ | status200 <= responseStatus resp && responseStatus resp < status300
461+ | otherwise = throw $ PredicateFailure t mreq resp