packages feed

servant-tower-hs-0.1.0.0: test/Servant/Tower/IntegrationSpec.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}

module Servant.Tower.IntegrationSpec (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (newTVarIO, readTVar, TVar, modifyTVar', atomically)
import Control.Monad.IO.Class (liftIO)
import Data.IORef
import Data.Proxy (Proxy(..))
import Data.Text (Text, isInfixOf)
import Network.HTTP.Client (newManager, defaultManagerSettings)
import Network.Wai.Handler.Warp (testWithApplication)
import Servant
import Servant.Client
import Test.Hspec

import Servant.Tower.Adapter (withTowerMiddleware)
import qualified Servant.Tower.Middleware.Logging as STL
import qualified Servant.Tower.Middleware.SetHeader as STS
import qualified Servant.Tower.Middleware.Validate as STV
import Tower.Middleware.CircuitBreaker
import Tower.Middleware.Filter
import Tower.Middleware.Hedge
import qualified Tower.Middleware.Logging as TL
import qualified Tower.Middleware.Tracing
import Tower.Middleware.Retry
import Tower.Middleware.Timeout

import OpenTelemetry.Attributes (emptyAttributes)
import OpenTelemetry.Trace.Core (InstrumentationLibrary(..))

-- ---------------------------------------------------------------------------
-- Test API
-- ---------------------------------------------------------------------------

type TestAPI =
       "hello"   :> Get '[JSON] String
  :<|> "slow"    :> Get '[JSON] String
  :<|> "flaky"   :> Get '[JSON] String
  :<|> "fail500" :> Get '[JSON] String

testServer :: TVar Int -> Server TestAPI
testServer callCount =
       helloHandler
  :<|> slowHandler
  :<|> flakyHandler callCount
  :<|> fail500Handler

helloHandler :: Handler String
helloHandler = pure "hello"

slowHandler :: Handler String
slowHandler = do
  liftIO $ threadDelay 2_000_000 -- 2 seconds
  pure "slow"

flakyHandler :: TVar Int -> Handler String
flakyHandler callCount = do
  n <- liftIO $ atomically $ do
    modifyTVar' callCount (+ 1)
    readTVar callCount
  if n <= 2
    then throwError err500 { errBody = "flaky failure" }
    else pure "recovered"

fail500Handler :: Handler String
fail500Handler = throwError err500 { errBody = "always fails" }

testApp :: TVar Int -> Application
testApp callCount = serve (Proxy :: Proxy TestAPI) (testServer callCount)

-- ---------------------------------------------------------------------------
-- Client functions
-- ---------------------------------------------------------------------------

helloClient :: ClientM String
slowClient :: ClientM String
flakyClient :: ClientM String
fail500Client :: ClientM String
helloClient :<|> slowClient :<|> flakyClient :<|> fail500Client =
  client (Proxy :: Proxy TestAPI)

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

withTestServer :: (Int -> IO a) -> IO a
withTestServer action = do
  callCount <- newTVarIO 0
  testWithApplication (pure (testApp callCount)) action

runWithMiddleware :: Int -> ClientM a -> (ClientEnv -> ClientEnv) -> IO (Either ClientError a)
runWithMiddleware port action applyMw = do
  manager <- newManager defaultManagerSettings
  baseUrl' <- parseBaseUrl $ "http://localhost:" ++ show port
  let env = applyMw (mkClientEnv manager baseUrl')
  runClientM action env

runPlain :: Int -> ClientM a -> IO (Either ClientError a)
runPlain port action = runWithMiddleware port action id

-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------

spec :: Spec
spec = describe "Servant.Tower integration" $ around withTestServer $ do

  -- Baseline: middleware adapter does not break normal requests
  describe "baseline (no middleware)" $ do
    it "calls a simple endpoint" $ \port -> do
      result <- runPlain port helloClient
      result `shouldBe` Right "hello"

  -- Retry middleware
  describe "withRetry" $ do
    it "retries flaky endpoint and eventually succeeds" $ \port -> do
      result <- runWithMiddleware port flakyClient $
        withTowerMiddleware (withRetry (constantBackoff 3 0))
      result `shouldBe` Right "recovered"

    it "exhausts retries on permanently failing endpoint" $ \port -> do
      result <- runWithMiddleware port fail500Client $
        withTowerMiddleware (withRetry (constantBackoff 2 0))
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "Expected failure after retry exhaustion"

  -- Timeout middleware
  describe "withTimeout" $ do
    it "passes fast requests through" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (withTimeout 5000)
      result `shouldBe` Right "hello"

    it "times out slow requests" $ \port -> do
      result <- runWithMiddleware port slowClient $
        withTowerMiddleware (withTimeout 500) -- 500ms, server takes 2s
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "Expected timeout error"

  -- Circuit breaker middleware
  describe "withCircuitBreaker" $ do
    it "trips open after repeated failures and rejects fast" $ \port -> do
      breaker <- newCircuitBreaker
      let config = CircuitBreakerConfig { cbFailureThreshold = 2, cbCooldownPeriod = 10 }
          mw = withTowerMiddleware (withCircuitBreaker config breaker)

      -- Two failures trip the breaker
      _ <- runWithMiddleware port fail500Client mw
      _ <- runWithMiddleware port fail500Client mw
      getCircuitBreakerState breaker >>= (`shouldBe` Open)

      -- Third call should be rejected immediately by circuit breaker
      result <- runWithMiddleware port fail500Client mw
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "Expected circuit breaker rejection"

  -- Composed middleware stack
  describe "composed middleware" $ do
    it "retry + timeout works together on fast endpoint" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware
          ( withRetry (constantBackoff 2 0)
          . withTimeout 5000
          )
      result `shouldBe` Right "hello"

    it "timeout fires before retry can succeed on slow endpoint" $ \port -> do
      result <- runWithMiddleware port slowClient $
        withTowerMiddleware
          ( withRetry (constantBackoff 2 0)
          . withTimeout 500
          )
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "Expected timeout through retry"

    it "retry + circuit breaker compose correctly" $ \port -> do
      breaker <- newCircuitBreaker
      let config = CircuitBreakerConfig { cbFailureThreshold = 5, cbCooldownPeriod = 10 }
      result <- runWithMiddleware port flakyClient $
        withTowerMiddleware
          ( withRetry (constantBackoff 3 0)
          . withCircuitBreaker config breaker
          )
      result `shouldBe` Right "recovered"
      -- Breaker should be closed since it recovered
      getCircuitBreakerState breaker >>= (`shouldBe` Closed)

  -- Filter middleware
  describe "withFilter" $ do
    it "passes requests that match the predicate" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (withFilter (const True))
      result `shouldBe` Right "hello"

    it "rejects requests that don't match the predicate" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (withFilter (const False))
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "Expected filter rejection"

  -- Hedge middleware
  describe "withHedge" $ do
    it "returns result for fast endpoint" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (withHedge 500)
      result `shouldBe` Right "hello"

  -- Full middleware stack: generic + servant-specific combined
  describe "full middleware stack" $ do
    it "composes generic tower-hs and servant-specific middleware together" $ \port -> do
      logRef <- newIORef ([] :: [Text])
      breaker <- newCircuitBreaker
      let config = CircuitBreakerConfig { cbFailureThreshold = 10, cbCooldownPeriod = 30 }
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware
          ( -- Generic tower-hs middleware
            withRetry (exponentialBackoff 2 0.1 2.0)
          . withTimeout 5000
          . withCircuitBreaker config breaker
          -- Servant-specific middleware
          . STS.withBearerAuth "my-token"
          . STS.withUserAgent "test-agent/1.0"
          . STS.withHeader "X-Custom" "value"
          . STV.withValidateStatus (\c -> c >= 200 && c < 300)
          . STL.withLogging (\msg -> modifyIORef' logRef (msg :))
          )
      result `shouldBe` Right "hello"
      getCircuitBreakerState breaker >>= (`shouldBe` Closed)
      -- Verify logging happened
      logs <- readIORef logRef
      length logs `shouldBe` 1

  -- -----------------------------------------------------------------------
  -- Servant-specific middleware
  -- -----------------------------------------------------------------------

  -- SetHeader middleware
  describe "Servant.Tower.Middleware.SetHeader" $ do
    it "adds headers without breaking requests" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware
          ( STS.withBearerAuth "my-token"
          . STS.withUserAgent "test-agent/1.0"
          . STS.withHeader "X-Custom" "value"
          )
      result `shouldBe` Right "hello"

  -- Validate middleware
  describe "Servant.Tower.Middleware.Validate" $ do
    it "passes valid status codes" $ \port -> do
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (STV.withValidateStatus (\c -> c >= 200 && c < 300))
      result `shouldBe` Right "hello"

    it "rejects invalid status codes" $ \port -> do
      result <- runWithMiddleware port fail500Client $
        withTowerMiddleware (STV.withValidateStatus (\c -> c >= 200 && c < 300))
      case result of
        Left _ -> pure ()
        Right _ -> expectationFailure "Expected validation failure"

  -- Logging middleware
  describe "Servant.Tower.Middleware.Logging" $ do
    it "logs successful requests" $ \port -> do
      logRef <- newIORef ([] :: [Text])
      let logger msg = modifyIORef' logRef (msg :)
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (STL.withLogging logger)
      result `shouldBe` Right "hello"
      logs <- readIORef logRef
      length logs `shouldBe` 1
      isInfixOf "GET" (head logs) `shouldBe` True

    it "logs with generic formatter" $ \port -> do
      logRef <- newIORef ([] :: [Text])
      let logger msg = modifyIORef' logRef (msg :)
          formatter _ _ _ = "custom-log"
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware (TL.withLogging formatter logger)
      result `shouldBe` Right "hello"
      logs <- readIORef logRef
      head logs `shouldBe` "custom-log"

  -- Tracing middleware (no-op without SDK, but must be transparent)
  describe "Servant.Tower.Middleware.Tracing" $ do
    it "passes requests through transparently" $ \port -> do
      -- Import locally to avoid name clash
      result <- runWithMiddleware port helloClient $
        withTowerMiddleware
          (Tower.Middleware.Tracing.withTracingGlobal testLib (Tower.Middleware.Tracing.defaultTracingConfig "test"))
      result `shouldBe` Right "hello"

testLib :: InstrumentationLibrary
testLib = InstrumentationLibrary
  { libraryName = "servant-tower-hs-test"
  , libraryVersion = "0.0.0"
  , librarySchemaUrl = ""
  , libraryAttributes = emptyAttributes
  }