diff --git a/gen/src/Gen/AST/Data/Syntax.hs b/gen/src/Gen/AST/Data/Syntax.hs index ab61094ab1c..6ccf98461b5 100644 --- a/gen/src/Gen/AST/Data/Syntax.hs +++ b/gen/src/Gen/AST/Data/Syntax.hs @@ -366,10 +366,14 @@ requestD c m h (a, as) (b, bs) = "Core.AWSRequest" (identifier a) $ Just - [ assocD (identifier a) "AWSResponse" (typeId (identifier b)), - funArgsD "request" ["overrides"] (requestF c m h a as), - funD "response" (responseE (m ^. protocol) b bs) - ] + ( [assocD (identifier a) "AWSResponse" (typeId (identifier b))] + ++ [ funArgsD "evaluateResponse" ["_"] (var "Prelude.rnf") + | not (any fieldStream (notLocated bs)) + ] + ++ [ funArgsD "request" ["overrides"] (requestF c m h a as), + funD "response" (responseE (m ^. protocol) b bs) + ] + ) responseE :: Protocol -> Ref -> [Field] -> Exp responseE p r fs = Exts.app (responseF p r fs) bdy diff --git a/lib/amazonka-core/src/Amazonka/Types.hs b/lib/amazonka-core/src/Amazonka/Types.hs index be0b3a63b43..6bb639793f5 100644 --- a/lib/amazonka-core/src/Amazonka/Types.hs +++ b/lib/amazonka-core/src/Amazonka/Types.hs @@ -662,6 +662,18 @@ class (Typeable a, Typeable (AWSResponse a)) => AWSRequest a where -- | The successful, expected response associated with a request. type AWSResponse a :: Type + -- | Evaluate a successful response before returning it from a send operation + -- or passing it to a waiter acceptor. + -- + -- The default evaluates only the outer constructor, which preserves + -- streaming responses. Generated non-streaming requests override this + -- with 'rnf'. + -- + -- Evaluation happens after response hooks. Any exception raised by this + -- method is propagated through IO and is not converted to 'Error'. + evaluateResponse :: a -> AWSResponse a -> () + evaluateResponse _ result = result `seq` () + request :: -- | Overrides applied to the default 'Service'. (Service -> Service) -> @@ -952,7 +964,6 @@ pattern TelAviv = Region' "il-central-1" pattern MexicoCentral :: Region pattern MexicoCentral = Region' "mx-central-1" - -- Middle East pattern Bahrain :: Region diff --git a/lib/amazonka-s3-encryption/amazonka-s3-encryption.cabal b/lib/amazonka-s3-encryption/amazonka-s3-encryption.cabal index 2c5a6eb8aa2..95e1915394e 100644 --- a/lib/amazonka-s3-encryption/amazonka-s3-encryption.cabal +++ b/lib/amazonka-s3-encryption/amazonka-s3-encryption.cabal @@ -120,7 +120,9 @@ test-suite amazonka-s3-encryption-test -- This section is encoded by the template and any modules added by -- hand outside these namespaces will not correctly be added to the -- distribution package. - other-modules: Test.Amazonka.S3.Encryption.Envelope + other-modules: + Test.Amazonka.S3.Encryption.Encrypt + Test.Amazonka.S3.Encryption.Envelope build-depends: , amazonka-core , amazonka-s3-encryption diff --git a/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Encrypt.hs b/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Encrypt.hs index deface3d72b..6cb08c4c92b 100644 --- a/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Encrypt.hs +++ b/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Encrypt.hs @@ -61,6 +61,8 @@ envelope = _encEnvelope instance (AWSRequest a) => AWSRequest (Encrypted a) where type AWSResponse (Encrypted a) = AWSResponse a + evaluateResponse (Encrypted x _ _ _) = evaluateResponse x + request overrides (Encrypted x xs l e) = coerce (request overrides x) & updateBodyAndHeaders where diff --git a/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Instructions.hs b/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Instructions.hs index 526cc382008..9d9216720b7 100644 --- a/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Instructions.hs +++ b/lib/amazonka-s3-encryption/src/Amazonka/S3/Encryption/Instructions.hs @@ -65,6 +65,8 @@ piExtension = Lens.lens _piExt (\s a -> s {_piExt = a}) instance AWSRequest PutInstructions where type AWSResponse PutInstructions = S3.PutObjectResponse + evaluateResponse _ = rnf + request overrides x = coerce . request overrides $ _piPut x & S3.putObject_key %~ appendExtension (_piExt x) @@ -130,6 +132,8 @@ diExtension = Lens.lens _diExt (\s a -> s {_diExt = a}) instance AWSRequest DeleteInstructions where type AWSResponse DeleteInstructions = S3.DeleteObjectResponse + evaluateResponse _ = rnf + request overrides x = coerce . request overrides $ _diDelete x & S3.deleteObject_key %~ appendExtension (_diExt x) diff --git a/lib/amazonka-s3-encryption/test/Main.hs b/lib/amazonka-s3-encryption/test/Main.hs index 388a3d49f3d..e940c2ae84f 100644 --- a/lib/amazonka-s3-encryption/test/Main.hs +++ b/lib/amazonka-s3-encryption/test/Main.hs @@ -2,6 +2,7 @@ module Main (main) where +import Test.Amazonka.S3.Encryption.Encrypt import Test.Amazonka.S3.Encryption.Envelope import Test.Tasty @@ -10,5 +11,6 @@ main = defaultMain $ testGroup "S3-encryption" - [ testGroup "envelope" envelopeTests + [ testGroup "envelope" envelopeTests, + encryptTests ] diff --git a/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Encrypt.hs b/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Encrypt.hs new file mode 100644 index 00000000000..b25f0e40c67 --- /dev/null +++ b/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Encrypt.hs @@ -0,0 +1,43 @@ +{-# LANGUAGE TypeFamilies #-} + +module Test.Amazonka.S3.Encryption.Encrypt (encryptTests) where + +import Amazonka.Core hiding (error) +import Amazonka.S3.Encryption.Encrypt +import Amazonka.S3.Encryption.Types +import Control.Exception (ErrorCall (..), evaluate, try) +import Test.Amazonka.S3.Encryption.Envelope (mkTestAESV2Envelope) +import Test.Tasty (TestTree, testGroup) +import Test.Tasty.HUnit (assertEqual, assertFailure, testCase) +import Prelude + +encryptTests :: TestTree +encryptTests = + testGroup + "encrypt" + [testCase "preserves response evaluation policy" testResponseEvaluation] + +data EvaluationProbe = EvaluationProbe + +newtype EvaluationResponse = EvaluationResponse Int + +instance AWSRequest EvaluationProbe where + type AWSResponse EvaluationProbe = EvaluationResponse + + evaluateResponse _ (EvaluationResponse value) = value `seq` () + + request = error "request is not used by this test" + + response = error "response is not used by this test" + +testResponseEvaluation :: IO () +testResponseEvaluation = do + let encryptedRequest = Encrypted EvaluationProbe [] Discard mkTestAESV2Envelope + evaluationResponse = EvaluationResponse (error "encrypted response was forced") + result <- + try (evaluate (evaluateResponse encryptedRequest evaluationResponse)) :: + IO (Either ErrorCall ()) + case result of + Left (ErrorCall exceptionMessage) -> + assertEqual "unexpected evaluation exception" "encrypted response was forced" exceptionMessage + Right () -> assertFailure "expected the wrapped response policy to be used" diff --git a/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Envelope.hs b/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Envelope.hs index 44a0f52ac7b..83460dccdf1 100644 --- a/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Envelope.hs +++ b/lib/amazonka-s3-encryption/test/Test/Amazonka/S3/Encryption/Envelope.hs @@ -2,7 +2,11 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -module Test.Amazonka.S3.Encryption.Envelope (envelopeTests) where +module Test.Amazonka.S3.Encryption.Envelope + ( envelopeTests, + mkTestAESV2Envelope, + ) +where import Amazonka.Core import Amazonka.S3.Encryption.Envelope diff --git a/lib/amazonka/CHANGELOG.md b/lib/amazonka/CHANGELOG.md index 911ea234bae..6a01d226be4 100644 --- a/lib/amazonka/CHANGELOG.md +++ b/lib/amazonka/CHANGELOG.md @@ -99,6 +99,10 @@ ### Fixed +- `amazonka`: Fully evaluate successful non-streaming service responses before + returning them from send operations or passing them to waiter acceptors. + Streaming response bodies remain lazy. + [\#1050](https://github.com/brendanhay/amazonka/pull/1050) - `amazonka`: `Amazonka.Auth.SSO.relativeCachedTokenFile` is now pure [\#1056](https://github.com/brendanhay/amazonka/pull/1056) - `amazonka-core`: `containers ^>= 0.7` is now supported. `containers-0.7` is shipped with GHC 9.10 and 9.12. diff --git a/lib/amazonka/amazonka.cabal b/lib/amazonka/amazonka.cabal index c86308fda95..38d87eb886b 100644 --- a/lib/amazonka/amazonka.cabal +++ b/lib/amazonka/amazonka.cabal @@ -122,16 +122,24 @@ library , uuid >=1.2.6 && <1.4 test-suite tests - import: base - type: exitcode-stdio-1.0 - hs-source-dirs: test - main-is: Main.hs - other-modules: Test.Amazonka.Auth.Background + import: base + type: exitcode-stdio-1.0 + hs-source-dirs: test + main-is: Main.hs + other-modules: + Test.Amazonka.Auth.Background + Test.Amazonka.Send build-depends: , amazonka , amazonka-core + , amazonka-sts + , bytestring + , conduit + , deepseq + , network + , resourcet + , tasty >=0.10 + , tasty-hunit >=0.9 + , time , http-client , http-types - , tasty >=0.10 - , tasty-hunit >=0.9 - , time diff --git a/lib/amazonka/src/Amazonka/HTTP.hs b/lib/amazonka/src/Amazonka/HTTP.hs index 8640ff0f6fa..a5ccff4d937 100644 --- a/lib/amazonka/src/Amazonka/HTTP.hs +++ b/lib/amazonka/src/Amazonka/HTTP.hs @@ -28,6 +28,7 @@ import Control.Monad.Trans.Resource (liftResourceT, transResourceT) import qualified Control.Retry as Retry import Data.Foldable (traverse_) import qualified Data.Time as Time +import qualified Network.HTTP.Client as Client import qualified Network.HTTP.Conduit as Client.Conduit retryRequest :: @@ -67,7 +68,7 @@ retryRequest env@Env {hooks} rq = do Retry.retrying policy shouldRetry attempt >>= \case Left e -> Left e <$ liftIO (Hooks.error hooks env (Final, cfgRq, e)) - Right a -> pure $ Right a + Right a -> Right a <$ evaluateSuccessfulResponse rq' a awaitRequest :: ( MonadResource m, @@ -84,7 +85,10 @@ awaitRequest env@Env {hooks} w rq = do w'@Wait {..} <- liftIO $ Hooks.wait hooks env w let handleResult res = (fromMaybe AcceptRetry $ accept w' cfgRq res, res) - attempt _ = handleResult <$> httpRequest env cfgRq + attempt _ = do + result <- httpRequest env cfgRq + traverse_ (evaluateSuccessfulResponse rq') result + pure (handleResult result) policy = Retry.limitRetries attempts <> Retry.constantDelay (toMicroseconds delay) @@ -147,6 +151,17 @@ httpRequest env@Env {hooks, manager, region} cfgRq = proxy :: Request a -> Proxy a proxy _ = Proxy +evaluateSuccessfulResponse :: + (MonadIO m, AWSRequest a) => + a -> + ClientResponse (AWSResponse a) -> + m () +evaluateSuccessfulResponse rq = + liftIO + . Exception.evaluate + . evaluateResponse rq + . Client.responseBody + -- Configures an AWS request `a` into its `Request a` form, applying -- service overrides from `env` and running hooks on the configured -- (Request a). diff --git a/lib/amazonka/src/Amazonka/Send.hs b/lib/amazonka/src/Amazonka/Send.hs index ede5e89f89b..d329b929567 100644 --- a/lib/amazonka/src/Amazonka/Send.hs +++ b/lib/amazonka/src/Amazonka/Send.hs @@ -29,8 +29,8 @@ sendEither :: Env -> a -> m (Either Error (AWSResponse a)) -sendEither env = - fmap (second Client.responseBody) . HTTP.retryRequest env +sendEither env rq = + fmap Client.responseBody <$> HTTP.retryRequest env rq -- | Send a request, returning the associated response if successful. -- @@ -59,8 +59,8 @@ sendUnsignedEither :: Env' withAuth -> a -> m (Either Error (AWSResponse a)) -sendUnsignedEither env = - fmap (second Client.responseBody) . HTTP.retryRequest (env {auth = Proxy}) +sendUnsignedEither env rq = + fmap Client.responseBody <$> HTTP.retryRequest (env {auth = Proxy}) rq -- | Make an unsigned request, returning the associated response if successful. -- diff --git a/lib/amazonka/test/Main.hs b/lib/amazonka/test/Main.hs index 644bcb3f50e..71fae18621c 100644 --- a/lib/amazonka/test/Main.hs +++ b/lib/amazonka/test/Main.hs @@ -1,8 +1,9 @@ module Main (main) where import qualified Test.Amazonka.Auth.Background as AuthBackground +import qualified Test.Amazonka.Send as Send import Test.Tasty (defaultMain, testGroup) import Prelude main :: IO () -main = defaultMain (testGroup "amazonka" [AuthBackground.tests]) +main = defaultMain (testGroup "amazonka" [AuthBackground.tests, Send.tests]) diff --git a/lib/amazonka/test/Test/Amazonka/Send.hs b/lib/amazonka/test/Test/Amazonka/Send.hs new file mode 100644 index 00000000000..274756b8488 --- /dev/null +++ b/lib/amazonka/test/Test/Amazonka/Send.hs @@ -0,0 +1,498 @@ +-- | +-- Module : Test.Amazonka.Send +-- Copyright : (c) 2013-2023 Brendan Hay +-- License : Mozilla Public License, v. 2.0. +-- Maintainer : Brendan Hay +-- Stability : provisional +-- Portability : non-portable (GHC extensions) +module Test.Amazonka.Send (tests) where + +import Amazonka hiding (accept, error, runResourceT) +import qualified Amazonka.Auth as Auth +import qualified Amazonka.Data as Data +import qualified Amazonka.Env.Hooks as Hooks +import qualified Amazonka.Request as Request +import qualified Amazonka.Response as Response +import qualified Amazonka.STS as STS +import qualified Amazonka.Waiter as Waiter +import Control.Concurrent (ThreadId, forkFinally, forkIO, killThread) +import Control.DeepSeq (NFData (..)) +import Control.Exception (ErrorCall (..), SomeException, bracket, displayException, try) +import Control.Monad (void) +import Control.Monad.Trans.Resource (runResourceT) +import qualified Data.ByteString as ByteString +import qualified Data.ByteString.Char8 as ByteString.Char8 +import qualified Data.Conduit as Conduit +import qualified Data.Conduit.List as Conduit.List +import Data.Foldable (for_) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import qualified Network.HTTP.Client as Client +import Network.Socket + ( Family (AF_INET), + SockAddr (SockAddrInet), + Socket, + SocketOption (ReuseAddr), + SocketType (Stream), + accept, + bind, + close, + defaultProtocol, + getSocketName, + listen, + setSocketOption, + socket, + tupleToHostAddress, + withSocketsDo, + ) +import qualified Network.Socket.ByteString as Socket +import Test.Tasty (TestTree, testGroup) +import Test.Tasty.HUnit (assertEqual, assertFailure, testCase) +import Prelude + +data DeepProbe = DeepProbe + +instance Data.ToPath DeepProbe where + toPath _ = "/" + +instance Data.ToQuery DeepProbe where + toQuery _ = mempty + +instance Data.ToHeaders DeepProbe where + toHeaders _ = mempty + +data DeepResponse = DeepResponse () LazyPayload + +instance NFData DeepResponse where + rnf (DeepResponse metadata payload) = rnf metadata `seq` rnf payload + +newtype LazyPayload = LazyPayload Int + +instance NFData LazyPayload where + rnf (LazyPayload value) = rnf value + +instance AWSRequest DeepProbe where + type AWSResponse DeepProbe = DeepResponse + + evaluateResponse _ = rnf + + request overrides = Request.get (overrides STS.defaultService) + + response = + Response.receiveBytes $ \_ _ _ -> + Right (DeepResponse () (LazyPayload (error "deep response was forced"))) + +instance AWSPager DeepProbe where + page _ _ = Nothing + +data ShallowProbe = ShallowProbe + +newtype ShallowResponse = ShallowResponse (IO ()) + +instance Data.ToPath ShallowProbe where + toPath _ = "/" + +instance Data.ToQuery ShallowProbe where + toQuery _ = mempty + +instance Data.ToHeaders ShallowProbe where + toHeaders _ = mempty + +instance AWSRequest ShallowProbe where + type AWSResponse ShallowProbe = ShallowResponse + + request overrides = Request.get (overrides STS.defaultService) + + response = + Response.receiveBytes $ \_ _ _ -> + Right (ShallowResponse (pure ())) + +data FailureProbe = FailureProbe + +instance Data.ToPath FailureProbe where + toPath _ = "/" + +instance Data.ToQuery FailureProbe where + toQuery _ = mempty + +instance Data.ToHeaders FailureProbe where + toHeaders _ = mempty + +instance AWSRequest FailureProbe where + type AWSResponse FailureProbe = () + + evaluateResponse _ _ = error "failed response was evaluated" + + request overrides = Request.get (overrides STS.defaultService) + + response = + Response.receiveBytes $ \_ _ _ -> + Left "expected probe parse failure" + +newtype HookProbe = HookProbe + { forceHookResponse :: Bool + } + +instance Data.ToPath HookProbe where + toPath _ = "/" + +instance Data.ToQuery HookProbe where + toQuery _ = mempty + +instance Data.ToHeaders HookProbe where + toHeaders _ = mempty + +instance AWSRequest HookProbe where + type AWSResponse HookProbe = DeepResponse + + evaluateResponse HookProbe {forceHookResponse} result + | forceHookResponse = rnf result + | otherwise = result `seq` () + + request overrides = Request.get (overrides STS.defaultService) + + response = + Response.receiveBytes $ \_ _ _ -> + Right (DeepResponse () (LazyPayload (error "deep response was forced"))) + +data StreamingProbe = StreamingProbe + +newtype StreamingResponse = StreamingResponse ResponseBody + +instance Data.ToPath StreamingProbe where + toPath _ = "/" + +instance Data.ToQuery StreamingProbe where + toQuery _ = mempty + +instance Data.ToHeaders StreamingProbe where + toHeaders _ = mempty + +instance AWSRequest StreamingProbe where + type AWSResponse StreamingProbe = StreamingResponse + + request overrides = Request.get (overrides STS.defaultService) + + response = + Response.receiveBody $ \_ _ body -> + Right (StreamingResponse body) + +data WaiterProbe = WaiterProbe + +data WaiterResponse = WaiterResponse Bool LazyPayload + +instance NFData WaiterResponse where + rnf (WaiterResponse retry payload) = rnf retry `seq` rnf payload + +instance Data.ToPath WaiterProbe where + toPath _ = "/" + +instance Data.ToQuery WaiterProbe where + toQuery _ = mempty + +instance Data.ToHeaders WaiterProbe where + toHeaders _ = mempty + +instance AWSRequest WaiterProbe where + type AWSResponse WaiterProbe = WaiterResponse + + evaluateResponse _ = rnf + + request overrides = Request.get (overrides STS.defaultService) + + response = + Response.receiveBytes $ \_ _ body -> + Right $ + if body == "retry" + then WaiterResponse True (LazyPayload 0) + else WaiterResponse False (LazyPayload (error "deep response was forced")) + +tests :: TestTree +tests = + testGroup + "Send response evaluation" + [ testCase "sendUnsignedEither evaluates the selected response policy" $ + withTestServer $ \port -> do + assertDeepResponseForced $ + withEnv port $ \env -> + runResourceT $ void (sendUnsignedEither env DeepProbe), + testCase "sendEither evaluates the selected response policy" $ + withTestServer $ \port -> do + assertDeepResponseForced $ + withSignedEnv port $ \env -> + runResourceT $ void (sendEither env DeepProbe), + testCase "sendEither uses the request returned by request hooks" $ + withTestServer $ \port -> do + assertDeepResponseForced $ + withSignedEnv port $ \env -> + runResourceT $ + void + ( sendEither + (withHookProbeForcing env) + HookProbe {forceHookResponse = False} + ), + testCase "response hooks run before response evaluation" $ + withTestServer $ \port -> do + hookRan <- newIORef False + assertDeepResponseForced $ + withSignedEnv port $ \env -> + runResourceT $ + void (sendEither (withResponseObservation hookRan env) DeepProbe) + readIORef hookRan >>= assertEqual "response hook did not run" True, + testCase "paginateEither evaluates responses before yielding pages" $ + withTestServer $ \port -> do + assertDeepResponseForced $ + withSignedEnv port $ \env -> + void . runResourceT $ + Conduit.runConduit + ( void (paginateEither env DeepProbe) + Conduit..| Conduit.awaitForever (const (pure ())) + ), + testCase "retryRequest evaluates the eventual successful response" + $ withTestServerResponses + [ serverResponse + "500 Internal Server Error" + "InternalFailureretryrequest-id", + successfulResponse + ] + $ \port -> do + assertDeepResponseForced $ + withRetryingEnv port $ \env -> + runResourceT $ void (sendEither env DeepProbe), + testCase "awaitEither evaluates successful responses before acceptors" $ + withTestServer $ \port -> do + assertDeepResponseForced $ + withSignedEnv port $ \env -> + runResourceT $ void (awaitEither env successfulWait DeepProbe), + testCase "awaitEither uses the request returned by request hooks" $ + withTestServer $ \port -> do + assertDeepResponseForced $ + withSignedEnv port $ \env -> + runResourceT $ + void + ( awaitEither + (withHookProbeForcing env) + hookWait + HookProbe {forceHookResponse = False} + ), + testCase "awaitEither evaluates every successful retry response" + $ withTestServerResponses + [serverResponse "200 OK" "retry", serverResponse "200 OK" "complete"] + $ \port -> do + assertDeepResponseForced $ + withSignedEnv port $ \env -> + runResourceT $ void (awaitEither env retryWait WaiterProbe), + testCase "failed sends do not evaluate the response policy" $ + withTestServer $ \port -> do + result <- withEnv port $ \env -> + runResourceT $ sendUnsignedEither env FailureProbe + case result of + Left _ -> pure () + Right () -> assertFailure "expected response parsing to fail", + testCase "failed waiter attempts do not evaluate the response policy" $ + withTestServer $ \port -> do + result <- withSignedEnv port $ \env -> + runResourceT $ awaitEither env failedWait FailureProbe + case result of + Right Waiter.AcceptSuccess -> pure () + Right waiterResult -> + assertFailure $ + "expected waiter success, got " + <> show waiterResult + Left exception -> + assertFailure $ + "expected the waiter acceptor to handle the failed response: " + <> displayException exception, + testCase "the default response policy does not require NFData" $ + withTestServer $ \port -> do + result <- try @SomeException $ withEnv port $ \env -> + runResourceT $ void (sendUnsignedEither env ShallowProbe) + case result of + Left exception -> + assertFailure $ + "expected the default response policy to succeed: " + <> displayException exception + Right () -> pure (), + testCase "the default response policy preserves streaming responses" $ + withTestServer $ \port -> do + result <- try @SomeException $ withEnv port $ \env -> runResourceT $ do + sendUnsignedEither env StreamingProbe >>= \case + Left exception -> pure (Left exception) + Right (StreamingResponse body) -> + Right . ByteString.concat <$> sinkBody body Conduit.List.consume + case result of + Left exception -> + assertFailure $ + "expected the streaming response policy to succeed: " + <> displayException exception + Right (Left exception) -> + assertFailure $ + "expected the streaming request to succeed: " + <> displayException exception + Right (Right body) -> + assertEqual "unexpected streaming response body" "ok" body + ] + +successfulWait :: Waiter.Wait DeepProbe +successfulWait = + Waiter.Wait + { Waiter.name = "deep-probe", + Waiter.attempts = 1, + Waiter.delay = 0, + Waiter.acceptors = [\_ _ -> Just Waiter.AcceptSuccess] + } + +hookWait :: Waiter.Wait HookProbe +hookWait = + Waiter.Wait + { Waiter.name = "hook-probe", + Waiter.attempts = 1, + Waiter.delay = 0, + Waiter.acceptors = [\_ _ -> Just Waiter.AcceptSuccess] + } + +retryWait :: Waiter.Wait WaiterProbe +retryWait = + Waiter.Wait + { Waiter.name = "retry-probe", + Waiter.attempts = 2, + Waiter.delay = 0, + Waiter.acceptors = + [ \_ -> \case + Right clientResponse -> + case Client.responseBody clientResponse of + WaiterResponse True _ -> Just Waiter.AcceptRetry + WaiterResponse False _ -> Just Waiter.AcceptSuccess + Left _ -> Just Waiter.AcceptFailure + ] + } + +failedWait :: Waiter.Wait FailureProbe +failedWait = + Waiter.Wait + { Waiter.name = "failed-probe", + Waiter.attempts = 1, + Waiter.delay = 0, + Waiter.acceptors = + [ \_ -> \case + Left _ -> Just Waiter.AcceptSuccess + Right _ -> Nothing + ] + } + +assertDeepResponseForced :: IO () -> IO () +assertDeepResponseForced action = do + result <- try @ErrorCall action + case result of + Left (ErrorCall message) -> + assertEqual "unexpected evaluation exception" "deep response was forced" message + Right () -> assertFailure "expected deep response evaluation to throw" + +withEnv :: Int -> (EnvNoAuth -> IO a) -> IO a +withEnv = withEnvUsing once + +withRetryingEnv :: Int -> (Env -> IO a) -> IO a +withRetryingEnv port action = + withEnvUsing id port $ + action + . Auth.fromKeys + (AccessKey "test-access-key") + (SecretKey "test-secret-key") + +withEnvUsing :: (EnvNoAuth -> EnvNoAuth) -> Int -> (EnvNoAuth -> IO a) -> IO a +withEnvUsing configure port action = do + manager <- Client.newManager Client.defaultManagerSettings + env <- newEnvNoAuthFromManager manager + let service = setEndpoint False "127.0.0.1" port STS.defaultService + action (configure (configureService service env)) + +withSignedEnv :: Int -> (Env -> IO a) -> IO a +withSignedEnv port action = + withEnv port $ + action + . Auth.fromKeys + (AccessKey "test-access-key") + (SecretKey "test-secret-key") + +withHookProbeForcing :: Env -> Env +withHookProbeForcing env = + env + { hooks = + Hooks.requestHook + ( Hooks.addRequestHookFor @HookProbe $ \_ hookProbe -> + pure hookProbe {forceHookResponse = True} + ) + (hooks env) + } + +withResponseObservation :: IORef Bool -> Env -> Env +withResponseObservation observed env = + env + { hooks = + Hooks.responseHook + ( Hooks.addResponseHookFor @DeepProbe $ \_ _ -> + writeIORef observed True + ) + (hooks env) + } + +data TestServer = TestServer + { serverSocket :: Socket, + serverThread :: ThreadId, + serverPort :: Int + } + +withTestServer :: (Int -> IO a) -> IO a +withTestServer = withTestServerResponses [successfulResponse] + +withTestServerResponses :: [ByteString.ByteString] -> (Int -> IO a) -> IO a +withTestServerResponses responses action = + withSocketsDo $ + bracket (startServer responses) stopServer (action . serverPort) + +startServer :: [ByteString.ByteString] -> IO TestServer +startServer responses = do + sock <- socket AF_INET Stream defaultProtocol + setSocketOption sock ReuseAddr 1 + bind sock (SockAddrInet 0 (tupleToHostAddress (127, 0, 0, 1))) + listen sock (max 1 (Prelude.length responses)) + SockAddrInet port _ <- getSocketName sock + thread <- forkIO $ + for_ responses $ \serverReply -> do + (connection, _) <- accept sock + void . forkFinally (serve serverReply connection) $ const (close connection) + pure + TestServer + { serverSocket = sock, + serverThread = thread, + serverPort = fromIntegral port + } + +stopServer :: TestServer -> IO () +stopServer server = do + killThread (serverThread server) + close (serverSocket server) + +serve :: ByteString.ByteString -> Socket -> IO () +serve serverReply connection = do + receiveHeaders ByteString.empty + Socket.sendAll connection serverReply + where + receiveHeaders buffered + | "\r\n\r\n" `ByteString.isInfixOf` buffered = pure () + | otherwise = do + chunk <- Socket.recv connection 4096 + if ByteString.null chunk + then pure () + else receiveHeaders (buffered <> chunk) + +successfulResponse :: ByteString.ByteString +successfulResponse = serverResponse "200 OK" "ok" + +serverResponse :: ByteString.ByteString -> ByteString.ByteString -> ByteString.ByteString +serverResponse status body = + "HTTP/1.1 " + <> status + <> "\r\nContent-Length: " + <> ByteString.Char8.pack (show (ByteString.length body)) + <> "\r\nConnection: close\r\n\r\n" + <> body