Skip to content

Commit ea21c7a

Browse files
Merge pull request #11 from restaumatic/save-all-requests-from-a-singe-run
Save all requests during proxying and allow compression of cassette
2 parents e9e5dc9 + 3061d7f commit ea21c7a

File tree

6 files changed

+77
-20
lines changed

6 files changed

+77
-20
lines changed

package.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies:
4343
- uri-bytestring
4444
- optparse-applicative
4545
- zlib
46+
- zstd
4647

4748
library:
4849
source-dirs: src

src/Network/VCR.hs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Control.Exception (SomeException, bracket)
1717
import Control.Monad
1818
import qualified Data.ByteString.Char8 as BS
1919
import qualified Data.ByteString.Lazy.Char8 as LBS
20-
import Data.IORef (IORef, newIORef)
20+
import Data.IORef (IORef, newIORef, readIORef)
2121
import qualified Data.Text as T
2222
import qualified Network.HTTP.Client as HC
2323
import qualified Network.HTTP.Conduit as HC
@@ -50,13 +50,14 @@ import Options.Applicative (
5050
)
5151
import System.Environment (getArgs)
5252

53-
import Data.Yaml (decodeFileEither, encodeFile)
53+
import Data.Yaml (decodeFileEither, encodeFile, ToJSON, ParseException, FromJSON)
5454
import System.Directory (doesFileExist)
5555
import System.IO (
5656
BufferMode (..),
5757
hSetBuffering,
5858
stdout,
5959
)
60+
import qualified Network.VCR.Compression as Compression
6061

6162
server :: IO ()
6263
server = execParser opts >>= run
@@ -74,14 +75,14 @@ run options = withServer options $ do
7475
forever $ threadDelay 1000000000
7576

7677
withServer :: Options -> IO a -> IO a
77-
withServer options@Options{mode, cassettePath, port} action = do
78+
withServer options@Options{mode, cassettePath, port, compression} action = do
7879
putStrLn $ "Starting VCR proxy, mode: " <> show mode <> ", cassette file: " <> cassettePath <> ", listening on port: " <> show port
7980
case mode of
8081
Record endpoint -> do
8182
exists <- doesFileExist cassettePath
82-
when (not exists) $ encodeFile cassettePath (emptyCassette $ T.pack endpoint)
83+
when (not exists) $ saveCassette compression cassettePath (emptyCassette $ T.pack endpoint)
8384
_ -> pure ()
84-
cas <- decodeFileEither cassettePath
85+
cas <- loadCassette compression cassettePath
8586
case cas of
8687
Left err -> die $ "Cassette: " <> cassettePath <> " couldn't be decoded or found! " <> (show err)
8788
Right cassette -> do
@@ -91,17 +92,24 @@ withServer options@Options{mode, cassettePath, port} action = do
9192
action
9293

9394
runInternal :: Options -> IORef Cassette -> IO a -> IO a
94-
runInternal Options{mode, cassettePath, port} cassetteIORef action = do
95+
runInternal Options{mode, cassettePath, port, compression} cassetteIORef action = do
9596
-- Set line buffering, because if we use it from a parent process, pipes are full buffered by default
9697
hSetBuffering stdout LineBuffering
9798
started <- newEmptyMVar
98-
bracket (start started) killThread $ \_ -> do
99+
bracket (start started) (stop compression cassettePath cassetteIORef) $ \_ -> do
99100
takeMVar started
100101
action
101102
where
102103
start started = forkIO $ do
103104
mgr <- HC.newManager HC.tlsManagerSettings
104-
Warp.runSettings (warpSettings started proxySettings) $ middleware mode cassetteIORef cassettePath $ HProxy.httpProxyApp proxySettings mgr
105+
Warp.runSettings (warpSettings started proxySettings) $ middleware mode cassetteIORef $ HProxy.httpProxyApp proxySettings mgr
106+
stop compression cassettePath casetteIORef threadId = do
107+
case mode of
108+
Record _ -> do
109+
cassette <- readIORef casetteIORef
110+
saveCassette compression cassettePath cassette
111+
_ -> pure ()
112+
killThread threadId
105113
proxySettings = HProxy.defaultProxySettings{HProxy.proxyPort = port}
106114

107115
warpSettings ::
@@ -124,3 +132,15 @@ defaultExceptionResponse e =
124132
HT.badGateway502
125133
[(HT.hContentType, "text/plain; charset=utf-8")]
126134
$ LBS.fromChunks [BS.pack $ show e]
135+
136+
saveCassette :: ToJSON a => Bool -> FilePath -> a -> IO ()
137+
saveCassette compression path v =
138+
if compression
139+
then Compression.save 3 path v
140+
else encodeFile path v
141+
142+
loadCassette :: FromJSON a => Bool -> FilePath -> IO (Either ParseException a)
143+
loadCassette compression path =
144+
if compression
145+
then Compression.load path
146+
else decodeFileEither path

src/Network/VCR/Compression.hs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{-# LANGUAGE LambdaCase #-}
2+
{-# LANGUAGE NamedFieldPuns #-}
3+
{-# LANGUAGE OverloadedStrings #-}
4+
5+
module Network.VCR.Compression where
6+
7+
import qualified Codec.Compression.Zstd as Zstd
8+
import qualified Data.ByteString as BS
9+
import Data.Yaml
10+
11+
{- | Magic number at the start of zstd frame, see <https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames>
12+
Used to detect compressed payloads.
13+
-}
14+
zstdMagicNumber :: BS.ByteString
15+
zstdMagicNumber = "\x28\xb5\x2f\xfd"
16+
17+
save :: ToJSON a => Int -> FilePath -> a -> IO ()
18+
save compressLevel path v = do
19+
let compressed = Zstd.compress compressLevel $ encode v
20+
BS.writeFile path compressed
21+
22+
load :: FromJSON a => FilePath -> IO (Either ParseException a)
23+
load path = do
24+
compressed <- BS.readFile path
25+
if zstdMagicNumber `BS.isPrefixOf` compressed
26+
then do
27+
case Zstd.decompress compressed of
28+
Zstd.Error err -> error $ "Invalid zstd compressed value: " <> show err
29+
Zstd.Skip -> error "Invalid zstd compressed value (Skip frame)"
30+
Zstd.Decompress v -> pure $ decodeEither' v
31+
else
32+
error $ "Expected " <> show path <> " to start with zstd magic number"

src/Network/VCR/Middleware.hs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,23 @@ import qualified Data.Aeson.Diff as A
5151
import qualified Data.Aeson.Encode.Pretty as A
5252

5353
import Data.CaseInsensitive (CI, mk)
54-
import Data.IORef (atomicModifyIORef)
54+
import Data.IORef (atomicModifyIORef')
5555
import qualified System.Exit as XIO
5656
import System.IO (stderr)
5757
import qualified System.IO (hPutStrLn)
5858
import qualified URI.ByteString as URI
5959

60-
middleware :: Mode -> IORef Cassette -> FilePath -> Wai.Middleware
60+
middleware :: Mode -> IORef Cassette -> Wai.Middleware
6161
middleware Replay = replayingMiddleware findAnyResponse
6262
middleware ReplayStrict = replayingMiddleware consumeRequestsInOrder
6363
middleware Record {endpoint} = recordingMiddleware endpoint
6464

6565
{- | Middleware which only records API calls, the requests are proxied to the `endpoint` and recorded in the
6666
`filePath` cassette file
6767
-}
68-
recordingMiddleware :: String -> IORef Cassette -> FilePath -> Wai.Middleware
69-
recordingMiddleware endpoint cassetteIORef filePath app req respond = do
70-
cassette@Cassette {apiCalls, ignoredHeaders} <- readIORef cassetteIORef
68+
recordingMiddleware :: String -> IORef Cassette -> Wai.Middleware
69+
recordingMiddleware endpoint cassetteIORef app req respond = do
70+
Cassette {ignoredHeaders} <- readIORef cassetteIORef
7171
(req', body) <- getRequestBody req
7272
-- Construct a request that can be sent to the actual remote API, by replacing the host in the request with the endpoint
7373
-- passed as an argument to the middleware
@@ -80,10 +80,7 @@ recordingMiddleware endpoint cassetteIORef filePath app req respond = do
8080
(status, headers, reBody) <- getResponseBody response
8181
savedResponse <- buildResponse reBody response
8282
-- Store the request, response pair
83-
encodeFile filePath $
84-
cassette
85-
{ apiCalls = apiCalls <> [ApiCall {request = savedRequest, response = savedResponse}]
86-
}
83+
atomicModifyIORef' cassetteIORef $ \cassette -> (cassette { apiCalls = apiCalls cassette <> [ApiCall {request = savedRequest, response = savedResponse}] }, ())
8784
respond $ Wai.responseLBS status headers (gzipIfNeeded headers reBody)
8885

8986
-- | A policy for obtaining responses given a request.
@@ -114,8 +111,8 @@ consumeRequestsInOrder cassetteIORef savedRequest = do
114111
{- | Middleware which only replays API calls, if a request is not found in the filePath provided cassette file,
115112
a 500 error will be thrown
116113
-}
117-
replayingMiddleware :: FindResponse -> IORef Cassette -> FilePath -> Wai.Middleware
118-
replayingMiddleware findResponse cassetteIORef filePath app req respond = do
114+
replayingMiddleware :: FindResponse -> IORef Cassette -> Wai.Middleware
115+
replayingMiddleware findResponse cassetteIORef app req respond = do
119116
cassette@Cassette {apiCalls, ignoredHeaders} <- readIORef cassetteIORef
120117
b <- Wai.strictRequestBody req
121118
let receivedRequest = buildRequest ignoredHeaders req b
@@ -209,7 +206,7 @@ getRequestBody req = do
209206
-- This implementation ensures that each chunk is only returned
210207
-- once.
211208
ichunks <- newIORef body
212-
let rbody = atomicModifyIORef ichunks $ \chunks ->
209+
let rbody = atomicModifyIORef' ichunks $ \chunks ->
213210
case chunks of
214211
[] -> ([], BS.empty)
215212
x : y -> (y, x)

src/Network/VCR/Types.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import Options.Applicative (
4343
short,
4444
showDefault,
4545
strOption,
46+
switch,
4647
value,
4748
)
4849

@@ -79,6 +80,7 @@ data Options = Options
7980
{ cassettePath :: FilePath
8081
, mode :: Mode
8182
, port :: Int
83+
, compression :: Bool
8284
}
8385
deriving (Eq, Show)
8486

@@ -91,6 +93,7 @@ parseOptions =
9193
<$> strOption (long "cassette" <> short 'c' <> metavar "CASSETTE_FILE" <> help "Cassette yaml file for recording/replaying the API interactions")
9294
<*> parseMode
9395
<*> option auto (long "port" <> help "Port to listen on" <> showDefault <> value DEFAULT_PORT <> metavar "INT")
96+
<*> switch (long "compression" <> help "Should cassette yaml be (de)compressed")
9497

9598
data Body = JSONBody Value | RawBody B.ByteString
9699
deriving (Show, Eq)

vcr-proxy.cabal

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ source-repository head
2626
library
2727
exposed-modules:
2828
Network.VCR
29+
Network.VCR.Compression
2930
Network.VCR.Middleware
3031
Network.VCR.Types
3132
other-modules:
@@ -56,6 +57,7 @@ library
5657
, warp
5758
, yaml
5859
, zlib
60+
, zstd
5961
default-language: Haskell2010
6062

6163
executable vcr-proxy
@@ -90,6 +92,7 @@ executable vcr-proxy
9092
, warp
9193
, yaml
9294
, zlib
95+
, zstd
9396
default-language: Haskell2010
9497

9598
test-suite vcr-proxy-test
@@ -125,4 +128,5 @@ test-suite vcr-proxy-test
125128
, warp
126129
, yaml
127130
, zlib
131+
, zstd
128132
default-language: Haskell2010

0 commit comments

Comments
 (0)