packages feed

req-1.0.0: pure-tests/Network/HTTP/ReqSpec.hs

{-# LANGUAGE CPP                       #-}
{-# LANGUAGE DataKinds                 #-}
{-# LANGUAGE DeriveGeneric             #-}
{-# LANGUAGE FlexibleInstances         #-}
{-# LANGUAGE NoMonomorphismRestriction #-}
{-# LANGUAGE OverloadedStrings         #-}
{-# LANGUAGE RankNTypes                #-}
{-# LANGUAGE RecordWildCards           #-}
{-# LANGUAGE ScopedTypeVariables       #-}
{-# OPTIONS_GHC -fno-warn-orphans      #-}

module Network.HTTP.ReqSpec
  ( spec )
where

import Control.Exception (throwIO)
import Data.Aeson (ToJSON (..))
import Data.ByteString (ByteString)
import Data.Default.Class
import Data.Maybe (isNothing, fromJust)
import Data.Monoid ((<>))
import Data.Proxy
import Data.Text (Text)
import Data.Time
import GHC.Generics
import Network.HTTP.Req
import Test.Hspec
import Test.Hspec.Core.Spec (SpecM)
import Test.QuickCheck
import qualified Blaze.ByteString.Builder as BB
import qualified Data.Aeson               as A
import qualified Data.ByteString          as B
import qualified Data.ByteString.Char8    as B8
import qualified Data.ByteString.Lazy     as BL
import qualified Data.CaseInsensitive     as CI
import qualified Data.Text                as T
import qualified Data.Text.Encoding       as T
import qualified Network.HTTP.Client      as L
import qualified Network.HTTP.Types       as Y

spec :: Spec
spec = do

  describe "config" $
    it "getHttpConfig has effect on resulting request" $
      property $ \config -> do
        request <- runReq config (req_ GET url NoReqBody mempty)
        L.proxy         request `shouldBe` httpConfigProxy         config
        L.redirectCount request `shouldBe` httpConfigRedirectCount config

  describe "methods" $ do
    let mnth
          :: forall method.
             ( HttpMethod method
             , HttpBodyAllowed (AllowsBody method) 'NoBody )
          => method
          -> SpecM () ()
        mnth method = do
          let name = httpMethodName (Proxy :: Proxy method)
          describe (B8.unpack name) $
            it "affects name of HTTP method" $ do
              request <- req_ method url NoReqBody mempty
              L.method request `shouldBe` name
    mnth GET
    mnth POST
    mnth HEAD
    mnth PUT
    mnth DELETE
    mnth TRACE
    mnth CONNECT
    mnth OPTIONS
    mnth PATCH

  describe "urls" $ do
    describe "http" $
      it "sets all the params correctly" $
        property $ \host -> do
          request <- req_ GET (http host) NoReqBody mempty
          L.secure request `shouldBe` False
          L.port   request `shouldBe` 80
          L.host   request `shouldBe` urlEncode host
    describe "https" $
      it "sets all the params correctly" $
        property $ \host -> do
          request <- req_ GET (https host) NoReqBody mempty
          L.secure request `shouldBe` True
          L.port   request `shouldBe` 443
          L.host   request `shouldBe` urlEncode host
    describe "(/~)" $
      it "attaches a path piece that is URL-encoded" $
        property $ \host pieces -> do
          let url' = foldl (/~) (https host) pieces
          request <- req_ GET url' NoReqBody mempty
          L.host request `shouldBe` urlEncode host
          L.path request `shouldBe` encodePathPieces pieces
    describe "(/:)" $
      it "attaches a path piece that is URL-encoded" $
        property $ \host pieces -> do
          let url' = foldl (/:) (https host) pieces
          request <- req_ GET url' NoReqBody mempty
          L.host request `shouldBe` urlEncode host
          L.path request `shouldBe` encodePathPieces pieces
    describe "parseUrlHttp" $ do
      it "does not recognize non-http schemes" $
        parseUrlHttp "https://httpbin.org" `shouldSatisfy` isNothing
      it "parses correct URLs" $
        property $ \host mport' pieces queryParams -> do
          let (url', path, queryString) =
                assembleUrl Http host mport' pieces queryParams
              (url'', options) = fromJust (parseUrlHttp url')
          request <- req_ GET url'' NoReqBody options
          L.host        request `shouldBe` urlEncode (unHost host)
          L.port        request `shouldBe` maybe 80 getNonNegative mport'
          L.path        request `shouldBe` path
          L.queryString request `shouldBe` queryString
      it "rejects gibberish in port component" $ do
        parseUrlHttp "http://my-site.com:bob/far" `shouldSatisfy` isNothing
        parseUrlHttp "http://my-site.com:7001uh/api" `shouldSatisfy` isNothing
        parseUrlHttp "http://my-site.com:/bar" `shouldSatisfy` isNothing
    describe "parseUrlHttps" $ do
      it "does not recognize non-https schemes" $
        parseUrlHttps "http://httpbin.org" `shouldSatisfy` isNothing
      it "parses correct URLs" $
        property $ \host mport' pieces queryParams -> do
          let (url', path, queryString) =
                assembleUrl Https host mport' pieces queryParams
              (url'', options) = fromJust (parseUrlHttps url')
          request <- req_ GET url'' NoReqBody options
          L.host        request `shouldBe` urlEncode (unHost host)
          L.port        request `shouldBe` maybe 443 getNonNegative mport'
          L.path        request `shouldBe` path
          L.queryString request `shouldBe` queryString
      it "rejects gibberish in port component" $ do
        parseUrlHttp "https://my-site.com:bob/far" `shouldSatisfy` isNothing
        parseUrlHttp "https://my-site.com:7001uh/api" `shouldSatisfy` isNothing
        parseUrlHttp "https://my-site.com:/bar" `shouldSatisfy` isNothing

  describe "bodies" $ do
    describe "NoReqBody" $
      it "sets body to empty byte string" $ do
        request <- req_ POST url NoReqBody mempty
        case L.requestBody request of
          L.RequestBodyBS x -> x `shouldBe` B.empty
          _ -> expectationFailure "Wrong request body constructor."
    describe "ReqBodyJson" $
      it "sets body to correct lazy byte string" $
        property $ \thing -> do
          request <- req_ POST url (ReqBodyJson thing) mempty
          case L.requestBody request of
            L.RequestBodyLBS x -> x `shouldBe` A.encode (thing :: Thing)
            _ -> expectationFailure "Wrong request body constructor."
    describe "ReqBodyBs" $
      it "sets body to specified strict byte string" $
        property $ \bs -> do
          request <- req_ POST url (ReqBodyBs bs) mempty
          case L.requestBody request of
            L.RequestBodyBS x -> x `shouldBe` bs
            _ -> expectationFailure "Wrong request body constructor."
    describe "ReqBodyLbs" $
     it "sets body to specified lazy byte string" $
        property $ \lbs -> do
          request <- req_ POST url (ReqBodyLbs lbs) mempty
          case L.requestBody request of
            L.RequestBodyLBS x -> x `shouldBe` lbs
            _ -> expectationFailure "Wrong request body constructor."
    describe "ReqBodyUrlEnc" $
      it "sets body to correct lazy byte string" $
        property $ \params -> do
          request <- req_ POST url (ReqBodyUrlEnc (formUrlEnc params)) mempty
          case L.requestBody request of
            L.RequestBodyLBS x -> x `shouldBe` renderQuery params
            _ -> expectationFailure "Wrong request body constructor."

  describe "optional parameters" $ do
    describe "header" $ do
      it "sets specified header value" $
        property $ \name value -> do
          request <- req_ GET url NoReqBody (header name value)
          lookup (CI.mk name) (L.requestHeaders request) `shouldBe` pure value
      it "left header wins" $
        property $ \name value0 value1 -> do
          request <- req_ GET url NoReqBody
            (header name value0 <> header name value1)
          lookup (CI.mk name) (L.requestHeaders request) `shouldBe` pure value0
      it "overwrites headers set by other parts of the lib" $
        property $ \value -> do
          request <- req_ POST url (ReqBodyUrlEnc mempty)
            (header "Content-Type" value)
          lookup "Content-Type" (L.requestHeaders request) `shouldBe` pure value
    describe "cookieJar" $
      it "cookie jar is set without modifications" $
        property $ \cjar -> do
          request <- req_ GET url NoReqBody (cookieJar cjar)
          L.cookieJar request `shouldBe` pure cjar
    describe "basicAuth" $ do
      it "sets Authorization header to correct value" $
        property $ \username password -> do
          request <- req_ GET url NoReqBody (basicAuth username password)
          lookup "Authorization" (L.requestHeaders request) `shouldBe`
            pure (basicAuthHeader username password)
      it "overwrites manual setting of header" $
        property $ \username password value -> do
          request0 <- req_ GET url NoReqBody
            (basicAuth username password <> header "Authorization" value)
          request1 <- req_ GET url NoReqBody
            (header "Authorization" value <> basicAuth username password)
          let result = basicAuthHeader username password
          lookup "Authorization" (L.requestHeaders request0) `shouldBe`
            pure result
          lookup "Authorization" (L.requestHeaders request1) `shouldBe`
            pure result
      it "left auth option wins" $
        property $ \username0 password0 username1 password1 -> do
          request <- req_ GET url NoReqBody
            (basicAuth username0 password0 <> basicAuth username1 password1)
          lookup "Authorization" (L.requestHeaders request) `shouldBe`
            pure (basicAuthHeader username0 password0)
    describe "oAuth2Bearer" $ do
      it "sets Authorization header to correct value" $
        property $ \token -> do
          request <- req_ GET url NoReqBody (oAuth2Bearer token)
          lookup "Authorization" (L.requestHeaders request) `shouldBe`
            pure ("Bearer " <> token)
      it "overwrites manual setting of header" $
        property $ \token value -> do
          request0 <- req_ GET url NoReqBody
            (oAuth2Bearer token <> header "Authorization" value)
          request1 <- req_ GET url NoReqBody
            (header "Authorization" value <> oAuth2Bearer token)
          let result = "Bearer " <> token
          lookup "Authorization" (L.requestHeaders request0) `shouldBe`
            pure result
          lookup "Authorization" (L.requestHeaders request1) `shouldBe`
            pure result
      it "left auth option wins" $
        property $ \token0 token1 -> do
          request <- req_ GET url NoReqBody
            (oAuth2Bearer token0 <> oAuth2Bearer token1)
          lookup "Authorization" (L.requestHeaders request) `shouldBe`
            pure ("Bearer " <> token0)
    describe "oAuth2Token" $ do
      it "sets Authorization header to correct value" $
        property $ \token -> do
          request <- req_ GET url NoReqBody (oAuth2Token token)
          lookup "Authorization" (L.requestHeaders request) `shouldBe`
            pure ("token " <> token)
      it "overwrites manual setting of header" $
        property $ \token value -> do
          request0 <- req_ GET url NoReqBody
            (oAuth2Token token <> header "Authorization" value)
          request1 <- req_ GET url NoReqBody
            (header "Authorization" value <> oAuth2Token token)
          let result = "token " <> token
          lookup "Authorization" (L.requestHeaders request0) `shouldBe`
            pure result
          lookup "Authorization" (L.requestHeaders request1) `shouldBe`
            pure result
      it "left auth option wins" $
        property $ \token0 token1 -> do
          request <- req_ GET url NoReqBody
            (oAuth2Token token0 <> oAuth2Token token1)
          lookup "Authorization" (L.requestHeaders request) `shouldBe`
            pure ("token " <> token0)
    describe "port" $
      it "sets port overwriting the defaults" $
        property $ \n -> do
          request <- req_ GET url NoReqBody (port n)
          L.port request `shouldBe` n
    describe "decompress" $
      it "sets decompress function overwriting the defaults" $
        property $ \token -> do
          request <- req_ GET url NoReqBody (decompress (/= token))
          L.decompress request token `shouldBe` False
    -- FIXME Can't really test responseTimeout right new because the
    -- ResponseTimeout data type does not implement Eq and its constructors
    -- are also not exported. Sent a PR.
    describe "httpVersion" $
      it "sets HTTP version overwriting the defaults" $
        property $ \major minor -> do
          request <- req_ GET url NoReqBody (httpVersion major minor)
          L.requestVersion request `shouldBe` Y.HttpVersion major minor

----------------------------------------------------------------------------
-- Instances

instance MonadHttp IO where
  handleHttpException = throwIO

instance Arbitrary HttpConfig where
  arbitrary = do
    httpConfigProxy         <- arbitrary
    httpConfigRedirectCount <- arbitrary
    let httpConfigAltManager          = Nothing
        httpConfigCheckResponse _ _ _ = Nothing
        httpConfigRetryPolicy         = def
        httpConfigRetryJudge      _ _ = False
    return HttpConfig {..}

instance Show HttpConfig where
  show HttpConfig {..} =
    "HttpConfig\n" ++
    "{ httpConfigProxy=" ++ show httpConfigProxy ++ "\n" ++
    ", httpRedirectCount=" ++ show httpConfigRedirectCount ++ "\n" ++
    ", httpConfigAltManager=<cannot be shown>\n" ++
    ", httpConfigCheckResponse=<cannot be shown>}\n"

instance Arbitrary L.Proxy where
  arbitrary = L.Proxy <$> arbitrary <*> arbitrary

instance Arbitrary ByteString where
  arbitrary = B.pack <$> arbitrary

instance Arbitrary BL.ByteString where
  arbitrary = BL.pack <$> arbitrary

instance Arbitrary Text where
  arbitrary = T.pack <$> arbitrary

instance Show (Option scheme) where
  show _ = "<cannot be shown>"

data Thing = Thing
  { _thingBool :: Bool
  , _thingText :: Text
  } deriving (Eq, Show, Generic)

instance ToJSON Thing

instance Arbitrary Thing where
  arbitrary = Thing <$> arbitrary <*> arbitrary

instance Arbitrary L.CookieJar where
  arbitrary = L.createCookieJar <$> arbitrary

instance Arbitrary L.Cookie where
  arbitrary = do
    cookie_name          <- arbitrary
    cookie_value         <- arbitrary
    cookie_expiry_time   <- arbitrary
    cookie_domain        <- arbitrary
    cookie_path          <- arbitrary
    cookie_creation_time <- arbitrary
    cookie_last_access_time <- arbitrary
    cookie_persistent    <- arbitrary
    cookie_host_only     <- arbitrary
    cookie_secure_only   <- arbitrary
    cookie_http_only     <- arbitrary
    return L.Cookie {..}

instance Arbitrary UTCTime where
  arbitrary = UTCTime <$> arbitrary <*> arbitrary

instance Arbitrary Day where
  arbitrary = ModifiedJulianDay <$> arbitrary

instance Arbitrary DiffTime where
  arbitrary = secondsToDiffTime <$> arbitrary

----------------------------------------------------------------------------
-- Helper types

-- | A wrapper to generate correct hosts.

newtype Host = Host { unHost :: Text }
  deriving (Eq, Show)

instance Arbitrary Host where
  arbitrary = Host . T.pack <$> listOf1 (arbitrary `suchThat` (/= ':'))

-- | A wrapper to generate correct query parameters.

newtype QueryParams = QueryParams [(Text, Maybe Text)]
  deriving (Eq, Show)

instance Arbitrary QueryParams where
  arbitrary = QueryParams <$> (arbitrary `suchThat` wellFormed)
    where
      wellFormed = all (not . T.null . fst)

----------------------------------------------------------------------------
-- Helpers

-- | 'req' that just returns the prepared 'L.Request'.

req_
  :: ( MonadHttp   m
     , HttpMethod  method
     , HttpBody    body
     , HttpBodyAllowed (AllowsBody method) (ProvidesBody body) )
  => method            -- ^ HTTP method
  -> Url scheme        -- ^ 'Url' — location of resource
  -> body              -- ^ Body of the request
  -> Option scheme     -- ^ Collection of optional parameters
  -> m L.Request       -- ^ Vanilla request
req_ method url' body options = req' method url' body options $
  \request _ -> return request

-- | A dummy 'Url'.

url :: Url 'Https
url = https "httpbin.org"

-- | Percent encode given 'Text'.

urlEncode :: Text -> ByteString
urlEncode = Y.urlEncode False . T.encodeUtf8

-- | Build URL path from given path pieces.

encodePathPieces :: [Text] -> ByteString
encodePathPieces = BL.toStrict . BB.toLazyByteString . Y.encodePathSegments

-- | Assemble a URL.

assembleUrl
  :: Scheme            -- ^ Scheme
  -> Host              -- ^ Host
  -> Maybe (NonNegative Int) -- ^ Port
  -> [Text]            -- ^ Path pieces
  -> QueryParams       -- ^ Query parameters
  -> (ByteString, ByteString, ByteString) -- ^ URL, path, query string
assembleUrl scheme' (Host host') mport' pathPieces (QueryParams queryParams) =
  (scheme <> host <> port' <> path <> queryString, path, queryString)
  where
    scheme = case scheme' of
      Http  -> "http://"
      Https -> "https://"
    host        = urlEncode host'
    port'       =
      case mport' of
        Nothing -> ""
        Just (NonNegative x) -> ":" <> B8.pack (show x)
    path        = encodePathPieces pathPieces
    queryString = Y.renderQuery True (Y.queryTextToQuery queryParams)

renderQuery :: [(Text, Maybe Text)] -> BL.ByteString
renderQuery = BL.fromStrict . Y.renderQuery False . Y.queryTextToQuery

-- | Convert collection of query parameters to 'FormUrlEncodedParam' thing.

formUrlEnc :: [(Text, Maybe Text)] -> FormUrlEncodedParam
formUrlEnc = foldMap (uncurry queryParam)

-- | Get “Authorization” basic auth header given username and password.

basicAuthHeader :: ByteString -> ByteString -> ByteString
basicAuthHeader username password =
  fromJust . lookup Y.hAuthorization . L.requestHeaders $
    L.applyBasicAuth username password L.defaultRequest