packages feed

vcr-0.0.0: src/WebMock.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module WebMock (
  Request (..)
, Response (..)
, disableRequests
, unsafeMockRequest
, mockRequest
, mockRequestChain
, mkRequestActions
, mkRequestAction
, module Network.HTTP.Types

, withRequestAction
, protectRequestAction

, toSimpleRequest
, fromSimpleResponse
, toSimpleResponse
) where

import Imports

import Control.Exception
import Data.ByteString.Lazy qualified as L
import Data.String
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import GHC.Stack (HasCallStack)
import Network.HTTP.Client.Internal (Manager, BodyReader)
import Network.HTTP.Client.Internal qualified as Client
import Network.HTTP.Types
import Network.URI (uriToString)
import Test.HUnit

import WebMock.Util

data Request = Request {
  requestMethod  :: Method
, requestUrl     :: String
, requestHeaders :: RequestHeaders
, requestBody    :: LazyByteString
} deriving (Eq, Ord)

instance IsString Request where
  fromString url = Request "GET" url [] ""

instance Show Request where
  show Request{..} = unlines [
      "Request {"
    , "  requestMethod = " ++ show requestMethod
    , ", requestUrl = " ++ show requestUrl
    , ", requestHeaders = " ++ show requestHeaders
    , ", requestBody = " ++ show requestBody
    , "}"
    ]

data Response = Response {
  responseStatus  :: Status
, responseHeaders :: ResponseHeaders
, responseBody    :: LazyByteString
} deriving (Eq, Show)

instance IsString Response where
  fromString body = Response status200 [] (L.fromStrict $ encodeUtf8 $ T.pack body)

unsafeMockRequest :: (Request -> IO Response) -> IO ()
unsafeMockRequest f = atomicWriteIORef Client.requestAction requestAction
  where
    requestAction :: RequestAction
    requestAction request _manager = do
      toSimpleRequest request >>= f >>= fromSimpleResponse request

mockRequest :: HasCallStack => Request -> Response -> IO a -> IO a
mockRequest expectedRequest response action = protectRequestAction do
  unsafeMockRequest \ request -> do
    request @?= expectedRequest
    return response
  action

disableRequests :: HasCallStack => IO a -> IO a
disableRequests action = do
  let
    requestAction :: IO Response -> Request -> IO Response
    requestAction _makeRequest request = do
      unexpectedRequest request
  withRequestAction requestAction action

mockRequestChain :: HasCallStack => [Request -> IO Response] -> IO a -> IO a
mockRequestChain interactions action = do
  ref <- newIORef interactions

  let
    requestAction :: IO Response -> Request -> IO Response
    requestAction _makeRequest request = do
      atomicModifyIORef' ref (drop 1 &&& listToMaybe) >>= \ case
        Just interaction -> interaction request
        Nothing -> unexpectedRequest request

    checkLeftover :: IO ()
    checkLeftover = do
      leftover <- length <$> atomicReadIORef ref
      when (leftover /= 0) do
        let
          total = length interactions
          actual = total - leftover
        assertFailure $ "Expected " ++ show total ++ " requests, but only received " ++ show actual ++ "!"

  withRequestAction requestAction action <* checkLeftover

unexpectedRequest :: HasCallStack => Request -> IO a
unexpectedRequest request = assertFailure $ "Unexpected HTTP request: " ++ show request

mkRequestActions :: HasCallStack => [(Request, Response)] -> [Request -> IO Response]
mkRequestActions = map (uncurry mkRequestAction)

mkRequestAction :: HasCallStack => Request -> Response -> Request -> IO Response
mkRequestAction expected response actual = do
  actual @?= expected
  return response

type RequestAction = Client.Request -> Manager -> IO (Client.Response BodyReader)

withRequestAction :: (IO Response -> Request -> IO Response) -> IO a -> IO a
withRequestAction action = bracket setup restore . const
  where
    lift :: RequestAction -> RequestAction
    lift makeClientRequest request manager = do
      toSimpleRequest request >>= makeRequest >>= fromSimpleResponse request
      where
        makeRequest :: Request -> IO Response
        makeRequest = action $ makeClientRequest request manager >>= toSimpleResponse

    setup :: IO RequestAction
    setup = atomicModifyIORef' Client.requestAction \ old -> (lift old, old)

    restore :: RequestAction -> IO ()
    restore = atomicWriteIORef Client.requestAction

protectRequestAction :: IO a -> IO a
protectRequestAction = bracket save restore . const
  where
    save :: IO RequestAction
    save = atomicReadIORef Client.requestAction

    restore :: RequestAction -> IO ()
    restore = atomicWriteIORef Client.requestAction

toSimpleRequest :: Client.Request -> IO Request
toSimpleRequest r = do
  body <- requestBodyToByteString (Client.requestBody r)
  return $ Request {
    requestMethod = Client.method r
  , requestUrl = uriToString id (Client.getUri r) ""
  , requestHeaders = Client.requestHeaders r
  , requestBody = body
  }

toSimpleResponse :: Client.Response BodyReader -> IO Response
toSimpleResponse r = do
  c <- Client.brConsume (Client.responseBody r)
  Client.responseClose r
  return $ Response {
    responseStatus = Client.responseStatus r
  , responseHeaders = Client.responseHeaders r
  , responseBody = L.fromChunks c
  }

fromSimpleResponse :: Client.Request -> Response -> IO (Client.Response BodyReader)
fromSimpleResponse request Response{..} = do
  body <- Client.constBodyReader (L.toChunks responseBody)
  return $ Client.Response {
    Client.responseStatus = responseStatus
  , Client.responseVersion = http11
  , Client.responseHeaders = responseHeaders
  , Client.responseBody = body
  , Client.responseCookieJar = mempty
  , Client.responseClose' = Client.ResponseClose pass
  , Client.responseOriginalRequest = request { Client.requestBody = mempty }
  , Client.responseEarlyHints = mempty
  }