freckle-app-1.15.0.0: library/Freckle/App/Test/Http/MatchRequest.hs
-- | 'Request' predicates for matching 'HttpStub's
--
-- == Usage
--
-- @
-- stubs :: ['HttpStub']
-- stubs =
-- [ \"https://example.com\"
-- & 'matchL' <>~ 'MatchMethod' \"POST\"
-- & 'matchL' <>~ 'MatchHeaders' [(hAccept, \"text/plain+csv\")]
-- & 'matchL' <>~ 'MatchBody' \"id,name\n42,Pat\n\"
-- & 'statusL' .~ 'status201'
-- & 'bodyL' .~ \"OK\n\"
-- ]
-- @
module Freckle.App.Test.Http.MatchRequest
( MatchRequest (..)
, matchRequestFromUrl
, matchRequest
, showMatchRequest
, showMatchRequestWithMismatches
) where
import Freckle.App.Prelude
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BSL
import Data.List (isPrefixOf)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Network.HTTP.Client (Request, RequestBody (..), parseRequest_)
import qualified Network.HTTP.Client.Internal as HTTP
import Network.HTTP.Types.Header (Header, RequestHeaders)
import Network.HTTP.Types.Method (Method)
data MatchRequest
= MatchAnything
| MatchAnd MatchRequest MatchRequest
| MatchMethod Method
| MatchSecure Bool
| MatchHost ByteString
| MatchPort Int
| MatchPath ByteString
| MatchQuery ByteString
| MatchHeaders RequestHeaders
| MatchHeader Header
| MatchBody ByteString
deriving stock (Show)
instance Semigroup MatchRequest where
a <> b = MatchAnd a b
matchRequestFromUrl :: String -> MatchRequest
matchRequestFromUrl url =
fold1 $ maybe id (<>) optionalMatches requiredMatches
where
req = parseRequest_ url
method = HTTP.method req
secure = HTTP.secure req
host = HTTP.host req
port = HTTP.port req
path = HTTP.path req
query = HTTP.queryString req
headers = HTTP.requestHeaders req
body = simplifyRequestBody req
requiredMatches = MatchMethod method :| [MatchSecure secure, MatchPort port]
optionalMatches =
NE.nonEmpty $
catMaybes
[ MatchHost host <$ guard (host /= "")
, MatchPath path <$ guard (hasExplicitPath secure host port url)
, MatchQuery query <$ guard (query /= "")
, MatchHeaders headers <$ guard (not $ null headers)
, MatchBody body <$ guard (body /= "")
]
hasExplicitPath :: Bool -> ByteString -> Int -> String -> Bool
hasExplicitPath secure host port url =
any ((`isPrefixOf` url) . toUrlPrefix) $
concat
[ [Just port]
, Nothing <$ guard (secure && port == 443)
, Nothing <$ guard (not secure && port == 80)
]
where
toUrlPrefix mport =
mconcat
[ "http"
, if secure then "s" else ""
, "://"
, BS8.unpack host
, maybe "" ((":" <>) . show) mport
, "/"
]
-- | Match a 'Request'
--
-- Success is @'Right' ()@, failure is a message in 'Left'.
matchRequest :: Request -> MatchRequest -> Either String ()
matchRequest req mr =
maybe (Right ()) (Left . showMatchRequestWithMismatches mr) $
buildMismatch req mr
showMatchRequest :: MatchRequest -> String
showMatchRequest mr =
"MatchRequest {"
<> concatMap (("\n " <>) . show) (flattenMatchRequest mr)
<> "\n}"
<> "\n"
showMatchRequestWithMismatches :: MatchRequest -> NonEmpty String -> String
showMatchRequestWithMismatches mr mismatches =
showMatchRequest mr
<> "\nMismatches {"
<> concatMap ("\n " <>) (toList mismatches)
<> "\n}"
<> "\n"
flattenMatchRequest :: MatchRequest -> [MatchRequest]
flattenMatchRequest = \case
MatchAnd a b -> flattenMatchRequest a <> flattenMatchRequest b
x -> [x]
buildMismatch :: Request -> MatchRequest -> Maybe (NonEmpty String)
buildMismatch req = \case
MatchAnything -> Nothing
MatchAnd a b -> buildMismatch req a <|> buildMismatch req b
MatchMethod m -> propMismatch "!=" (==) "method" m HTTP.method req
MatchSecure s -> propMismatch "!=" (==) "secure" s HTTP.secure req
MatchHost h -> propMismatch "!=" (==) "host" h HTTP.host req
MatchPort p -> propMismatch "!=" (==) "port" p HTTP.port req
MatchPath p -> propMismatch "!=" (==) "path" p (ensureLeadingSlash . HTTP.path) req
MatchQuery q -> propMismatch "!=" (==) "query" q HTTP.queryString req
MatchHeaders hs -> propMismatch "!=" (==) "headers" hs HTTP.requestHeaders req
MatchHeader h -> propMismatch "not in" elem "header" h HTTP.requestHeaders req
MatchBody bs -> propMismatch "!=" (==) "body" bs simplifyRequestBody req
propMismatch
:: (Show a, Show b)
=> String
-- ^ Label to show infix when comparison fails, e.g. "!="
-> (a -> b -> Bool)
-- ^ How to compare values
-> String
-- ^ Label for the property itself
-> a
-- ^ Value to compare to property
-> (Request -> b)
-- ^ Function to get property from 'Request'
-> Request
-> Maybe (NonEmpty String)
propMismatch opLabel op propLabel a f req
| a `op` b = Nothing
| otherwise = Just $ pure msg
where
b = f req
msg =
"✗ "
<> propLabel
<> ": "
<> show a
<> " "
<> opLabel
<> " "
<> show b
simplifyRequestBody :: Request -> ByteString
simplifyRequestBody = go . HTTP.requestBody
where
go = \case
RequestBodyLBS lbs -> BSL.toStrict lbs
RequestBodyBS bs -> bs
_ -> ""
ensureLeadingSlash :: ByteString -> ByteString
ensureLeadingSlash bs
| Just ('/', _) <- BS8.uncons bs = bs
| otherwise = BS8.cons '/' bs