graphql-engine/server/src-lib/Hasura/Server/App.hs
Rakesh Emmadi e32f5a1fb1 sync metadata cache across multiple instances connected to same db (closes #1182) (#1574)
1. Haskel library `pg-client-hs` has been updated to expose a function that helps listen to `postgres` notifications over a `channel` in this [PR](https://github.com/hasura/pg-client-hs/pull/5)
2. The server records an event in a table `hdb_catalog.hdb_cache_update_event` whenever any `/v1/query` (that changes metadata) is requested. A trigger notifies a `cache update` event via `hasura_cache_update` channel
3. The server runs two concurrent threads namely `listener` and `processor`. The `listener` thread listens to events on `hasura_cache_update` channel and pushed into a `Queue`. The `processor` thread fetches events from that `Queue` and processes it. Thus server rebuilds schema cache from database and updates.
2019-03-12 11:16:27 +05:30

434 lines
15 KiB
Haskell

{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE RankNTypes #-}
module Hasura.Server.App where
import Control.Arrow ((***))
import Control.Concurrent.MVar
import Data.Aeson hiding (json)
import Data.IORef
import Data.Time.Clock (UTCTime,
getCurrentTime)
import Network.Wai (requestHeaders,
strictRequestBody)
import Web.Spock.Core
import qualified Data.ByteString.Lazy as BL
#ifdef LocalConsole
import qualified Data.FileEmbed as FE
#endif
import qualified Data.HashMap.Strict as M
import qualified Data.HashSet as S
import qualified Data.Text as T
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as N
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.WebSockets as WS
import qualified Network.WebSockets as WS
import qualified Text.Mustache as M
import qualified Text.Mustache.Compile as M
import qualified Database.PG.Query as Q
import qualified Hasura.GraphQL.Explain as GE
import qualified Hasura.GraphQL.Schema as GS
import qualified Hasura.GraphQL.Transport.HTTP as GH
import qualified Hasura.GraphQL.Transport.HTTP.Protocol as GH
import qualified Hasura.GraphQL.Transport.WebSocket as WS
import qualified Hasura.Logging as L
import Hasura.GraphQL.RemoteServer
import Hasura.Prelude hiding (get, put)
import Hasura.RQL.DDL.Schema.Table
import Hasura.RQL.DML.QueryTemplate
import Hasura.RQL.Types
import Hasura.Server.Auth (AuthMode (..),
getUserInfo)
import Hasura.Server.Cors
import Hasura.Server.Init
import Hasura.Server.Logging
import Hasura.Server.Middleware (corsMiddleware)
import Hasura.Server.Query
import Hasura.Server.Utils
import Hasura.Server.Version
import Hasura.SQL.Types
consoleTmplt :: M.Template
consoleTmplt = $(M.embedSingleTemplate "src-rsr/console.html")
boolToText :: Bool -> T.Text
boolToText = bool "false" "true"
isAdminSecretSet :: AuthMode -> T.Text
isAdminSecretSet AMNoAuth = boolToText False
isAdminSecretSet _ = boolToText True
#ifdef LocalConsole
consoleAssetsLoc :: Text
consoleAssetsLoc = "/static"
#else
consoleAssetsLoc :: Text
consoleAssetsLoc =
"https://storage.googleapis.com/hasura-graphql-engine/console/" <> consoleVersion
#endif
mkConsoleHTML :: T.Text -> AuthMode -> Bool -> Either String T.Text
mkConsoleHTML path authMode enableTelemetry =
bool (Left errMsg) (Right res) $ null errs
where
(errs, res) = M.checkedSubstitute consoleTmplt $
object [ "consoleAssetsLoc" .= consoleAssetsLoc
, "isAdminSecretSet" .= isAdminSecretSet authMode
, "consolePath" .= consolePath
, "enableTelemetry" .= boolToText enableTelemetry
]
consolePath = case path of
"" -> "/console"
r -> "/console/" <> r
errMsg = "console template rendering failed: " ++ show errs
data SchemaCacheRef
= SchemaCacheRef
{ _scrLock :: MVar ()
, _scrCache :: IORef SchemaCache
}
withSCUpdate
:: (MonadIO m, MonadError e m)
=> SchemaCacheRef -> m (a, SchemaCache) -> m a
withSCUpdate scr action = do
acquireLock
(res, newSC) <- action `catchError` onError
-- update schemacache in IO reference
liftIO $ writeIORef cacheRef newSC
releaseLock
return res
where
SchemaCacheRef lk cacheRef = scr
onError e = releaseLock >> throwError e
acquireLock = liftIO $ takeMVar lk
releaseLock = liftIO $ putMVar lk ()
data ServerCtx
= ServerCtx
{ scIsolation :: Q.TxIsolation
, scPGPool :: Q.PGPool
, scLogger :: L.Logger
, scCacheRef :: SchemaCacheRef
, scAuthMode :: AuthMode
, scManager :: HTTP.Manager
, scStringifyNum :: Bool
, scEnabledAPIs :: S.HashSet API
, scInstanceId :: InstanceId
}
data HandlerCtx
= HandlerCtx
{ hcServerCtx :: ServerCtx
, hcReqBody :: BL.ByteString
, hcUser :: UserInfo
, hcReqHeaders :: [N.Header]
}
type Handler = ExceptT QErr (ReaderT HandlerCtx IO)
isMetadataEnabled :: ServerCtx -> Bool
isMetadataEnabled sc = S.member METADATA $ scEnabledAPIs sc
isGraphQLEnabled :: ServerCtx -> Bool
isGraphQLEnabled sc = S.member GRAPHQL $ scEnabledAPIs sc
-- {-# SCC parseBody #-}
parseBody :: (FromJSON a) => Handler a
parseBody = do
reqBody <- hcReqBody <$> ask
case decode' reqBody of
Just jVal -> decodeValue jVal
Nothing -> throw400 InvalidJSON "invalid json"
onlyAdmin :: Handler ()
onlyAdmin = do
uRole <- asks (userRole . hcUser)
when (uRole /= adminRole) $
throw400 AccessDenied "You have to be an admin to access this endpoint"
buildQCtx :: Handler QCtx
buildQCtx = do
scRef <- scCacheRef . hcServerCtx <$> ask
userInfo <- asks hcUser
cache <- liftIO $ readIORef $ _scrCache scRef
strfyNum <- scStringifyNum . hcServerCtx <$> ask
return $ QCtx userInfo cache $ SQLGenCtx strfyNum
logResult
:: (MonadIO m)
=> Maybe UserInfo -> Wai.Request -> BL.ByteString -> ServerCtx
-> Either QErr BL.ByteString -> Maybe (UTCTime, UTCTime)
-> m ()
logResult userInfoM req reqBody sc res qTime =
liftIO $ logger $ mkAccessLog userInfoM req (reqBody, res) qTime
where
logger = L.unLogger $ scLogger sc
logError
:: MonadIO m
=> Maybe UserInfo -> Wai.Request
-> BL.ByteString -> ServerCtx -> QErr -> m ()
logError userInfoM req reqBody sc qErr =
logResult userInfoM req reqBody sc (Left qErr) Nothing
mkSpockAction
:: (MonadIO m)
=> (Bool -> QErr -> Value)
-> ServerCtx
-> Handler BL.ByteString
-> ActionT m ()
mkSpockAction qErrEncoder serverCtx handler = do
req <- request
reqBody <- liftIO $ strictRequestBody req
let headers = requestHeaders req
authMode = scAuthMode serverCtx
manager = scManager serverCtx
userInfoE <- liftIO $ runExceptT $ getUserInfo logger manager headers authMode
userInfo <- either (logAndThrow req reqBody False) return userInfoE
let handlerState = HandlerCtx serverCtx reqBody userInfo headers
t1 <- liftIO getCurrentTime -- for measuring response time purposes
result <- liftIO $ runReaderT (runExceptT handler) handlerState
t2 <- liftIO getCurrentTime -- for measuring response time purposes
-- log result
logResult (Just userInfo) req reqBody serverCtx result $ Just (t1, t2)
either (qErrToResp $ userRole userInfo == adminRole) resToResp result
where
logger = scLogger serverCtx
-- encode error response
qErrToResp :: (MonadIO m) => Bool -> QErr -> ActionCtxT ctx m b
qErrToResp includeInternal qErr = do
setStatus $ qeStatus qErr
json $ qErrEncoder includeInternal qErr
logAndThrow req reqBody includeInternal qErr = do
logError Nothing req reqBody serverCtx qErr
qErrToResp includeInternal qErr
resToResp resp = do
uncurry setHeader jsonHeader
lazyBytes resp
v1QueryHandler :: RQLQuery -> Handler BL.ByteString
v1QueryHandler query = do
scRef <- scCacheRef . hcServerCtx <$> ask
bool (fst <$> dbAction) (withSCUpdate scRef dbActionReload) $
queryNeedsReload query
where
-- Hit postgres
dbAction = do
userInfo <- asks hcUser
scRef <- scCacheRef . hcServerCtx <$> ask
schemaCache <- liftIO $ readIORef $ _scrCache scRef
httpMgr <- scManager . hcServerCtx <$> ask
strfyNum <- scStringifyNum . hcServerCtx <$> ask
pool <- scPGPool . hcServerCtx <$> ask
isoL <- scIsolation . hcServerCtx <$> ask
instanceId <- scInstanceId . hcServerCtx <$> ask
runQuery pool isoL instanceId userInfo schemaCache httpMgr strfyNum query
-- Also update the schema cache
dbActionReload = do
(resp, newSc) <- dbAction
httpMgr <- scManager . hcServerCtx <$> ask
--FIXME: should we be fetching the remote schema again? if not how do we get the remote schema?
newGCtxMap <- GS.mkGCtxMap (scTables newSc) (scFunctions newSc)
(mergedGCtxMap, defGCtx) <-
mergeSchemas (scRemoteResolvers newSc) newGCtxMap httpMgr
let newSc' =
newSc { scGCtxMap = mergedGCtxMap, scDefaultRemoteGCtx = defGCtx }
return (resp, newSc')
v1Alpha1GQHandler :: GH.GraphQLRequest -> Handler BL.ByteString
v1Alpha1GQHandler query = do
userInfo <- asks hcUser
reqBody <- asks hcReqBody
reqHeaders <- asks hcReqHeaders
manager <- scManager . hcServerCtx <$> ask
scRef <- scCacheRef . hcServerCtx <$> ask
sc <- liftIO $ readIORef $ _scrCache scRef
pool <- scPGPool . hcServerCtx <$> ask
isoL <- scIsolation . hcServerCtx <$> ask
strfyNum <- scStringifyNum . hcServerCtx <$> ask
GH.runGQ pool isoL userInfo (SQLGenCtx strfyNum) sc manager reqHeaders query reqBody
gqlExplainHandler :: GE.GQLExplain -> Handler BL.ByteString
gqlExplainHandler query = do
onlyAdmin
scRef <- scCacheRef . hcServerCtx <$> ask
sc <- liftIO $ readIORef $ _scrCache scRef
pool <- scPGPool . hcServerCtx <$> ask
isoL <- scIsolation . hcServerCtx <$> ask
strfyNum <- scStringifyNum . hcServerCtx <$> ask
GE.explainGQLQuery pool isoL sc (SQLGenCtx strfyNum) query
newtype QueryParser
= QueryParser { getQueryParser :: QualifiedTable -> Handler RQLQuery }
queryParsers :: M.HashMap T.Text QueryParser
queryParsers =
M.fromList
[ ("select", mkQueryParser RQSelect)
, ("insert", mkQueryParser RQInsert)
, ("update", mkQueryParser RQUpdate)
, ("delete", mkQueryParser RQDelete)
, ("count", mkQueryParser RQCount)
]
where
mkQueryParser f =
QueryParser $ \qt -> do
obj <- parseBody
let val = Object $ M.insert "table" (toJSON qt) obj
q <- decodeValue val
return $ f q
legacyQueryHandler :: TableName -> T.Text -> Handler BL.ByteString
legacyQueryHandler tn queryType =
case M.lookup queryType queryParsers of
Just queryParser -> getQueryParser queryParser qt >>= v1QueryHandler
Nothing -> throw404 "No such resource exists"
where
qt = QualifiedObject publicSchema tn
mkWaiApp
:: Q.TxIsolation -> L.LoggerCtx -> Bool
-> Q.PGPool -> HTTP.Manager -> AuthMode
-> CorsConfig -> Bool -> Bool
-> InstanceId -> S.HashSet API
-> IO (Wai.Application, SchemaCacheRef, Maybe UTCTime)
mkWaiApp isoLevel loggerCtx strfyNum pool httpManager mode corsCfg
enableConsole enableTelemetry instanceId apis = do
(cacheRef, cacheBuiltTime) <- do
pgResp <- runExceptT $ peelRun emptySchemaCache adminUserInfo
httpManager strfyNum pool Q.Serializable $ do
buildSchemaCache
liftTx fetchLastUpdate
(time, sc) <- either initErrExit return pgResp
scRef <- newIORef sc
return (scRef, snd <$> time)
cacheLock <- newMVar ()
let schemaCacheRef = SchemaCacheRef cacheLock cacheRef
serverCtx = ServerCtx isoLevel pool (L.mkLogger loggerCtx)
schemaCacheRef mode httpManager strfyNum apis instanceId
spockApp <- spockAsApp $ spockT id $
httpApp corsCfg serverCtx enableConsole enableTelemetry
let runTx tx = runExceptT $ runLazyTx pool isoLevel tx
corsPolicy = mkDefaultCorsPolicy corsCfg
sqlGenCtx = SQLGenCtx strfyNum
wsServerEnv <- WS.createWSServerEnv (scLogger serverCtx) httpManager sqlGenCtx
cacheRef runTx corsPolicy
let wsServerApp = WS.createWSServerApp mode wsServerEnv
return ( WS.websocketsOr WS.defaultConnectionOptions wsServerApp spockApp
, schemaCacheRef
, cacheBuiltTime
)
httpApp :: CorsConfig -> ServerCtx -> Bool -> Bool -> SpockT IO ()
httpApp corsCfg serverCtx enableConsole enableTelemetry = do
-- cors middleware
unless (isCorsDisabled corsCfg) $
middleware $ corsMiddleware (mkDefaultCorsPolicy corsCfg)
-- API Console and Root Dir
when (enableConsole && enableMetadata) serveApiConsole
get "v1/version" $ do
uncurry setHeader jsonHeader
lazyBytes $ encode $ object [ "version" .= currentVersion ]
when enableMetadata $ do
get ("v1/template" <//> var) tmpltGetOrDeleteH
post ("v1/template" <//> var) tmpltPutOrPostH
put ("v1/template" <//> var) tmpltPutOrPostH
delete ("v1/template" <//> var) tmpltGetOrDeleteH
post "v1/query" $ mkSpockAction encodeQErr serverCtx $ do
query <- parseBody
v1QueryHandler query
post ("api/1/table" <//> var <//> var) $ \tableName queryType ->
mkSpockAction encodeQErr serverCtx $
legacyQueryHandler (TableName tableName) queryType
when enableGraphQL $ do
post "v1alpha1/graphql/explain" $ mkSpockAction encodeQErr serverCtx $ do
expQuery <- parseBody
gqlExplainHandler expQuery
post "v1alpha1/graphql" $ mkSpockAction GH.encodeGQErr serverCtx $ do
query <- parseBody
v1Alpha1GQHandler query
-- get "v1alpha1/graphql/schema" $
-- mkSpockAction encodeQErr serverCtx v1Alpha1GQSchemaHandler
forM_ [GET,POST] $ \m -> hookAny m $ \_ -> do
let qErr = err404 NotFound "resource does not exist"
raiseGenericApiError qErr
where
enableGraphQL = isGraphQLEnabled serverCtx
enableMetadata = isMetadataEnabled serverCtx
tmpltGetOrDeleteH tmpltName = do
tmpltArgs <- tmpltArgsFromQueryParams
mkSpockAction encodeQErr serverCtx $ mkQTemplateAction tmpltName tmpltArgs
tmpltPutOrPostH tmpltName = do
tmpltArgs <- tmpltArgsFromQueryParams
mkSpockAction encodeQErr serverCtx $ do
bodyTmpltArgs <- parseBody
mkQTemplateAction tmpltName $ M.union bodyTmpltArgs tmpltArgs
tmpltArgsFromQueryParams = do
qparams <- params
return $ M.fromList $ flip map qparams $
TemplateParam *** String
mkQTemplateAction tmpltName tmpltArgs =
v1QueryHandler $ RQExecuteQueryTemplate $
ExecQueryTemplate (TQueryName tmpltName) tmpltArgs
raiseGenericApiError qErr = do
req <- request
reqBody <- liftIO $ strictRequestBody req
logError Nothing req reqBody serverCtx qErr
uncurry setHeader jsonHeader
setStatus $ qeStatus qErr
lazyBytes $ encode qErr
serveApiConsole = do
get root $ redirect "console"
get ("console" <//> wildcard) $ \path ->
either (raiseGenericApiError . err500 Unexpected . T.pack) html $
mkConsoleHTML path (scAuthMode serverCtx) enableTelemetry
#ifdef LocalConsole
get "static/main.js" $ do
setHeader "Content-Type" "text/javascript;charset=UTF-8"
bytes $(FE.embedFile "../console/static/dist/main.js")
get "static/main.css" $ do
setHeader "Content-Type" "text/css;charset=UTF-8"
bytes $(FE.embedFile "../console/static/dist/main.css")
get "static/vendor.js" $ do
setHeader "Content-Type" "text/javascript;charset=UTF-8"
bytes $(FE.embedFile "../console/static/dist/vendor.js")
#endif