hipsql-server-0.0.0.0: library/Hipsql/Server/Internal.hs
-- | Internal module which implements starting a @hipsql-server@ from Haskell code.
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Hipsql.Server.Internal
( -- * Disclaimer
-- $disclaimer
-- ** Internals
module Hipsql.Server.Internal
) where
import Control.Concurrent (MVar, newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.Async (race_)
import Control.Exception (Exception, SomeException, catch, fromException, throwIO)
import Control.Monad (mfilter)
import Control.Monad.Except (ExceptT(ExceptT))
import Control.Monad.IO.Class (MonadIO(liftIO))
import Data.ByteString (ByteString)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Maybe (fromMaybe, listToMaybe)
import Data.String (IsString(fromString))
import Data.Traversable (for)
import GHC.Stack (SrcLoc, prettySrcLoc)
import Hipsql.API (HipsqlRoutes(HipsqlRoutes, eval, getVersion), HipsqlAPI, theHipsqlAPI, theHipsqlApiVersion)
import Hipsql.API.Internal (lookupHipsqlPort)
import Servant.Server
( Handler(Handler), HasServer(ServerT), ServerError(errBody), Application, Server, err400, err500
, hoistServer, serve
)
import Servant.Server.Generic (genericServerT)
import System.IO (hPutStrLn, stderr)
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as Lazy
import qualified Data.ByteString.Lazy.Char8 as Lazy.Char8
import qualified Data.List as List
import qualified Database.PostgreSQL.LibPQ as LibPQ
import qualified Network.Wai.Handler.Warp as Warp
-- | Runtime environment of the @hipsql@ server.
data ServerEnv = ServerEnv
{ conn :: LibPQ.Connection
, killswitch :: MVar ()
, state :: IORef ServerState
}
-- | Runtime state of the @hipsql@ server.
newtype ServerState = ServerState
{ extendedDisplay :: Bool
}
-- | Create an initial 'ServerEnv'.
newServerEnv :: LibPQ.Connection -> IO ServerEnv
newServerEnv conn = do
killswitch <- newEmptyMVar
state <- newIORef ServerState
{ extendedDisplay = False
}
pure ServerEnv { conn, killswitch, state }
-- | Lifts the 'IO' action to a @servant@ 'Handler', ensuring exceptions
-- are handled accordingly.
toHandler :: IO a -> Handler a
toHandler = Handler . ExceptT . handleServantErr
where
handleServantErr :: IO a -> IO (Either ServerError a)
handleServantErr x =
catch
(fmap Right x)
(pure . Left . toServantError)
toServantError :: SomeException -> ServerError
toServantError e = case fromException e of
Just (QueryError m) -> err400 { errBody = Lazy.Char8.fromStrict m }
Nothing -> err500 { errBody = fromString $ show e }
-- | Create a @servant@ 'Server' of the 'HipsqlAPI'.
server :: ServerEnv -> Server HipsqlAPI
server env = hoistServer theHipsqlAPI toHandler ioServer
where
ioServer :: ServerT HipsqlAPI IO
ioServer = genericServerT HipsqlRoutes
{ getVersion = pure theHipsqlApiVersion
, eval
}
eval input = case Lazy.Char8.uncons input of
Just ('\\', c) -> runCommand c
_ | input `elem` ["quit", "exit"] -> quit
_ -> runQuery input
quit = do
putMVar (killswitch env) ()
mempty
runCommand = \case
"x" -> runToggleExtendedDisplay
"?" -> runHelp
s | s `elem` ["q", "quit"] -> quit
s -> invalidCommand s
runHelp = pure $ Lazy.fromStrict helpMessage
invalidCommand s = do
pure $ "invalid command \\" <> s <> "\nTry \\? for help."
runToggleExtendedDisplay = do
x <- toggleExtendedDisplay
pure $ "Extended display is " <> (if x then "on" else "off") <> "."
modify f = do
atomicModifyIORef' (state env) \s -> let s' = f s in (s', s')
toggleExtendedDisplay = do
s <- modify \s -> s { extendedDisplay = not (extendedDisplay s) }
pure $ extendedDisplay s
runQuery q = do
if Lazy.Char8.null q then do
mempty
else do
renderQueryResult env =<< rawQuery env (Lazy.toStrict q)
-- | Render a 'QueryResponse' received from issuing a query as human
-- readable. Respects the current 'extendedDisplay' state.
renderQueryResult :: ServerEnv -> QueryResponse -> IO Lazy.ByteString
renderQueryResult env QueryResponse { columnNames, resultRows } = do
ServerState { extendedDisplay } <- readIORef (state env)
let rendered =
if extendedDisplay then
renderXTable renderedColNames renderedValues
else
renderTable (Just renderedColNames) renderedValues
pure $ rendered <> "\n" <> renderedRowCount <> "\n"
where
renderedColNames = map (fromMaybe "?") columnNames
renderedValues = map (map (fromMaybe "null")) resultRows
renderedRowCount = case length resultRows of
1 -> "(1 row)"
n -> "(" <> fromString (show n) <> " rows)"
-- | Special case of 'renderTable' which renders the supplied header
-- and values via 'extendedDisplay'.
renderXTable :: [ByteString] -> [[ByteString]] -> Lazy.ByteString
renderXTable hs = Lazy.Char8.intercalate "\n" . zipWith go [1..]
where
go :: Int -> [ByteString] -> Lazy.ByteString
go i rs =
"-[ RECORD " <> fromString (show i) <> " ]\n"
<> renderTable Nothing (zipL hs rs)
zipL :: [a] -> [a] -> [[a]]
zipL = zipWith \a1 a2 -> [a1, a2]
-- | Render the supplied optional header and values as a table for printing in
-- the console. Attempts to match closely to real psql output.
renderTable :: Maybe [ByteString] -> [[ByteString]] -> Lazy.ByteString
renderTable maybeHeader rows = renderedHeader <> renderedTable
where
maxLens =
map
(\col -> if null col then 0 else maximum (map Char8.length col))
(List.transpose (fromMaybe [] maybeHeader : rows))
-- We don't care about the last column since it never needs to be padded,
-- so 'safeInit' to omit it.
maxPadAt :: Int -> Int
maxPadAt i = maybe 0 (1+) $ listToMaybe $ drop i $ safeInit maxLens
lineLenAt :: Int -> Int
lineLenAt i = maybe 0 (2+) $ listToMaybe $ drop i maxLens
renderCell i s =
" " <> Lazy.Char8.fromStrict s <>
Lazy.Char8.replicate
(fromIntegral (maxPadAt i - Char8.length s))
' '
renderRow = zipWith renderCell [0..]
renderedHeader = case maybeHeader of
Nothing -> ""
Just header ->
Lazy.Char8.intercalate "|" (renderRow header) <> "\n"
<> Lazy.Char8.intercalate "+"
( map (\i -> Lazy.Char8.replicate (fromIntegral (lineLenAt i)) '-') [0..(length header - 1)]
) <> "\n"
renderedTable =
Lazy.Char8.intercalate "\n"
$ map (Lazy.Char8.intercalate "|" . renderRow) rows
-- | Same as 'init' except returns an empty list if the supplied list is empty.
safeInit :: [a] -> [a]
safeInit xs = if null xs then xs else init xs
-- | Execute the supplied query and return its response.
rawQuery :: ServerEnv -> ByteString -> IO QueryResponse
rawQuery env q = do
liftIO do
LibPQ.exec (conn env) q >>= \case
Just result -> do
maybeMessage <- LibPQ.resultErrorMessage result
case mfilter (not . Char8.null) maybeMessage of
Nothing -> mkQueryResponse result
Just message -> throwIO $ QueryError message
Nothing -> do
maybeMessage <- LibPQ.errorMessage (conn env)
throwIO $ QueryError $ fromMaybe "ERROR: (no message)" maybeMessage
-- | Construct a 'QueryResponse' from a 'LibPQ.Result'.
-- NOTE: This is dangerous as it does not validate that an
-- error did not occur. Prefer 'rawQuery' instead.
mkQueryResponse :: LibPQ.Result -> IO QueryResponse
mkQueryResponse result = do
numRows <- LibPQ.ntuples result
numCols <- LibPQ.nfields result
columnNames <-
for [0..(numCols - 1)] \c -> do
LibPQ.fname result c
resultRows <-
for [0..(numRows - 1)] \r -> do
for [0..(numCols - 1)] \c -> do
LibPQ.getvalue' result r c
pure QueryResponse
{ columnNames
, resultRows
}
-- | An error response from issuing a query.
newtype QueryError = QueryError ByteString
deriving stock (Show)
deriving anyclass (Exception)
-- | A successful response from issuing a query.
data QueryResponse = QueryResponse
{ columnNames :: [Maybe ByteString]
, resultRows :: [[Maybe ByteString]]
}
-- | Help message displayed when the user gives the @\?@ command.
helpMessage :: ByteString
helpMessage =
Char8.intercalate "\n"
[ "General"
, " \\q quit psql"
, "Help"
, " \\? show this help message"
, ""
, "Formatting"
, " \\x toggle expanded output"
]
-- | A @servant@ 'Application' of the 'HipsqlAPI'.
application :: ServerEnv -> Application
application env = serve theHipsqlAPI (server env)
-- | Start a @hipsql@ session with the given 'LibPQ.Connection'.
startHipsql :: Maybe SrcLoc -> LibPQ.Connection -> IO ()
startHipsql loc conn = do
config <- getDefaultConfig
deps <- getDefaultDeps
startHipsql' loc config deps conn
-- | Same as 'startHipsql' but allows you to specify the 'Config' and 'Deps' directly.
startHipsql' :: Maybe SrcLoc -> Config -> Deps -> LibPQ.Connection -> IO ()
startHipsql' loc config deps conn = do
env <- newServerEnv conn
logger $
"Starting hipsql server on port "
<> show port
<> "; called at "
<> maybe "<unknown>" prettySrcLoc loc
race_ (waitForKillswitch env) (Warp.run port (application env))
where
Config { port } = config
Deps { logger } = deps
waitForKillswitch env = do
takeMVar (killswitch env)
logger "Shutting down hipsql server"
-- | Same as 'startHipsql' except uses a 'LibPQ.Connection' acquiring function.
-- Useful when integrating with libraries like @postgresql-simple@ which
-- give you exclusive access to the 'LibPQ.Connection' via such a function.
startHipsqlWith :: Maybe SrcLoc -> ((LibPQ.Connection -> IO ()) -> IO ()) -> IO ()
startHipsqlWith loc f = f (startHipsql loc)
-- | Same as 'startHipsqlWith' but allows you to specify the 'Config' and 'Deps' directly.
startHipsqlWith' :: Maybe SrcLoc -> Config -> Deps -> ((LibPQ.Connection -> IO ()) -> IO ()) -> IO ()
startHipsqlWith' loc config deps f = f (startHipsql' loc config deps)
-- | Configuration required for starting a @hipsql@ server.
newtype Config = Config
{ port :: Int
}
-- | Gets the default 'Config' used by the @hipsql@ server.
getDefaultConfig :: IO Config
getDefaultConfig = do
lookupHipsqlPort >>= \case
Left message -> do
error $ "Failed to start hipsql server; could not parse port: " <> message
Right port -> do
pure Config
{ port
}
-- | Dependencies required for starting a @hipsql@ server.
newtype Deps = Deps
{ logger :: String -> IO ()
}
-- | Gets the default 'Deps' used by the @hipsql@ server.
getDefaultDeps :: IO Deps
getDefaultDeps = do
pure Deps
{ logger = hPutStrLn stderr
}
-- $disclaimer
--
-- Changes to this module will not be reflected in the library's version
-- updates.