packages feed

pg-wire-0.1.0.0: mockserver/PgWire/MockServer.hs

-- | A minimal mock PostgreSQL server for testing.
--
-- Speaks enough of the PG v3 wire protocol to test client behavior
-- without a real database: authentication, simple queries, extended
-- query protocol, error responses, and connection lifecycle.
--
-- @
-- withMockServer defaultMockConfig $ \\port -> do
--   conn <- connectString (\"postgres:\/\/user:pass\@localhost:\" <> BS8.pack (show port) <> \"\/testdb\")
--   (rows, _) <- simpleQuery conn \"SELECT 1\"
--   close conn
-- @
module PgWire.MockServer
  ( -- * Server lifecycle
    withMockServer
  , MockConfig (..)
  , defaultMockConfig
    -- * Query handlers
  , QueryHandler
  , simpleHandler
  , errorHandler
  , failNTimes
    -- * Response builders
  , sendBackendMsg
  , buildBackendMsg
  , buildErrorResponse
  , buildCommandComplete
  , buildRowDescription
  , buildDataRow
  , buildReadyForQuery
  ) where

import Control.Concurrent (forkIO, killThread)
import Control.Exception (SomeException, catch, finally)
import Data.IORef
import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Builder (Builder)
import Data.ByteString.Builder qualified as B
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as LBS
import Data.Int (Int32)
import Data.Word (Word8)
import Network.Socket qualified as NS
import Network.Socket.ByteString qualified as NSB

-- | Configuration for the mock server.
data MockConfig = MockConfig
  { mockUser :: ByteString
  -- ^ Expected username. Default: @\"testuser\"@.
  , mockPassword :: ByteString
  -- ^ Expected password. Default: @\"testpass\"@.
  , mockDatabase :: ByteString
  -- ^ Expected database name. Default: @\"testdb\"@.
  , mockQueryHandler :: QueryHandler
  -- ^ Handler for simple queries. Default: returns empty result.
  }

-- | Handler called for each simple query. Receives the SQL text and
-- a function to send backend messages.
type QueryHandler = ByteString -> (Builder -> IO ()) -> IO ()

-- | Default config: accepts auth, returns empty results for all queries.
defaultMockConfig :: MockConfig
defaultMockConfig = MockConfig
  { mockUser = "testuser"
  , mockPassword = "testpass"
  , mockDatabase = "testdb"
  , mockQueryHandler = \_ send -> do
      send $ buildCommandComplete "SELECT 0"
  }

-- | A simple query handler that returns fixed rows for a specific query.
simpleHandler :: [(ByteString, [[(ByteString, ByteString)]])] -> QueryHandler
simpleHandler table sql send = case lookup sql table of
  Nothing -> send $ buildCommandComplete "SELECT 0"
  Just rows -> do
    case rows of
      [] -> send $ buildCommandComplete "SELECT 0"
      (first : _) -> do
        -- Send RowDescription
        let cols = map fst first
        send $ buildRowDescription cols
        -- Send DataRows
        mapM_ (\row -> send $ buildDataRow (map snd row)) rows
        send $ buildCommandComplete ("SELECT " <> BS8.pack (show (length rows)))

-- | A handler that returns a PG error with the given SQLSTATE and message.
--
-- @
-- errorHandler \"23505\" \"duplicate key value violates unique constraint \\\"users_email_key\\\"\"
-- @
errorHandler :: ByteString -> ByteString -> QueryHandler
errorHandler sqlstate msg _ send =
  send $ buildErrorResponse sqlstate msg

-- | A handler backed by an IORef counter. Returns an error for the first
-- @n@ calls, then delegates to the fallback handler. Useful for testing
-- retry logic.
--
-- @
-- ref <- newIORef 0
-- let handler = failNTimes ref 2 \"40001\" \"serialization failure\" defaultHandler
-- @
failNTimes :: IORef Int -> Int -> ByteString -> ByteString -> QueryHandler -> QueryHandler
failNTimes ref n sqlstate msg fallback sql send = do
  count <- atomicModifyIORef' ref (\c -> (c + 1, c))
  if count < n
    then send $ buildErrorResponse sqlstate msg
    else fallback sql send

-- | Start a mock server on a random port, run an action with the port,
-- then shut down.
withMockServer :: MockConfig -> (NS.PortNumber -> IO a) -> IO a
withMockServer cfg action = do
  -- Create a listening socket
  sock <- NS.socket NS.AF_INET NS.Stream NS.defaultProtocol
  NS.setSocketOption sock NS.ReuseAddr 1
  NS.bind sock (NS.SockAddrInet 0 (NS.tupleToHostAddress (127, 0, 0, 1)))
  NS.listen sock 5
  port <- NS.socketPort sock

  -- Start acceptor thread
  tid <- forkIO $ acceptLoop sock cfg

  -- Run the action, then clean up
  action port `finally` do
    killThread tid
    NS.close sock

-- | Accept loop: handle one connection at a time.
acceptLoop :: NS.Socket -> MockConfig -> IO ()
acceptLoop listenSock cfg = go
  where
    go = do
      (clientSock, _) <- NS.accept listenSock
      _ <- forkIO (handleClient clientSock cfg `catch` \(_ :: SomeException) -> pure ())
      go

-- | Handle a single client connection.
handleClient :: NS.Socket -> MockConfig -> IO ()
handleClient sock cfg = do
  -- Read startup message (no tag byte)
  startupBytes <- recvN sock 4
  let startupLen = decodeInt32BE startupBytes 0
  _payload <- recvN sock (fromIntegral startupLen - 4)

  -- Send AuthOk (cleartext auth skipped for simplicity)
  sendBuilder sock $ buildAuth 0 -- AuthOk

  -- Send server parameters
  sendBuilder sock $ buildParameterStatus "server_version" "16.0"
  sendBuilder sock $ buildParameterStatus "server_encoding" "UTF8"
  sendBuilder sock $ buildParameterStatus "client_encoding" "UTF8"
  sendBuilder sock $ buildParameterStatus "is_superuser" "off"

  -- Send BackendKeyData
  sendBuilder sock $ buildBackendKeyData 1234 5678

  -- Send ReadyForQuery (Idle)
  sendBuilder sock $ buildReadyForQuery 'I'

  -- Message loop
  let loop = do
        mTag <- recvMaybe sock 1
        case mTag of
          Nothing -> pure () -- client disconnected
          Just tagBs -> do
            let tag = BS.index tagBs 0
            lenBs <- recvN sock 4
            let len = decodeInt32BE lenBs 0
            payload <- if len > 4 then recvN sock (fromIntegral len - 4) else pure BS.empty
            handleMessage sock cfg tag payload
            loop

  loop `catch` \(_ :: SomeException) -> pure ()
  NS.close sock

handleMessage :: NS.Socket -> MockConfig -> Word8 -> ByteString -> IO ()
handleMessage sock cfg tag payload = case tag of
  -- 'Q' = Simple Query
  0x51 -> do
    let sql = BS.takeWhile (/= 0) payload -- strip NUL terminator
    mockQueryHandler cfg sql (sendBuilder sock)
    sendBuilder sock $ buildReadyForQuery 'I'

  -- 'P' = Parse
  0x50 -> do
    sendBuilder sock $ buildSimpleTag '1' -- ParseComplete

  -- 'B' = Bind
  0x42 -> do
    sendBuilder sock $ buildSimpleTag '2' -- BindComplete

  -- 'D' = Describe
  0x44 -> do
    sendBuilder sock $ buildNoData

  -- 'E' = Execute
  0x45 -> do
    sendBuilder sock $ buildCommandComplete "SELECT 0"

  -- 'S' = Sync
  0x53 -> do
    sendBuilder sock $ buildReadyForQuery 'I'

  -- 'H' = Flush
  0x48 -> pure ()

  -- 'C' = Close
  0x43 -> do
    sendBuilder sock $ buildSimpleTag '3' -- CloseComplete

  -- 'X' = Terminate
  0x58 -> pure () -- connection will close

  -- Unknown
  _ -> do
    sendBuilder sock $ buildErrorResponse "08P01" ("Unknown message tag: " <> BS8.pack (show tag))
    sendBuilder sock $ buildReadyForQuery 'E'

------------------------------------------------------------------------
-- Backend message builders
------------------------------------------------------------------------

-- | Build a complete backend message as a Builder.
buildBackendMsg :: Word8 -> Builder -> Builder
buildBackendMsg tag payload =
  let payloadBs = LBS.toStrict (B.toLazyByteString payload)
      len = fromIntegral (BS.length payloadBs + 4) :: Int32
   in B.word8 tag <> B.int32BE len <> B.byteString payloadBs

-- | Send a backend message builder to a socket.
sendBackendMsg :: NS.Socket -> Word8 -> Builder -> IO ()
sendBackendMsg sock tag payload = sendBuilder sock (buildBackendMsg tag payload)

buildAuth :: Int32 -> Builder
buildAuth authType = buildBackendMsg 0x52 (B.int32BE authType)

buildParameterStatus :: ByteString -> ByteString -> Builder
buildParameterStatus name value =
  buildBackendMsg 0x53 (B.byteString name <> B.word8 0 <> B.byteString value <> B.word8 0)

buildBackendKeyData :: Int32 -> Int32 -> Builder
buildBackendKeyData pid key =
  buildBackendMsg 0x4B (B.int32BE pid <> B.int32BE key)

buildReadyForQuery :: Char -> Builder
buildReadyForQuery status =
  buildBackendMsg 0x5A (B.word8 (fromIntegral (fromEnum status)))

buildSimpleTag :: Char -> Builder
buildSimpleTag c = buildBackendMsg (fromIntegral (fromEnum c)) mempty

buildNoData :: Builder
buildNoData = buildSimpleTag 'n'

buildCommandComplete :: ByteString -> Builder
buildCommandComplete tag =
  buildBackendMsg 0x43 (B.byteString tag <> B.word8 0)

buildRowDescription :: [ByteString] -> Builder
buildRowDescription cols =
  buildBackendMsg 0x54 $ do
    B.int16BE (fromIntegral (length cols))
    <> foldMap buildFieldInfo cols
  where
    buildFieldInfo name =
      B.byteString name <> B.word8 0  -- name + NUL
        <> B.word32BE 0               -- table OID
        <> B.int16BE 0                -- column number
        <> B.word32BE 25              -- type OID (text)
        <> B.int16BE (-1)             -- type size
        <> B.int32BE (-1)             -- type modifier
        <> B.int16BE 0                -- format (text)

buildDataRow :: [ByteString] -> Builder
buildDataRow cols =
  buildBackendMsg 0x44 $
    B.int16BE (fromIntegral (length cols))
    <> foldMap (\v -> B.int32BE (fromIntegral (BS.length v)) <> B.byteString v) cols

buildErrorResponse :: ByteString -> ByteString -> Builder
buildErrorResponse sqlState msg =
  buildBackendMsg 0x45 $
    B.word8 (fromIntegral (fromEnum 'S')) <> B.byteString "ERROR" <> B.word8 0
    <> B.word8 (fromIntegral (fromEnum 'C')) <> B.byteString sqlState <> B.word8 0
    <> B.word8 (fromIntegral (fromEnum 'M')) <> B.byteString msg <> B.word8 0
    <> B.word8 0 -- terminator

------------------------------------------------------------------------
-- Socket helpers
------------------------------------------------------------------------

sendBuilder :: NS.Socket -> Builder -> IO ()
sendBuilder sock b = NSB.sendAll sock (LBS.toStrict (B.toLazyByteString b))

recvN :: NS.Socket -> Int -> IO ByteString
recvN sock n = go n []
  where
    go 0 acc = pure (BS.concat (reverse acc))
    go remaining acc = do
      chunk <- NSB.recv sock (min 65536 remaining)
      if BS.null chunk
        then pure (BS.concat (reverse acc))
        else go (remaining - BS.length chunk) (chunk : acc)

recvMaybe :: NS.Socket -> Int -> IO (Maybe ByteString)
recvMaybe sock n = do
  bs <- NSB.recv sock n
  if BS.null bs then pure Nothing else pure (Just bs)

decodeInt32BE :: ByteString -> Int -> Int32
decodeInt32BE bs off =
  let b0 = fromIntegral (BS.index bs off) :: Int32
      b1 = fromIntegral (BS.index bs (off + 1)) :: Int32
      b2 = fromIntegral (BS.index bs (off + 2)) :: Int32
      b3 = fromIntegral (BS.index bs (off + 3)) :: Int32
   in b0 `shiftL` 24 .|. b1 `shiftL` 16 .|. b2 `shiftL` 8 .|. b3