vcr-0.1.0: src/VCR.hs
-- |
-- = Record and replay HTTP interactions
--
-- This module provides functionality for recording and replaying HTTP
-- interactions using a tape. A tape represents a stored log of
-- request/response pairs and can be operated in two modes:
--
-- * `AnyOrder` (the default): Requests may be replayed in any order. In this
-- mode the tape is interpreted as a mapping from requests to responses.
-- * `Sequential`: Requests must be replayed in the order in which they were
-- recorded. In this mode the tape is interpreted as a list of
-- request/response pairs.
--
-- The module exports the following operations to work with tapes:
--
-- * `with` - Replays previously recorded interactions and records new ones.
-- * `record` - Records new interactions, overwriting any existing ones.
-- * `play` - Replays previously recorded interactions and fails when a
-- requested interaction is not found on the tape.
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NoFieldSelectors #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
module VCR (
-- * Types
Tape(..)
, Mode(..)
-- * Tape Operations
, with
, record
, play
) where
import Imports
import Control.Exception
import Control.Concurrent.MVar
import Control.Concurrent.Async
import Data.List
import Data.Either
import Data.String
import Data.Map.Strict (Map)
import Data.Map qualified as Map
import GHC.Stack (HasCallStack)
import Test.HUnit
import WebMock hiding (withRequestAction)
import WebMock qualified
import VCR.Serialize qualified as Serialize
-- | A `Tape` is used to record and replay HTTP interactions.
--
-- It consists of:
--
-- * A `file` path to persist the tape to.
-- * A `mode` that controls whether interactions are replayed sequentially or
-- in any order.
-- * A `redact` function that can modify HTTP requests (by default this redacts
-- the @Authorization@ header).
data Tape = Tape {
file :: FilePath -- ^ Path to the tape file.
, mode :: Mode -- ^ Mode of operation.
, redact :: Request -> Request -- ^ Function to redact sensitive information from a request.
}
-- | Represents the mode of operation for a tape.
data Mode =
Sequential -- ^ Requests must be replayed in the order in which they were recorded.
| AnyOrder -- ^ Requests may be replayed in any order.
deriving (Eq, Show)
instance IsString Tape where
fromString file = Tape file AnyOrder redactAuthorization
-- | Execute an action with the given tape in read-write mode.
--
-- * Replay interactions that have been recorded earlier.
-- * Record new interactions that do not yet exist on the tape.
--
-- Use `with` when you want to both replay and record interactions.
with :: HasCallStack => Tape -> IO a -> IO a
with = runTape ReadWriteMode
-- | Execute an action with the given tape in write mode.
--
-- * Overwrite interactions that have been recorded earlier.
-- * Record new interactions that do not yet exist on the tape.
--
-- Use `record` when you want to update a tape with new responses, replacing
-- any existing interactions for a given request.
record :: Tape -> IO a -> IO a
record = runTape WriteMode
-- | Execute an action with the given tape in read mode.
--
-- * Replay interactions that have been recorded earlier.
-- * Fail on new interactions that do not yet exist on the tape.
--
-- Use `play` when you want to test against recorded responses while at the
-- same time also deny real HTTP requests for interactions that have not been
-- recorded.
play :: HasCallStack => Tape -> IO a -> IO a
play = runTape ReadMode
data OpenTape mode = OpenTape {
file :: FilePath
, interactions :: InteractionsFor mode
, redact :: Request -> Request
}
type family InteractionsFor (mode :: Mode)
type instance InteractionsFor AnyOrder = MVar Interactions
data Interactions = Interactions {
modified :: Modified
, interactions :: Map Request (WithIndex (Async Response))
, nextIndex :: Index
}
data Modified = NotModified | Modified
data WithIndex a = WithIndex Index a
newtype Index = Index Int
deriving newtype (Eq, Show, Ord, Enum, Num)
type instance InteractionsFor Sequential = MVar InteractionSequence
data InteractionSequence = InteractionSequence {
modified :: Modified
, replay :: [(Request, Response)]
, rec :: [(Request, Response)]
}
checkLeftover :: HasCallStack => OpenTape Sequential -> IO ()
checkLeftover tape = withMVar tape.interactions \ interactions -> case interactions.replay of
[] -> pass
_ -> assertFailure $ "Expected " ++ show total ++ " requests, but only received " ++ show actual ++ "!"
where
total = actual + length interactions.replay
actual = length interactions.rec
data ReadWriteMode = ReadMode | WriteMode | ReadWriteMode
shouldRecord :: ReadWriteMode -> Bool
shouldRecord = \ case
ReadMode -> False
WriteMode -> True
ReadWriteMode -> True
shouldReplay :: ReadWriteMode -> Bool
shouldReplay = \ case
ReadMode -> True
WriteMode -> False
ReadWriteMode -> True
runTape :: HasCallStack => ReadWriteMode -> Tape -> IO a -> IO a
runTape mode tape = case tape.mode of
AnyOrder -> runTape'AnyOrder mode tape
Sequential -> runTape'Sequential mode tape
runTape'AnyOrder :: HasCallStack => ReadWriteMode -> Tape -> IO c -> IO c
runTape'AnyOrder mode tape action = bracket (openTape'AnyOrder tape) closeTape'AnyOrder \ t -> do
processTape'AnyOrder mode t action
processTape'AnyOrder :: HasCallStack => ReadWriteMode -> OpenTape AnyOrder -> IO a -> IO a
processTape'AnyOrder mode tape = withRequestAction tape.redact \ makeRequest request -> join do
modifyMVar tape.interactions \ interactions -> do
case guard (shouldReplay mode) >> Map.lookup request interactions.interactions of
Just (WithIndex _ response) -> do
return (interactions, wait response)
Nothing | shouldRecord mode -> do
response <- async makeRequest
return (addInteraction request response interactions, wait response)
Nothing -> do
unexpectedRequest request
addInteraction :: Request -> Async Response -> Interactions -> Interactions
addInteraction request response (Interactions _ interactions nextIndex) =
case Map.lookup request interactions of
Nothing -> Interactions {
modified = Modified
, interactions = Map.insert request (WithIndex nextIndex response) interactions
, nextIndex = succ nextIndex
}
Just (WithIndex n _) -> Interactions {
modified = Modified
, interactions = Map.insert request (WithIndex n response) interactions
, nextIndex
}
runTape'Sequential :: HasCallStack => ReadWriteMode -> Tape -> IO c -> IO c
runTape'Sequential mode tape action = do
bracket (openTape'Sequential mode tape) closeTape'Sequential \ t -> do
processTape'Sequential mode t action <* checkLeftover t
processTape'Sequential :: HasCallStack => ReadWriteMode -> OpenTape Sequential -> IO a -> IO a
processTape'Sequential mode tape = withRequestAction tape.redact \ makeRequest request -> do
modifyMVar tape.interactions \ interactions -> do
case interactions.replay of
recorded@(recordedRequest, recordedResponse) : replay -> do
request @?= recordedRequest
return (interactions {
replay
, rec = recorded : interactions.rec
}, recordedResponse)
[] | shouldRecord mode -> do
response <- makeRequest
return (interactions {
modified = Modified
, rec = (request, response) : interactions.rec
}, response)
[] -> do
unexpectedRequest request
unexpectedRequest :: HasCallStack => Request -> IO a
unexpectedRequest request = assertFailure $ "Unexpected HTTP request: " ++ show request
withRequestAction :: (Request -> Request) -> (IO Response -> Request -> IO Response) -> IO a -> IO a
withRequestAction redact requestAction = WebMock.withRequestAction
\ makeRequest request -> requestAction makeRequest (redact request)
openTape'AnyOrder :: Tape -> IO (OpenTape AnyOrder)
openTape'AnyOrder Tape{..} = do
interactions <- Serialize.loadTape file >>= toInteractions >>= newMVar
return OpenTape {
file
, interactions
, redact
}
where
toInteractions :: [(Request, Response)] -> IO Interactions
toInteractions recordedInteractions = do
interactions <- Map.fromList . addIndices <$> traverse toAsyncResponse recordedInteractions
return Interactions {
modified = NotModified
, interactions
, nextIndex = Index (Map.size interactions)
}
toAsyncResponse :: (Request, Response) -> IO (Request, Async Response)
toAsyncResponse = traverse $ return >>> async
addIndices :: [(a, b)] -> [(a, WithIndex b)]
addIndices = zipWith (\ n (request, response) -> (request, WithIndex n response)) [0 ..]
closeTape'AnyOrder :: OpenTape AnyOrder -> IO ()
closeTape'AnyOrder tape = do
withMVar tape.interactions \ (Interactions modified interactions _) -> case modified of
NotModified -> pass
Modified -> sequenceInteractions (toSortedList interactions) >>= Serialize.saveTape tape.file
where
sequenceInteractions :: [(Request, Async Response)] -> IO [(Request, Response)]
sequenceInteractions = fmap rights . traverse waitCatchAll
waitCatchAll :: (Request, Async Response) -> IO (Either SomeException (Request, Response))
waitCatchAll = fmap sequence . traverse waitCatch
toSortedList :: Map a (WithIndex b) -> [(a, b)]
toSortedList = map (fmap value) . sortOnIndex . Map.toList
sortOnIndex :: [(a, WithIndex b)] -> [(a, WithIndex b)]
sortOnIndex = sortOn (index . snd)
index :: WithIndex a -> Index
index (WithIndex n _) = n
value :: WithIndex a -> a
value (WithIndex _ a) = a
openTape'Sequential :: ReadWriteMode -> Tape -> IO (OpenTape Sequential)
openTape'Sequential mode Tape{file, redact} = do
replay <- if shouldReplay mode then Serialize.loadTape file else return []
interactions <- newMVar InteractionSequence {
modified = NotModified
, replay
, rec = []
}
return OpenTape {
file
, interactions
, redact
}
closeTape'Sequential :: OpenTape Sequential -> IO ()
closeTape'Sequential tape = withMVar tape.interactions \ interactions -> case interactions.modified of
Modified -> Serialize.saveTape tape.file (reverse interactions.rec)
_ -> pass
redactAuthorization :: Request -> Request
redactAuthorization request@Request{..} = request { requestHeaders = map redact requestHeaders }
where
redact :: Header -> Header
redact header@(name, _)
| name == hAuthorization = (name, "********")
| otherwise = header