bolty-0.2.0.0: src/Database/Bolty/Message/Request.hs
{-# LANGUAGE ApplicativeDo #-}
-- | Internal module. Not part of the public API.
module Database.Bolty.Message.Request
( Request(..)
, Hello(..)
, Logon(..)
, TelemetryApi(..)
, RunExplicitTransaction(..)
, RunAutoCommitTransaction(..), defaultRunAutoCommitTransaction, mkRunAutoCommit
, Discard(..)
, Begin(..)
, Pull(..), defaultPull
, Route(..)
, RouteExtra(..)
, RunExtra(..), defaultRunExtra
) where
import Data.Int (Int64)
import Data.Kind (Type)
import Data.Maybe (fromMaybe)
import GHC.Generics (Generic)
import qualified Data.HashMap.Lazy as H
import qualified Data.Text as T
import qualified Data.Vector as V
import Data.PackStream
import Data.PackStream.Integer (toPSInteger, fromPSInteger)
import Database.Bolty.AccessMode (AccessMode(..))
import Database.Bolty.Connection.Instances ()
import Database.Bolty.Connection.Type (UserAgent(..), Scheme(..), Routing(..))
-- | A BOLT client request message.
type Request :: Type
data Request
= RHello Hello
| RGoodbye
| RReset
| RRunExplicitTransaction RunExplicitTransaction
| RRunAutoCommitTransaction RunAutoCommitTransaction
| RDiscard Discard
| RPull Pull
| RBegin Begin
| RCommit
| RRollback
| RRoute Route
| RLogon Logon
| RLogoff
| RTelemetry TelemetryApi
deriving stock (Generic)
instance PackStream Request where
toPs (RHello hello) = structureSingleton 0x01 $ toPs hello
toPs (RGoodbye) = PsStructure 0x02 V.empty
toPs (RReset) = PsStructure 0x0F V.empty
toPs (RRunExplicitTransaction x) = runExplicitTransactionToPs x
toPs (RRunAutoCommitTransaction x) = runAutoCommitTransactiontoPs x
toPs (RDiscard discard) = structureSingleton 0x2F $ toPs discard
toPs (RPull pull) = structureSingleton 0x3F $ toPs pull
toPs (RBegin begin) = structureSingleton 0x11 $ toPs begin
toPs (RCommit) = PsStructure 0x12 V.empty
toPs (RRollback) = PsStructure 0x13 V.empty
toPs (RRoute route) = routeToPs route
toPs (RLogon logon) = structureSingleton 0x6A $ logonToPs logon
toPs (RLogoff) = PsStructure 0x6B V.empty
toPs (RTelemetry api) = PsStructure 0x54 $ V.singleton $ PsInteger $ toPSInteger $ telemetryApiToInt api
fromPs (PsStructure 0x01 fields) = fromOneField fields RHello "expected exactly one field for Hello"
fromPs (PsStructure 0x02 fields) = if V.null fields then Success RGoodbye else Error "additional data on RGoodbye request is not allowed"
fromPs (PsStructure 0x0F fields) = if V.null fields then Success RReset else Error "additional data on RReset request is not allowed"
fromPs (PsStructure 0x10 fields) = fromPsToRunRequest fields
fromPs (PsStructure 0x2F fields) = fromOneField fields RDiscard "expected exactly one field for Discard"
fromPs (PsStructure 0x3F fields) = fromOneField fields RPull "expected exactly one field for Pull"
fromPs (PsStructure 0x11 fields) = fromOneField fields RBegin "expected exactly one field for Begin"
fromPs (PsStructure 0x12 fields) = if V.null fields then Success RCommit else Error "additional data on RCommit request is not allowed"
fromPs (PsStructure 0x13 fields) = if V.null fields then Success RRollback else Error "additional data on RRollback request is not allowed"
fromPs (PsStructure 0x66 fields) = fromPsToRoute fields
fromPs (PsStructure 0x6A fields) = fromOneField fields RLogon "expected exactly one field for Logon"
fromPs (PsStructure 0x6B fields) = if V.null fields then Success RLogoff else Error "additional data on RLogoff request is not allowed"
fromPs (PsStructure 0x54 fields) = fromPsToTelemetry fields
fromPs _ = Error "Could not decode Request"
fromPsToRunRequest :: V.Vector Ps -> Result Request
fromPsToRunRequest fields =
if V.length fields /= 1 then
Error "expected exactly one field for Run"
else do
arg <- fromPs $ V.unsafeHead fields
f arg
where f :: H.HashMap T.Text Ps -> Result Request
f map = if any (\f -> H.member f map) ["bookmarks", "tx_timeout", "tx_metadata", "mode", "db", "imp_user"] then do
query :: T.Text <- lookupWithError "query" map "\"query\" not found in Run Request"
parameters :: H.HashMap T.Text Ps <- lookupWithError "parameters" map "\"parameters\" not found in Run Request"
extra :: RunExtra <- lookupWithError "extra" map "\"extra\" not found in Run Request"
bookmarks :: V.Vector T.Text <- lookupWithError "bookmarks" map "\"bookmarks\" not found in Run Request"
tx_timeout :: Maybe Int64 <- lookupMaybe "tx_timeout" map
tx_metadata :: H.HashMap T.Text Ps <- lookupWithError "tx_metadata" map "\"tx_metadata\" not found in Run Request"
mode :: AccessMode <- lookupMode map
db :: Maybe T.Text <- lookupMaybe "db" map
imp_user :: Maybe T.Text <- lookupMaybe "imp_user" map
pure $ RRunAutoCommitTransaction RunAutoCommitTransaction{query, parameters, extra, bookmarks, tx_timeout, tx_metadata, mode, db, imp_user}
else do
query :: T.Text <- lookupWithError "query" map "\"query\" not found in Run Request"
parameters :: H.HashMap T.Text Ps <- lookupWithError "parameters" map "\"parameters\" not found in Run Request"
extra :: RunExtra <- lookupWithError "extra" map "\"extra\" not found in Run Request"
pure $ RRunExplicitTransaction RunExplicitTransaction{query, parameters, extra}
-- | HELLO message payload for initial handshake authentication.
type Hello :: Type
data Hello = Hello
{ user_agent :: UserAgent
, scheme :: Scheme
, routing :: Routing
, patchBolt :: Bool
-- ^ When True, include @"patch_bolt": ["utc"]@ in HELLO (BOLT 4.3\/4.4 only).
}
deriving stock (Generic)
instance PackStream Hello where
toPs = helloToPs
fromPs = helloFromPs
-- | RUN message payload within an explicit transaction.
type RunExplicitTransaction :: Type
data RunExplicitTransaction = RunExplicitTransaction
{ query :: !T.Text
, parameters :: !(H.HashMap T.Text Ps)
, extra :: RunExtra
}
deriving stock (Generic)
runExplicitTransactionToPs :: RunExplicitTransaction -> Ps
runExplicitTransactionToPs RunExplicitTransaction{query, parameters, extra} =
PsStructure 0x10 $ V.fromList
[ toPs query
, toPs parameters
, toPs extra
]
-- | Default 'RunAutoCommitTransaction' with empty query, parameters, and bookmarks.
defaultRunAutoCommitTransaction :: RunAutoCommitTransaction
defaultRunAutoCommitTransaction = RunAutoCommitTransaction
{ query = ""
, parameters = H.empty
, extra = defaultRunExtra
, bookmarks = V.empty
, tx_timeout = Nothing
, tx_metadata = H.empty
, mode = WriteAccess
, db = Nothing
, imp_user = Nothing
}
-- | Build a 'RunAutoCommitTransaction' with the given query and parameters.
mkRunAutoCommit :: T.Text -> H.HashMap T.Text Ps -> RunAutoCommitTransaction
mkRunAutoCommit q p = RunAutoCommitTransaction
{ query = q
, parameters = p
, extra = defaultRunExtra
, bookmarks = V.empty
, tx_timeout = Nothing
, tx_metadata = H.empty
, mode = WriteAccess
, db = Nothing
, imp_user = Nothing
}
-- | RUN message payload for an auto-commit transaction.
type RunAutoCommitTransaction :: Type
data RunAutoCommitTransaction = RunAutoCommitTransaction
{ query :: !T.Text
, parameters :: !(H.HashMap T.Text Ps)
, extra :: RunExtra
, bookmarks :: V.Vector T.Text
, tx_timeout :: !(Maybe Int64)
, tx_metadata :: !(H.HashMap T.Text Ps)
, mode :: !AccessMode
, db :: !(Maybe T.Text)
, imp_user :: !(Maybe T.Text)
}
deriving stock (Generic)
runAutoCommitTransactiontoPs :: RunAutoCommitTransaction -> Ps
runAutoCommitTransactiontoPs RunAutoCommitTransaction{query, parameters, bookmarks, tx_timeout, tx_metadata, mode, db, imp_user} =
PsStructure 0x10 $ V.fromList
[ toPs query
, toPs parameters
, PsDictionary $ H.fromList $
let tx_timeout_list = case tx_timeout of
Nothing -> []
Just t -> [("tx_timeout", toPs t)]
imp_user_list = case imp_user of
Nothing -> []
Just i -> [("imp_user", toPs i)]
in [ ("bookmarks", toPs bookmarks)
, ("tx_metadata", toPs tx_metadata)
, ("mode", toPs mode)
, ("db", toPs db)
] <> tx_timeout_list <> imp_user_list
]
-- | DISCARD message payload with record count and query ID.
type Discard :: Type
data Discard = Discard
{ n :: Int64
, qid :: Int64
}
deriving stock (Generic)
instance PackStream Discard where
toPs Discard{n, qid} = PsDictionary $ H.fromList
[("n", toPs n), ("qid", toPs qid)]
fromPs = withDictionary "Discard" $ \m -> do
n' <- lookupWithError "n" m "\"n\" not found in Discard"
qid' <- lookupWithError "qid" m "\"qid\" not found in Discard"
pure $ Discard{n = n', qid = qid'}
-- | BEGIN message payload for starting an explicit transaction.
type Begin :: Type
data Begin = Begin
{ bookmarks :: !(V.Vector T.Text)
, tx_timeout :: !(Maybe Int64)
, tx_metadata :: !(H.HashMap T.Text Ps)
, mode :: !AccessMode
, db :: !(Maybe T.Text)
, imp_user :: !(Maybe T.Text)
}
deriving stock (Generic)
instance PackStream Begin where
toPs Begin{bookmarks, tx_timeout, tx_metadata, mode, db, imp_user} = PsDictionary $ H.fromList
[ ("bookmarks", toPs bookmarks)
, ("tx_timeout", toPs tx_timeout)
, ("tx_metadata", toPs tx_metadata)
, ("mode", toPs mode)
, ("db", toPs db)
, ("imp_user", toPs imp_user)
]
toBinary run_extra = putPs $ toPs run_extra
fromPs = withDictionary "Begin" f
where f map = do
bookmarks :: V.Vector T.Text <- lookupWithError "bookmarks" map "\"bookmarks\" not found in Begin"
tx_timeout :: Maybe Int64 <- lookupMaybe "tx_timeout" map
tx_metadata :: H.HashMap T.Text Ps <- lookupWithError "tx_metadata" map "\"tx_metadata\" not found in Begin"
mode :: AccessMode <- lookupMode map
db :: (Maybe T.Text) <- lookupMaybe "db" map
imp_user :: Maybe T.Text <- lookupMaybe "imp_user" map
pure $ Begin{bookmarks, tx_timeout, tx_metadata, mode, db, imp_user}
-- | ROUTE message payload for fetching routing tables.
type Route :: Type
data Route = Route
{ routing :: !(H.HashMap T.Text Ps) -- Only "address" specified
, bookmarks :: !(V.Vector T.Text)
, extra :: RouteExtra
}
routeToPs :: Route -> Ps
routeToPs Route{routing, bookmarks, extra} =
PsStructure 0x66 $ V.fromList
[ toPs routing
, toPs bookmarks
, toPs extra
]
fromPsToRoute :: V.Vector Ps -> Result Request
fromPsToRoute fields
| V.length fields /= 3 = Error "expected exactly 3 fields for Route"
| otherwise = do
routing <- fromPs (fields V.! 0)
bookmarks <- fromPs (fields V.! 1)
extra <- fromPs (fields V.! 2)
Success $ RRoute Route{routing, bookmarks, extra}
-- | Default 'Pull' requesting up to 1000 records per batch, with no explicit query ID.
-- Matches the default fetch size used by the official Neo4j drivers (Java, Python, JS, .NET).
-- Use @n = -1@ to request all records in a single batch (no back-pressure).
defaultPull :: Pull
defaultPull = Pull{n = 1000, qid = Nothing}
-- | PULL message payload with record count and optional query ID.
type Pull :: Type
data Pull = Pull
{ n :: Int64
, qid :: Maybe Int64
}
deriving stock (Generic)
instance PackStream Pull where
toPs Pull{n, qid} = PsDictionary $ H.fromList $
[("n", toPs n)] <>
case qid of
Nothing -> []
Just q -> [("qid", toPs q)]
fromPs = withDictionary "Pull" $ \m -> do
n' <- lookupWithError "n" m "\"n\" not found in Pull"
qid' <- lookupMaybe "qid" m
pure $ Pull{n = n', qid = qid'}
-- | Extra parameters for the ROUTE message (database and impersonated user).
type RouteExtra :: Type
data RouteExtra = RouteExtra
{ db :: !(Maybe T.Text)
, imp_user :: !(Maybe T.Text)
}
instance PackStream RouteExtra where
toPs RouteExtra{db, imp_user} = PsDictionary $ H.fromList $
maybe [] (\d -> [("db", toPs d)]) db <>
maybe [] (\i -> [("imp_user", toPs i)]) imp_user
toBinary route_extra = putPs $ toPs route_extra
fromPs = withDictionary "RouteExtra" f
where f map = do
db :: Maybe T.Text <- lookupMaybe "db" map
imp_user :: Maybe T.Text <- lookupMaybe "imp_user" map
pure $ RouteExtra{db, imp_user}
-- | Extra parameters for the RUN message (bookmarks, timeout, metadata, mode, db, impersonation).
type RunExtra :: Type
data RunExtra = RunExtra
{ bookmarks :: !(V.Vector T.Text)
, tx_timeout :: !(Maybe Int64)
, tx_metadata :: !(H.HashMap T.Text Ps)
, mode :: !AccessMode
, db :: !(Maybe T.Text)
, imp_user :: !(Maybe T.Text)
}
-- | Default 'RunExtra' with empty bookmarks, no timeout, write mode, and no database.
defaultRunExtra :: RunExtra
defaultRunExtra = RunExtra
{ bookmarks = V.empty
, tx_timeout = Nothing
, tx_metadata = H.empty
, mode = WriteAccess
, db = Nothing
, imp_user = Nothing
}
instance PackStream RunExtra where
toPs RunExtra{bookmarks, tx_timeout, tx_metadata, mode, db, imp_user} = PsDictionary $ H.fromList
[ ("bookmarks", toPs bookmarks)
, ("tx_timeout", toPs tx_timeout)
, ("tx_metadata", toPs tx_metadata)
, ("mode", toPs mode)
, ("db", toPs db)
, ("imp_user", toPs imp_user)
]
toBinary run_extra = putPs $ toPs run_extra
fromPs = withDictionary "RunExtra" f
where f map = do
bookmarks :: V.Vector T.Text <- lookupWithError "bookmarks" map "\"bookmarks\" not found in RunExtra"
tx_timeout :: Maybe Int64 <- lookupMaybe "tx_timeout" map
tx_metadata :: H.HashMap T.Text Ps <- lookupWithError "tx_metadata" map "\"tx_metadata\" not found in RunExtra"
mode :: AccessMode <- lookupMode map
db :: Maybe T.Text <- lookupMaybe "db" map
imp_user :: Maybe T.Text <- lookupMaybe "imp_user" map
pure $ RunExtra{bookmarks, tx_timeout, tx_metadata, mode, db, imp_user}
helloToPs :: Hello -> Ps
helloToPs Hello{user_agent, scheme, routing, patchBolt} =
PsDictionary $ H.fromList $
[ ("user_agent", toPs user_agent)
, ("scheme" , toPs scheme')
] <>
( case scheme of
None -> []
Basic principal credentials -> [("principal", toPs principal), ("credentials", toPs credentials)]
Kerberos -> []
Bearer credentials -> [("credentials", toPs credentials)]
) <>
( case routing of
NoRouting -> []
Routing -> [("routing", PsDictionary H.empty)]
RoutingSpec address urlQueryParams -> [("routing", PsDictionary $ H.fromList $
("address", toPs address) : [(k, toPs v) | (k, v) <- H.toList urlQueryParams])]
) <>
( if patchBolt then [("patch_bolt", PsList $ V.singleton $ PsString "utc")] else [] )
where
scheme' :: T.Text
scheme' = case scheme of
None -> "none"
Basic _ _ -> "basic"
Kerberos -> "kerberos"
Bearer _ -> "bearer"
helloFromPs :: Ps -> Result Hello
helloFromPs = withDictionary "Request:Hello" f
where f :: H.HashMap T.Text Ps -> Result Hello
f map = do
user_agent :: UserAgent <- lookupWithError "user_agent" map "\"user_agent\" not found in hello request"
scheme :: T.Text <- lookupWithError "scheme" map "\"scheme\" not found in hello request"
routing <- case H.lookup "routing" map of
Nothing -> Success NoRouting
Just (PsDictionary m)
| H.null m -> Success Routing
| otherwise -> case H.lookup "address" m of
Just addr -> do
a <- fromPs addr
let params = H.delete "address" m
ps <- traverse fromPs params
Success $ RoutingSpec a ps
Nothing -> Error "\"routing\" dictionary missing \"address\" key"
Just _ -> Error "\"routing\" field should be a dictionary"
let credentials = H.lookup "credentials" map
let principal = H.lookup "principal" map
let pb = case H.lookup "patch_bolt" map of
Just (PsList v) -> V.elem (PsString "utc") v
_ -> False
case scheme of
"none" -> pure $ Hello {user_agent, scheme = None, routing, patchBolt = pb}
"basic" -> case (principal, credentials) of
(Just p, Just c) -> do
p2 <- fromPs p
c2 <- fromPs c
pure $ Hello {user_agent, scheme = Basic p2 c2, routing, patchBolt = pb}
_ -> Error "\"principal\" and \"credentials\" required for scheme \"basic\""
"kerberos" -> pure $ Hello {user_agent, scheme = Kerberos, routing, patchBolt = pb}
"bearer" -> case credentials of
Just c -> do
c2 <- fromPs c
pure $ Hello {user_agent, scheme = Bearer c2, routing, patchBolt = pb}
Nothing -> Error "\"credentials\" required for scheme \"bearer\""
_ -> Error $ "unknown scheme: " <> scheme
-- | LOGON message carries authentication credentials (Bolt 5.1+).
-- In 5.1+ HELLO no longer carries credentials; auth moves to LOGON.
type Logon :: Type
data Logon = Logon
{ scheme :: Scheme
}
deriving stock (Generic)
instance PackStream Logon where
toPs = logonToPs
fromPs = logonFromPs
logonToPs :: Logon -> Ps
logonToPs Logon{scheme} =
PsDictionary $ H.fromList $
[ ("scheme", toPs scheme')
] <>
( case scheme of
None -> []
Basic principal credentials -> [("principal", toPs principal), ("credentials", toPs credentials)]
Kerberos -> []
Bearer credentials -> [("credentials", toPs credentials)]
)
where
scheme' :: T.Text
scheme' = case scheme of
None -> "none"
Basic _ _ -> "basic"
Kerberos -> "kerberos"
Bearer _ -> "bearer"
logonFromPs :: Ps -> Result Logon
logonFromPs = withDictionary "Request:Logon" f
where f :: H.HashMap T.Text Ps -> Result Logon
f map = do
schemeTxt :: T.Text <- lookupWithError "scheme" map "\"scheme\" not found in logon request"
let credentials = H.lookup "credentials" map
let principal = H.lookup "principal" map
case schemeTxt of
"none" -> pure $ Logon{scheme = None}
"basic" -> case (principal, credentials) of
(Just p, Just c) -> do
p2 <- fromPs p
c2 <- fromPs c
pure $ Logon{scheme = Basic p2 c2}
_ -> Error "\"principal\" and \"credentials\" required for scheme \"basic\""
"kerberos" -> pure $ Logon{scheme = Kerberos}
"bearer" -> case credentials of
Just c -> do
c2 <- fromPs c
pure $ Logon{scheme = Bearer c2}
Nothing -> Error "\"credentials\" required for scheme \"bearer\""
_ -> Error $ "unknown scheme: " <> schemeTxt
-- | Telemetry API type indicators (Bolt 5.4+).
type TelemetryApi :: Type
data TelemetryApi
= ManagedTransactions
| ExplicitTransactions
| ImplicitTransactions
| ExecuteQuery
deriving stock (Show, Eq)
telemetryApiToInt :: TelemetryApi -> Int64
telemetryApiToInt ManagedTransactions = 0
telemetryApiToInt ExplicitTransactions = 1
telemetryApiToInt ImplicitTransactions = 2
telemetryApiToInt ExecuteQuery = 3
telemetryApiFromInt :: Int64 -> Result TelemetryApi
telemetryApiFromInt 0 = Success ManagedTransactions
telemetryApiFromInt 1 = Success ExplicitTransactions
telemetryApiFromInt 2 = Success ImplicitTransactions
telemetryApiFromInt 3 = Success ExecuteQuery
telemetryApiFromInt n = Error $ "unknown telemetry api value: " <> T.pack (show n)
fromPsToTelemetry :: V.Vector Ps -> Result Request
fromPsToTelemetry fields
| V.length fields /= 1 = Error "expected exactly one field for Telemetry"
| otherwise = case V.unsafeHead fields of
PsInteger n -> case fromPSInteger n of
Just i -> fmap RTelemetry $ telemetryApiFromInt i
Nothing -> Error "telemetry api integer out of Int64 range"
_ -> Error "expected integer field for Telemetry"
-- | Look up the BEGIN\/RUN @mode@ field, defaulting to 'WriteAccess' when
-- absent. Per the Bolt protocol, @mode@ is an optional field with default
-- @"w"@; spec-compliant peers may omit it on write transactions to save
-- bytes. A bare @lookupWithError@ would reject those messages.
lookupMode :: H.HashMap T.Text Ps -> Result AccessMode
lookupMode m = fromMaybe WriteAccess <$> lookupMaybe "mode" m