mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-18 04:51:35 +03:00
6e574f1bbe
## Description ### I want to speak to the `Manager` Oh boy. This PR is both fairly straightforward and overreaching, so let's break it down. For most network access, we need a [`HTTP.Manager`](https://hackage.haskell.org/package/http-client-0.1.0.0/docs/Network-HTTP-Client-Manager.html). It is created only once, at the top level, when starting the engine, and is then threaded through the application to wherever we need to make a network call. As of main, the way we do this is not standardized: most of the GraphQL execution code passes it "manually" as a function argument throughout the code. We also have a custom monad constraint, `HasHttpManagerM`, that describes a monad's ability to provide a manager. And, finally, several parts of the code store the manager in some kind of argument structure, such as `RunT`'s `RunCtx`. This PR's first goal is to harmonize all of this: we always create the manager at the root, and we already have it when we do our very first `runReaderT`. Wouldn't it make sense for the rest of the code to not manually pass it anywhere, to not store it anywhere, but to always rely on the current monad providing it? This is, in short, what this PR does: it implements a constraint on the base monads, so that they provide the manager, and removes most explicit passing from the code. ### First come, first served One way this PR goes a tiny bit further than "just" doing the aforementioned harmonization is that it starts the process of implementing the "Services oriented architecture" roughly outlined in this [draft document](https://docs.google.com/document/d/1FAigqrST0juU1WcT4HIxJxe1iEBwTuBZodTaeUvsKqQ/edit?usp=sharing). Instead of using the existing `HasHTTPManagerM`, this PR revamps it into the `ProvidesNetwork` service. The idea is, again, that we should make all "external" dependencies of the engine, all things that the core of the engine doesn't care about, a "service". This allows us to define clear APIs for features, to choose different implementations based on which version of the engine we're running, harmonizes our many scattered monadic constraints... Which is why this service is called "Network": we can refine it, moving forward, to be the constraint that defines how all network communication is to operate, instead of relying on disparate classes constraint or hardcoded decisions. A comment in the code clarifies this intent. ### Side-effects? In my Haskell? This PR also unavoidably touches some other aspects of the codebase. One such example: it introduces `Hasura.App.AppContext`, named after `HasuraPro.Context.AppContext`: a name for the reader structure at the base level. It also transforms `Handler` from a type alias to a newtype, as `Handler` is where we actually enforce HTTP limits; but without `Handler` being a distinct type, any code path could simply do a `runExceptT $ runReader` and forget to enforce them. (As a rule of thumb, i am starting to consider any straggling `runReaderT` or `runExceptT` as a code smell: we should not stack / unstack monads haphazardly, and every layer should be an opaque `newtype` with a corresponding run function.) ## Further work In several places, i have left TODOs when i have encountered things that suggest that we should do further unrelated cleanups. I'll write down the follow-up steps, either in the aforementioned document or on slack. But, in short, at a glance, in approximate order, we could: - delete `ExecutionCtx` as it is only a subset of `ServerCtx`, and remove one more `runReaderT` call - delete `ServerConfigCtx` as it is only a subset of `ServerCtx`, and remove it from `RunCtx` - remove `ServerCtx` from `HandlerCtx`, and make it part of `AppContext`, or even make it the `AppContext` altogether (since, at least for the OSS version, `AppContext` is there again only a subset) - remove `CacheBuildParams` and `CacheBuild` altogether, as they're just a distinct stack that is a `ReaderT` on top of `IO` that contains, you guessed it, the same thing as `ServerCtx` - move `RunT` out of `RQL.Types` and rename it, since after the previous cleanups **it only contains `UserInfo`**; it could be bundled with the authentication service, made a small implementation detail in `Hasura.Server.Auth` - rename `PGMetadaStorageT` to something a bit more accurate, such as `App`, and enforce its IO base This would significantly simply our complex stack. From there, or in parallel, we can start moving existing dependencies as Services. For the purpose of supporting read replicas entitlement, we could move `MonadResolveSource` to a `SourceResolver` service, as attempted in #7653, and transform `UserAuthenticationM` into a `Authentication` service. PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7736 GitOrigin-RevId: 68cce710eb9e7d752bda1ba0c49541d24df8209f
828 lines
36 KiB
Haskell
828 lines
36 KiB
Haskell
{-# LANGUAGE QuasiQuotes #-}
|
|
|
|
module Hasura.GraphQL.Execute.Action
|
|
( fetchActionLogResponses,
|
|
runActionExecution,
|
|
asyncActionsProcessor,
|
|
resolveActionExecution,
|
|
resolveActionMutationAsync,
|
|
resolveAsyncActionQuery,
|
|
insertActionTx,
|
|
fetchUndeliveredActionEventsTx,
|
|
setActionStatusTx,
|
|
fetchActionResponseTx,
|
|
clearActionDataTx,
|
|
setProcessingActionLogsToPendingTx,
|
|
LockedActionIdArray (..),
|
|
module Types,
|
|
)
|
|
where
|
|
|
|
import Control.Concurrent.Async.Lifted.Safe qualified as LA
|
|
import Control.Concurrent.Extended (Forever (..), sleep)
|
|
import Control.Concurrent.STM qualified as STM
|
|
import Control.Exception (try)
|
|
import Control.Lens
|
|
import Control.Monad.Trans.Control (MonadBaseControl)
|
|
import Data.Aeson qualified as J
|
|
import Data.Aeson.Key qualified as K
|
|
import Data.Aeson.KeyMap qualified as KM
|
|
import Data.Aeson.Ordered qualified as AO
|
|
import Data.ByteString.Lazy qualified as BL
|
|
import Data.CaseInsensitive qualified as CI
|
|
import Data.Environment qualified as Env
|
|
import Data.Has
|
|
import Data.HashMap.Strict qualified as Map
|
|
import Data.SerializableBlob qualified as SB
|
|
import Data.Set (Set)
|
|
import Data.Text.Extended
|
|
import Data.Text.NonEmpty
|
|
import Database.PG.Query qualified as PG
|
|
import Hasura.Backends.Postgres.Connection.MonadTx
|
|
import Hasura.Backends.Postgres.Execute.Prepare
|
|
import Hasura.Backends.Postgres.Execute.Types
|
|
import Hasura.Backends.Postgres.SQL.DML qualified as S
|
|
import Hasura.Backends.Postgres.SQL.Types
|
|
import Hasura.Backends.Postgres.SQL.Value (PGScalarValue (..))
|
|
import Hasura.Backends.Postgres.Translate.Select qualified as RS
|
|
import Hasura.Backends.Postgres.Translate.Select.Internal.Helpers (selectToSelectWith, toQuery)
|
|
import Hasura.Backends.Postgres.Types.Function qualified as TF
|
|
import Hasura.Base.Error
|
|
import Hasura.EncJSON
|
|
import Hasura.Eventing.Common
|
|
import Hasura.GraphQL.Execute.Action.Types as Types
|
|
import Hasura.GraphQL.Parser.Name qualified as GName
|
|
import Hasura.GraphQL.Schema.Options qualified as Options
|
|
import Hasura.GraphQL.Transport.HTTP.Protocol as GH
|
|
import Hasura.HTTP
|
|
import Hasura.Logging qualified as L
|
|
import Hasura.Metadata.Class
|
|
import Hasura.Name qualified as Name
|
|
import Hasura.Prelude
|
|
import Hasura.RQL.DDL.Headers
|
|
import Hasura.RQL.DDL.Webhook.Transform
|
|
import Hasura.RQL.IR.Action qualified as IR
|
|
import Hasura.RQL.IR.BoolExp
|
|
import Hasura.RQL.IR.Select qualified as RS
|
|
import Hasura.RQL.IR.Value qualified as IR
|
|
import Hasura.RQL.Types.Action
|
|
import Hasura.RQL.Types.Column
|
|
import Hasura.RQL.Types.Common
|
|
import Hasura.RQL.Types.ComputedField
|
|
import Hasura.RQL.Types.CustomTypes
|
|
import Hasura.RQL.Types.Eventing
|
|
import Hasura.RQL.Types.Function
|
|
import Hasura.RQL.Types.SchemaCache
|
|
import Hasura.SQL.Backend
|
|
import Hasura.Server.Prometheus (PrometheusMetrics (..))
|
|
import Hasura.Server.Utils
|
|
( mkClientHeadersForward,
|
|
mkSetCookieHeaders,
|
|
)
|
|
import Hasura.Services.Network
|
|
import Hasura.Session
|
|
import Hasura.Tracing qualified as Tracing
|
|
import Language.GraphQL.Draft.Syntax qualified as G
|
|
import Network.HTTP.Client.Transformable qualified as HTTP
|
|
import Network.Wreq qualified as Wreq
|
|
import System.Metrics.Prometheus.Counter as Prometheus.Counter
|
|
|
|
fetchActionLogResponses ::
|
|
(MonadError QErr m, MonadMetadataStorage m, Foldable t) =>
|
|
t ActionId ->
|
|
m (ActionLogResponseMap, Bool)
|
|
fetchActionLogResponses actionIds = do
|
|
responses <- for (toList actionIds) $ \actionId ->
|
|
(actionId,)
|
|
<$> liftEitherM (fetchActionResponse actionId)
|
|
-- An action is said to be completed/processed iff response is captured from webhook or
|
|
-- in case any exception occured in calling webhook.
|
|
let isActionComplete ActionLogResponse {..} =
|
|
isJust _alrResponsePayload || isJust _alrErrors
|
|
pure (Map.fromList responses, all (isActionComplete . snd) responses)
|
|
|
|
runActionExecution ::
|
|
( MonadIO m,
|
|
MonadBaseControl IO m,
|
|
MonadError QErr m,
|
|
Tracing.MonadTrace m,
|
|
MonadMetadataStorage m
|
|
) =>
|
|
UserInfo ->
|
|
ActionExecutionPlan ->
|
|
m (DiffTime, (EncJSON, Maybe HTTP.ResponseHeaders))
|
|
runActionExecution userInfo aep =
|
|
withElapsedTime $ case aep of
|
|
AEPSync e -> second Just <$> unActionExecution e
|
|
AEPAsyncQuery (AsyncActionQueryExecutionPlan actionId execution) -> do
|
|
actionLogResponse <- liftEitherM $ fetchActionResponse actionId
|
|
(,Nothing) <$> case execution of
|
|
AAQENoRelationships f -> liftEither $ f actionLogResponse
|
|
AAQEOnSourceDB srcConfig (AsyncActionQuerySourceExecution _ jsonAggSelect f) -> do
|
|
let selectAST = f actionLogResponse
|
|
selectResolved <- traverse (prepareWithoutPlan userInfo) selectAST
|
|
let querySQL = toQuery $ selectToSelectWith $ RS.mkSQLSelect jsonAggSelect selectResolved
|
|
liftEitherM $ runExceptT $ _pecRunTx (_pscExecCtx srcConfig) (PGExecCtxInfo (Tx PG.ReadOnly Nothing) InternalRawQuery) $ liftTx $ asSingleRowJsonResp querySQL []
|
|
AEPAsyncMutation actionId -> pure $ (,Nothing) $ encJFromJValue $ actionIdToText actionId
|
|
|
|
-- | This function is generally used on the result of 'selectQuerySQL',
|
|
-- 'selectAggregateQuerySQL' or 'connectionSelectSQL' to run said query and get
|
|
-- back the resulting JSON.
|
|
asSingleRowJsonResp ::
|
|
PG.Query ->
|
|
[PG.PrepArg] ->
|
|
PG.TxE QErr EncJSON
|
|
asSingleRowJsonResp query args =
|
|
runIdentity . PG.getRow
|
|
<$> PG.rawQE dmlTxErrorHandler query args True
|
|
|
|
-- | Synchronously execute webhook handler and resolve response to action "output"
|
|
resolveActionExecution ::
|
|
HTTP.Manager ->
|
|
Env.Environment ->
|
|
L.Logger L.Hasura ->
|
|
PrometheusMetrics ->
|
|
UserInfo ->
|
|
IR.AnnActionExecution Void ->
|
|
ActionExecContext ->
|
|
Maybe GQLQueryText ->
|
|
ActionExecution
|
|
resolveActionExecution httpManager env logger prometheusMetrics _userInfo IR.AnnActionExecution {..} ActionExecContext {..} gqlQueryText =
|
|
ActionExecution $ first (encJFromOrderedValue . makeActionResponseNoRelations _aaeFields _aaeOutputType _aaeOutputFields True) <$> runWebhook
|
|
where
|
|
handlerPayload = ActionWebhookPayload (ActionContext _aaeName) _aecSessionVariables _aaePayload gqlQueryText
|
|
|
|
runWebhook ::
|
|
(MonadIO m, MonadError QErr m, Tracing.MonadTrace m) =>
|
|
m (ActionWebhookResponse, HTTP.ResponseHeaders)
|
|
runWebhook =
|
|
-- TODO: do we need to add the logger as a reader? can't we just give it as an argument?
|
|
flip runReaderT logger $
|
|
callWebhook
|
|
env
|
|
httpManager
|
|
prometheusMetrics
|
|
_aaeOutputType
|
|
_aaeOutputFields
|
|
_aecHeaders
|
|
_aaeHeaders
|
|
_aaeForwardClientHeaders
|
|
_aaeWebhook
|
|
handlerPayload
|
|
_aaeTimeOut
|
|
_aaeRequestTransform
|
|
_aaeResponseTransform
|
|
|
|
throwUnexpected :: (MonadError QErr m) => Text -> m ()
|
|
throwUnexpected = throw400 Unexpected
|
|
|
|
-- Webhook response object should conform to action output fields
|
|
validateResponseObject :: MonadError QErr m => KM.KeyMap J.Value -> IR.ActionOutputFields -> m ()
|
|
validateResponseObject obj outputField = do
|
|
-- Note: Fields not specified in the output are ignored
|
|
void $
|
|
flip Map.traverseWithKey outputField $ \fieldName fieldTy ->
|
|
-- When field is non-nullable, it has to present in the response with no null value
|
|
unless (G.isNullable fieldTy) $ case KM.lookup (K.fromText $ G.unName fieldName) obj of
|
|
Nothing ->
|
|
throwUnexpected $
|
|
"field " <> fieldName <<> " expected in webhook response, but not found"
|
|
Just v ->
|
|
when (v == J.Null) $
|
|
throwUnexpected $
|
|
"expecting not null value for field " <>> fieldName
|
|
|
|
-- Validates the webhook response against the output type
|
|
validateResponse :: (MonadError QErr m) => J.Value -> GraphQLType -> IR.ActionOutputFields -> m ()
|
|
validateResponse webhookResponse' outputType outputF =
|
|
unless (isCustomScalar (unGraphQLType outputType) outputF) do
|
|
case (webhookResponse', outputType) of
|
|
(J.Null, _) -> do
|
|
unless (isNullableType outputType) $ throwUnexpected "got null for the action webhook response"
|
|
(J.Number _, (GraphQLType (G.TypeNamed _ name))) -> do
|
|
unless (name == GName._Int || name == GName._Float) $
|
|
throwUnexpected $
|
|
"got scalar String for the action webhook response, expecting " <> G.unName name
|
|
(J.Bool _, (GraphQLType (G.TypeNamed _ name))) -> do
|
|
unless (name == GName._Boolean) $
|
|
throwUnexpected $
|
|
"got scalar Boolean for the action webhook response, expecting " <> G.unName name
|
|
(J.String _, (GraphQLType (G.TypeNamed _ name))) -> do
|
|
unless (name == GName._String || name == GName._ID) $
|
|
throwUnexpected $
|
|
"got scalar String for the action webhook response, expecting " <> G.unName name
|
|
(J.Array _, (GraphQLType (G.TypeNamed _ name))) -> throwUnexpected $ "got array for the action webhook response, expecting " <> G.unName name
|
|
(J.Array objs, (GraphQLType (G.TypeList _ outputType''))) -> do
|
|
traverse_ (\o -> validateResponse o (GraphQLType outputType'') outputF) objs
|
|
((J.Object obj), (GraphQLType (G.TypeNamed _ name))) -> do
|
|
when (isInBuiltScalar (G.unName name)) $
|
|
throwUnexpected $
|
|
"got object for the action webhook response, expecting " <> G.unName name
|
|
validateResponseObject obj outputF
|
|
(_, (GraphQLType (G.TypeList _ _))) ->
|
|
throwUnexpected $ "expecting array for the action webhook response"
|
|
|
|
-- | Build action response from the Webhook JSON response when there are no relationships defined
|
|
makeActionResponseNoRelations :: IR.ActionFields -> GraphQLType -> IR.ActionOutputFields -> Bool -> ActionWebhookResponse -> AO.Value
|
|
makeActionResponseNoRelations annFields outputType outputF shouldCheckOutputField webhookResponse =
|
|
let mkResponseObject :: IR.ActionFields -> KM.KeyMap J.Value -> AO.Value
|
|
mkResponseObject fields obj =
|
|
AO.object $
|
|
flip mapMaybe fields $ \(fieldName, annField) ->
|
|
let fieldText = getFieldNameTxt fieldName
|
|
in (fieldText,) <$> case annField of
|
|
IR.ACFExpression t -> Just $ AO.String t
|
|
IR.ACFScalar fname -> Just $ maybe AO.Null AO.toOrdered (KM.lookup (K.fromText $ G.unName fname) obj)
|
|
IR.ACFNestedObject _ nestedFields -> do
|
|
let mkValue :: J.Value -> Maybe AO.Value
|
|
mkValue = \case
|
|
J.Object o -> Just $ mkResponseObject nestedFields o
|
|
J.Array a -> Just $ AO.array $ mapMaybe mkValue $ toList a
|
|
J.Null -> Just AO.Null
|
|
_ -> Nothing
|
|
KM.lookup (K.fromText fieldText) obj >>= mkValue
|
|
mkResponseArray :: J.Value -> AO.Value
|
|
mkResponseArray = \case
|
|
(J.Object o) -> mkResponseObject annFields o
|
|
x -> AO.toOrdered x
|
|
in -- NOTE (Sam): This case would still not allow for aliased fields to be
|
|
-- a part of the response. Also, seeing that none of the other `annField`
|
|
-- types would be caught in the example, I've chosen to leave it as it is.
|
|
-- NOTE: (Pranshi) Bool here is applied to specify if we want to check ActionOutputFields
|
|
-- (in async actions, we have object types, which need to be validated
|
|
-- and ActionOutputField information is not present in `resolveAsyncActionQuery` -
|
|
-- so added a boolean which will make sure that the response is validated)
|
|
if gTypeContains isCustomScalar (unGraphQLType outputType) outputF && shouldCheckOutputField
|
|
then AO.toOrdered $ J.toJSON webhookResponse
|
|
else case webhookResponse of
|
|
J.Array objs -> AO.array $ toList $ fmap mkResponseArray objs
|
|
J.Object obj ->
|
|
mkResponseObject annFields obj
|
|
J.Null -> AO.Null
|
|
_ -> AO.toOrdered $ J.toJSON webhookResponse
|
|
|
|
gTypeContains :: (G.GType -> IR.ActionOutputFields -> Bool) -> G.GType -> IR.ActionOutputFields -> Bool
|
|
gTypeContains fun gType aof = case gType of
|
|
t@(G.TypeNamed _ _) -> fun t aof
|
|
(G.TypeList _ expectedType) -> gTypeContains fun expectedType aof
|
|
|
|
isCustomScalar :: G.GType -> IR.ActionOutputFields -> Bool
|
|
isCustomScalar (G.TypeNamed _ name) outputF = isJust (lookup (G.unName name) pgScalarTranslations) || (Map.null outputF && (not (isInBuiltScalar (G.unName name))))
|
|
isCustomScalar (G.TypeList _ _) _ = False
|
|
|
|
{- Note: [Async action architecture]
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
In async actions, acquiring the action result is deferred. The async action mutation is made to
|
|
initiate the action which returns an UUID. The UUID is used to query/subsribe for actions response.
|
|
|
|
On mutation, the server makes an action log record in the metadata storage with request headers
|
|
and input arguments. The `asyncActionsProcessor` background thread processes the async actions by executing
|
|
the webhook handler and writing back the response payload or errors if any in the metadata storage.
|
|
|
|
When an async action query/subscription is made, the server fetches the relavent data from the
|
|
metadata storage. See Note [Resolving async action query] below.
|
|
-}
|
|
|
|
-- | Resolve asynchronous action mutation which returns only the action uuid
|
|
resolveActionMutationAsync ::
|
|
(MonadMetadataStorage m, MonadError QErr m) =>
|
|
IR.AnnActionMutationAsync ->
|
|
[HTTP.Header] ->
|
|
SessionVariables ->
|
|
m ActionId
|
|
resolveActionMutationAsync annAction reqHeaders sessionVariables =
|
|
liftEitherM $ insertAction actionName sessionVariables reqHeaders inputArgs
|
|
where
|
|
IR.AnnActionMutationAsync actionName _ inputArgs = annAction
|
|
|
|
{- Note: [Resolving async action query]
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
Resolving async action query happens in two steps;
|
|
|
|
1. Fetching webhook response from the metadata storage:
|
|
In this step, using @'fetchActionResponse' method of @'MonadMetadataStorage' type class, we fetch
|
|
the webhook response of any async action mutation (See Note [Async action architecture] for more)
|
|
|
|
2. Generating client response:
|
|
Generation of appropriate client response happens in two ways based on the availibility of
|
|
relationships on action output object type.
|
|
|
|
a. With relationships:- If there are any relationships defined to tables in a source,
|
|
we need to join the rows from the tables. So, we'll generate a SELECT SQL statement from
|
|
a table built virtually from action webhook response fetched in the Step 1 and joining it
|
|
with appropriate tables. Then, we execute this SQL in the source database.
|
|
|
|
SELECT .... FROM pg_catalog.jsonb_to_recordset('action webhook response')
|
|
JOIN .... (SELECT ... FROM <source relationship table>)
|
|
|
|
b. Without relationships:- In this case, we'll build the response purely in Haskell code from
|
|
the action webhook response fetched in the Step 1.
|
|
-}
|
|
|
|
-- TODO: Add tracing here? Avoided now because currently the function is pure
|
|
|
|
-- | See Note: [Resolving async action query]
|
|
resolveAsyncActionQuery ::
|
|
UserInfo ->
|
|
IR.AnnActionAsyncQuery ('Postgres 'Vanilla) Void ->
|
|
AsyncActionQueryExecution (IR.UnpreparedValue ('Postgres 'Vanilla))
|
|
resolveAsyncActionQuery userInfo annAction =
|
|
case actionSource of
|
|
IR.ASINoSource -> AAQENoRelationships \actionLogResponse -> runExcept do
|
|
let ActionLogResponse {..} = actionLogResponse
|
|
resolvedFields <- for asyncFields $ \(fieldName, fld) -> do
|
|
let fieldText = getFieldNameTxt fieldName
|
|
(fieldText,) <$> case fld of
|
|
IR.AsyncTypename t -> pure $ AO.String t
|
|
IR.AsyncOutput annFields ->
|
|
fromMaybe AO.Null <$> forM
|
|
_alrResponsePayload
|
|
\response -> makeActionResponseNoRelations annFields outputType Map.empty False <$> decodeValue response
|
|
IR.AsyncId -> pure $ AO.String $ actionIdToText actionId
|
|
IR.AsyncCreatedAt -> pure $ AO.toOrdered $ J.toJSON _alrCreatedAt
|
|
IR.AsyncErrors -> pure $ AO.toOrdered $ J.toJSON _alrErrors
|
|
pure $ encJFromOrderedValue $ AO.object resolvedFields
|
|
IR.ASISource sourceName sourceConfig ->
|
|
let jsonAggSelect = mkJsonAggSelect outputType
|
|
in AAQEOnSourceDB sourceConfig $
|
|
AsyncActionQuerySourceExecution sourceName jsonAggSelect $ \actionLogResponse ->
|
|
let annotatedFields =
|
|
asyncFields <&> second \case
|
|
IR.AsyncTypename t -> RS.AFExpression t
|
|
IR.AsyncOutput annFields ->
|
|
RS.AFComputedField () (ComputedFieldName [nonEmptyTextQQ|__action_computed_field|]) $
|
|
RS.CFSTable jsonAggSelect $
|
|
processOutputSelectionSet TF.AEActionResponsePayload outputType definitionList annFields stringifyNumerics
|
|
IR.AsyncId -> mkAnnFldFromPGCol idColumn
|
|
IR.AsyncCreatedAt -> mkAnnFldFromPGCol createdAtColumn
|
|
IR.AsyncErrors -> mkAnnFldFromPGCol errorsColumn
|
|
|
|
jsonbToRecordSet = QualifiedObject "pg_catalog" $ FunctionName "jsonb_to_recordset"
|
|
actionLogInput =
|
|
IR.UVParameter Nothing $
|
|
ColumnValue (ColumnScalar PGJSONB) $
|
|
PGValJSONB $
|
|
PG.JSONB $
|
|
J.toJSON [actionLogResponse]
|
|
functionArgs = FunctionArgsExp [TF.AEInput actionLogInput] mempty
|
|
tableFromExp =
|
|
RS.FromFunction jsonbToRecordSet functionArgs $
|
|
Just
|
|
[idColumn, createdAtColumn, responsePayloadColumn, errorsColumn, sessionVarsColumn]
|
|
tableArguments =
|
|
RS.noSelectArgs
|
|
{ RS._saWhere = Just tableBoolExpression
|
|
}
|
|
tablePermissions = RS.TablePerm annBoolExpTrue Nothing
|
|
in RS.AnnSelectG annotatedFields tableFromExp tablePermissions tableArguments stringifyNumerics Nothing
|
|
where
|
|
IR.AnnActionAsyncQuery _ actionId outputType asyncFields definitionList stringifyNumerics _ actionSource = annAction
|
|
|
|
idColumn = (unsafePGCol "id", PGUUID)
|
|
responsePayloadColumn = (unsafePGCol TF.actionResponsePayloadColumn, PGJSONB)
|
|
createdAtColumn = (unsafePGCol "created_at", PGTimeStampTZ)
|
|
errorsColumn = (unsafePGCol "errors", PGJSONB)
|
|
sessionVarsColumn = (unsafePGCol "session_variables", PGJSONB)
|
|
|
|
mkAnnFldFromPGCol (column', columnType) =
|
|
RS.mkAnnColumnField column' (ColumnScalar columnType) Nothing Nothing
|
|
|
|
tableBoolExpression =
|
|
let actionIdColumnInfo =
|
|
ColumnInfo
|
|
{ ciColumn = fst idColumn,
|
|
ciName = Name._id,
|
|
ciPosition = 0,
|
|
ciType = ColumnScalar (snd idColumn),
|
|
ciIsNullable = False,
|
|
ciDescription = Nothing,
|
|
ciMutability = ColumnMutability False False
|
|
}
|
|
actionIdColumnEq = BoolField $ AVColumn actionIdColumnInfo [AEQ True $ IR.UVLiteral $ S.SELit $ actionIdToText actionId]
|
|
-- TODO: avoid using ColumnInfo
|
|
sessionVarsColumnInfo =
|
|
ColumnInfo
|
|
{ ciColumn = unsafePGCol "session_variables",
|
|
ciName = Name._session_variables,
|
|
ciPosition = 0,
|
|
ciType = ColumnScalar (snd sessionVarsColumn),
|
|
ciIsNullable = True,
|
|
ciDescription = Nothing,
|
|
ciMutability = ColumnMutability False False
|
|
}
|
|
sessionVarValue =
|
|
IR.UVParameter Nothing $
|
|
ColumnValue (ColumnScalar PGJSONB) $
|
|
PGValJSONB $
|
|
PG.JSONB $
|
|
J.toJSON $
|
|
_uiSession userInfo
|
|
sessionVarsColumnEq = BoolField $ AVColumn sessionVarsColumnInfo [AEQ True sessionVarValue]
|
|
in -- For non-admin roles, accessing an async action's response should be allowed only for the user
|
|
-- who initiated the action through mutation. The action's response is accessible for a query/subscription
|
|
-- only when it's session variables are equal to that of action's.
|
|
if (adminRoleName == (_uiRole userInfo))
|
|
then actionIdColumnEq
|
|
else BoolAnd [actionIdColumnEq, sessionVarsColumnEq]
|
|
|
|
-- | Process async actions from hdb_catalog.hdb_action_log table. This functions is executed in a background thread.
|
|
-- See Note [Async action architecture] above
|
|
asyncActionsProcessor ::
|
|
forall m.
|
|
( MonadIO m,
|
|
MonadBaseControl IO m,
|
|
LA.Forall (LA.Pure m),
|
|
Tracing.HasReporter m,
|
|
MonadMetadataStorage m,
|
|
ProvidesNetwork m
|
|
) =>
|
|
Env.Environment ->
|
|
L.Logger L.Hasura ->
|
|
IO SchemaCache ->
|
|
STM.TVar (Set LockedActionEventId) ->
|
|
PrometheusMetrics ->
|
|
Milliseconds ->
|
|
Maybe GH.GQLQueryText ->
|
|
m (Forever m)
|
|
asyncActionsProcessor env logger getSCFromRef' lockedActionEvents prometheusMetrics sleepTime gqlQueryText =
|
|
return $
|
|
Forever () $
|
|
const $ do
|
|
actionCache <- scActions <$> liftIO getSCFromRef'
|
|
let asyncActions =
|
|
Map.filter ((== ActionMutation ActionAsynchronous) . (^. aiDefinition . adType)) actionCache
|
|
unless (Map.null asyncActions) $ do
|
|
-- fetch undelivered action events only when there's at least
|
|
-- one async action present in the schema cache
|
|
asyncInvocationsE <- fetchUndeliveredActionEvents
|
|
asyncInvocations <- liftIO $ onLeft asyncInvocationsE mempty
|
|
-- save the actions that are currently fetched from the DB to
|
|
-- be processed in a TVar (Set LockedActionEventId) and when
|
|
-- the action is processed we remove it from the set. This set
|
|
-- is maintained because on shutdown of the graphql-engine, we
|
|
-- would like to wait for a certain time (see `--graceful-shutdown-time`)
|
|
-- during which to complete all the in-flight actions. So, when this
|
|
-- locked action events set TVar is empty, it will mean that there are
|
|
-- no events that are in the 'processing' state
|
|
saveLockedEvents (map (EventId . actionIdToText . _aliId) asyncInvocations) lockedActionEvents
|
|
LA.mapConcurrently_ (callHandler actionCache) asyncInvocations
|
|
liftIO $ sleep $ milliseconds sleepTime
|
|
where
|
|
callHandler :: ActionCache -> ActionLogItem -> m ()
|
|
callHandler actionCache actionLogItem = Tracing.runTraceT Tracing.sampleAlways "async actions processor" do
|
|
httpManager <- askHTTPManager
|
|
let ActionLogItem
|
|
actionId
|
|
actionName
|
|
reqHeaders
|
|
sessionVariables
|
|
inputPayload = actionLogItem
|
|
case Map.lookup actionName actionCache of
|
|
Nothing -> return ()
|
|
Just actionInfo -> do
|
|
let definition = _aiDefinition actionInfo
|
|
outputFields = IR.getActionOutputFields $ snd $ _aiOutputType actionInfo
|
|
webhookUrl = _adHandler definition
|
|
forwardClientHeaders = _adForwardClientHeaders definition
|
|
confHeaders = _adHeaders definition
|
|
timeout = _adTimeout definition
|
|
outputType = _adOutputType definition
|
|
actionContext = ActionContext actionName
|
|
metadataRequestTransform = _adRequestTransform definition
|
|
metadataResponseTransform = _adResponseTransform definition
|
|
eitherRes <-
|
|
runExceptT $
|
|
-- TODO: do we need to add the logger as a reader? can't we just give it as an argument?
|
|
flip runReaderT logger $
|
|
callWebhook
|
|
env
|
|
httpManager
|
|
prometheusMetrics
|
|
outputType
|
|
outputFields
|
|
reqHeaders
|
|
confHeaders
|
|
forwardClientHeaders
|
|
webhookUrl
|
|
(ActionWebhookPayload actionContext sessionVariables inputPayload gqlQueryText)
|
|
timeout
|
|
metadataRequestTransform
|
|
metadataResponseTransform
|
|
resE <-
|
|
setActionStatus actionId $ case eitherRes of
|
|
Left e -> AASError e
|
|
Right (responsePayload, _) -> AASCompleted $ J.toJSON responsePayload
|
|
removeEventFromLockedEvents (EventId (actionIdToText actionId)) lockedActionEvents
|
|
liftIO $ onLeft resE mempty
|
|
|
|
callWebhook ::
|
|
forall m r.
|
|
( MonadIO m,
|
|
MonadError QErr m,
|
|
Tracing.MonadTrace m,
|
|
MonadReader r m,
|
|
Has (L.Logger L.Hasura) r
|
|
) =>
|
|
Env.Environment ->
|
|
HTTP.Manager ->
|
|
PrometheusMetrics ->
|
|
GraphQLType ->
|
|
IR.ActionOutputFields ->
|
|
[HTTP.Header] ->
|
|
[HeaderConf] ->
|
|
Bool ->
|
|
EnvRecord ResolvedWebhook ->
|
|
ActionWebhookPayload ->
|
|
Timeout ->
|
|
Maybe RequestTransform ->
|
|
Maybe MetadataResponseTransform ->
|
|
m (ActionWebhookResponse, HTTP.ResponseHeaders)
|
|
callWebhook
|
|
env
|
|
manager
|
|
prometheusMetrics
|
|
outputType
|
|
outputFields
|
|
reqHeaders
|
|
confHeaders
|
|
forwardClientHeaders
|
|
resolvedWebhook
|
|
actionWebhookPayload
|
|
timeoutSeconds
|
|
metadataRequestTransform
|
|
metadataResponseTransform = do
|
|
resolvedConfHeaders <- makeHeadersFromConf env confHeaders
|
|
let clientHeaders = if forwardClientHeaders then mkClientHeadersForward reqHeaders else mempty
|
|
-- Using HashMap to avoid duplicate headers between configuration headers
|
|
-- and client headers where configuration headers are preferred
|
|
hdrs = (Map.toList . Map.fromList) (resolvedConfHeaders <> defaultHeaders <> clientHeaders)
|
|
postPayload = J.toJSON actionWebhookPayload
|
|
requestBody = J.encode postPayload
|
|
requestBodySize = BL.length requestBody
|
|
responseTimeout = HTTP.responseTimeoutMicro $ (unTimeout timeoutSeconds) * 1000000
|
|
(EnvRecord webhookEnvName resolvedWebhookValue) = resolvedWebhook
|
|
webhookUrl = unResolvedWebhook resolvedWebhookValue
|
|
sessionVars = Just $ _awpSessionVariables actionWebhookPayload
|
|
|
|
initReq <- liftIO $ HTTP.mkRequestThrow webhookUrl
|
|
|
|
let req =
|
|
initReq
|
|
& set HTTP.method "POST"
|
|
& set HTTP.headers hdrs
|
|
& set HTTP.body (Just requestBody)
|
|
& set HTTP.timeout responseTimeout
|
|
|
|
(transformedReq, transformedReqSize, reqTransformCtx) <- case metadataRequestTransform of
|
|
Nothing -> pure (Nothing, Nothing, Nothing)
|
|
Just RequestTransform {..} ->
|
|
let reqTransformCtx = fmap mkRequestContext $ mkReqTransformCtx webhookUrl sessionVars templateEngine
|
|
in case applyRequestTransform reqTransformCtx requestFields req of
|
|
Left err -> do
|
|
-- Log The Transformation Error
|
|
logger :: L.Logger L.Hasura <- asks getter
|
|
L.unLogger logger $ L.UnstructuredLog L.LevelError (SB.fromLBS $ J.encode err)
|
|
|
|
-- Throw an exception with the Transformation Error
|
|
throw500WithDetail "Request Transformation Failed" $ J.toJSON err
|
|
Right transformedReq ->
|
|
let transformedPayloadSize = HTTP.getReqSize transformedReq
|
|
in pure (Just transformedReq, Just transformedPayloadSize, Just reqTransformCtx)
|
|
|
|
let actualReq = fromMaybe req transformedReq
|
|
actualSize = fromMaybe requestBodySize transformedReqSize
|
|
|
|
httpResponse <-
|
|
Tracing.tracedHttpRequest actualReq $ \request ->
|
|
liftIO . try $ HTTP.performRequest request manager
|
|
|
|
let requestInfo = ActionRequestInfo webhookEnvName postPayload (confHeaders <> toHeadersConf clientHeaders) transformedReq
|
|
|
|
case httpResponse of
|
|
Left e ->
|
|
throw500WithDetail "http exception when calling webhook" $
|
|
J.toJSON $
|
|
ActionInternalError (J.toJSON $ HttpException e) requestInfo Nothing
|
|
Right responseWreq -> do
|
|
-- TODO(SOLOMON): Remove 'wreq'
|
|
let responseBody = responseWreq ^. Wreq.responseBody
|
|
responseBodySize = BL.length responseBody
|
|
actionName = _acName $ _awpAction actionWebhookPayload
|
|
responseStatus = responseWreq ^. Wreq.responseStatus
|
|
mkResponseInfo respBody =
|
|
ActionResponseInfo (HTTP.statusCode responseStatus) respBody $
|
|
toHeadersConf $
|
|
responseWreq ^. Wreq.responseHeaders
|
|
|
|
transformedResponseBody <- case metadataResponseTransform of
|
|
Nothing -> pure responseBody
|
|
Just metadataResponseTransform' ->
|
|
let responseTransform = mkResponseTransform metadataResponseTransform'
|
|
engine = respTransformTemplateEngine responseTransform
|
|
responseTransformCtx = buildRespTransformCtx (reqTransformCtx <*> Just actualReq) sessionVars engine (HTTP.responseBody responseWreq)
|
|
in applyResponseTransform responseTransform responseTransformCtx `onLeft` \err -> do
|
|
-- Log The Response Transformation Error
|
|
logger :: L.Logger L.Hasura <- asks getter
|
|
L.unLogger logger $ L.UnstructuredLog L.LevelError (SB.fromLBS $ J.encode err)
|
|
|
|
-- Throw an exception with the Transformation Error
|
|
throw500WithDetail "Response Transformation Failed" $ J.toJSON err
|
|
|
|
-- log the request and response to/from the action handler
|
|
liftIO $ do
|
|
Prometheus.Counter.add
|
|
(pmActionBytesSent prometheusMetrics)
|
|
actualSize
|
|
Prometheus.Counter.add
|
|
(pmActionBytesReceived prometheusMetrics)
|
|
responseBodySize
|
|
logger :: (L.Logger L.Hasura) <- asks getter
|
|
L.unLogger logger $ ActionHandlerLog req transformedReq requestBodySize transformedReqSize responseBodySize actionName
|
|
|
|
case J.eitherDecode transformedResponseBody of
|
|
Left e -> do
|
|
let responseInfo = mkResponseInfo $ J.String $ bsToTxt $ BL.toStrict responseBody
|
|
throw500WithDetail "not a valid json response from webhook" $
|
|
J.toJSON $
|
|
ActionInternalError (J.toJSON $ "invalid json: " <> e) requestInfo $
|
|
Just responseInfo
|
|
Right responseValue -> do
|
|
let responseInfo = mkResponseInfo responseValue
|
|
addInternalToErr e =
|
|
let actionInternalError =
|
|
J.toJSON $
|
|
ActionInternalError (J.String "unexpected response") requestInfo $
|
|
Just responseInfo
|
|
in e {qeInternal = Just $ ExtraInternal actionInternalError}
|
|
|
|
if
|
|
| HTTP.statusIsSuccessful responseStatus -> do
|
|
modifyQErr addInternalToErr $ do
|
|
webhookResponse <- decodeValue responseValue
|
|
validateResponse responseValue outputType outputFields
|
|
pure (webhookResponse, mkSetCookieHeaders responseWreq)
|
|
| HTTP.statusIsClientError responseStatus -> do
|
|
ActionWebhookErrorResponse message maybeCode maybeExtensions <-
|
|
modifyQErr addInternalToErr $ decodeValue responseValue
|
|
let code = maybe Unexpected ActionWebhookCode maybeCode
|
|
qErr = QErr [] responseStatus message code (ExtraExtensions <$> maybeExtensions)
|
|
throwError qErr
|
|
| otherwise -> do
|
|
let err =
|
|
J.toJSON $
|
|
"expecting 2xx or 4xx status code, but found "
|
|
++ show (HTTP.statusCode responseStatus)
|
|
throw500WithDetail "internal error" $
|
|
J.toJSON $
|
|
ActionInternalError err requestInfo $
|
|
Just responseInfo
|
|
|
|
processOutputSelectionSet ::
|
|
TF.ArgumentExp v ->
|
|
GraphQLType ->
|
|
[(PGCol, PGScalarType)] ->
|
|
IR.ActionFields ->
|
|
Options.StringifyNumbers ->
|
|
RS.AnnSimpleSelectG ('Postgres 'Vanilla) Void v
|
|
processOutputSelectionSet tableRowInput actionOutputType definitionList actionFields strfyNum =
|
|
RS.AnnSelectG annotatedFields selectFrom RS.noTablePermissions RS.noSelectArgs strfyNum Nothing
|
|
where
|
|
annotatedFields = fmap actionFieldToAnnField <$> actionFields
|
|
jsonbToPostgresRecordFunction =
|
|
QualifiedObject "pg_catalog" $
|
|
FunctionName $
|
|
if isListType actionOutputType
|
|
then "jsonb_to_recordset" -- Multirow array response
|
|
else "jsonb_to_record" -- Single object response
|
|
functionArgs = FunctionArgsExp [tableRowInput] mempty
|
|
selectFrom = RS.FromFunction jsonbToPostgresRecordFunction functionArgs $ Just definitionList
|
|
|
|
actionFieldToAnnField :: IR.ActionFieldG Void -> RS.AnnFieldG ('Postgres 'Vanilla) Void v
|
|
actionFieldToAnnField = \case
|
|
IR.ACFScalar asf -> RS.mkAnnColumnField (unsafePGCol $ toTxt asf) (ColumnScalar PGJSON) Nothing Nothing
|
|
IR.ACFExpression txt -> RS.AFExpression txt
|
|
IR.ACFNestedObject fieldName _ -> RS.mkAnnColumnField (unsafePGCol $ toTxt fieldName) (ColumnScalar PGJSON) Nothing Nothing
|
|
|
|
mkJsonAggSelect :: GraphQLType -> JsonAggSelect
|
|
mkJsonAggSelect =
|
|
bool JASSingleObject JASMultipleRows . isListType
|
|
|
|
insertActionTx ::
|
|
ActionName ->
|
|
SessionVariables ->
|
|
[HTTP.Header] ->
|
|
J.Value ->
|
|
PG.TxE QErr ActionId
|
|
insertActionTx actionName sessionVariables httpHeaders inputArgsPayload =
|
|
runIdentity . PG.getRow
|
|
<$> PG.withQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
INSERT INTO
|
|
"hdb_catalog"."hdb_action_log"
|
|
("action_name", "session_variables", "request_headers", "input_payload", "status")
|
|
VALUES
|
|
($1, $2, $3, $4, $5)
|
|
RETURNING "id"
|
|
|]
|
|
( actionName,
|
|
PG.ViaJSON sessionVariables,
|
|
PG.ViaJSON $ toHeadersMap httpHeaders,
|
|
PG.ViaJSON inputArgsPayload,
|
|
"created" :: Text
|
|
)
|
|
False
|
|
where
|
|
toHeadersMap = Map.fromList . map ((bsToTxt . CI.original) *** bsToTxt)
|
|
|
|
fetchUndeliveredActionEventsTx :: PG.TxE QErr [ActionLogItem]
|
|
fetchUndeliveredActionEventsTx =
|
|
map mapEvent
|
|
<$> PG.withQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
update hdb_catalog.hdb_action_log set status = 'processing'
|
|
where
|
|
id in (
|
|
select id from hdb_catalog.hdb_action_log
|
|
where status = 'created'
|
|
for update skip locked limit 10
|
|
)
|
|
returning
|
|
id, action_name, request_headers::json, session_variables::json, input_payload::json
|
|
|]
|
|
()
|
|
False
|
|
where
|
|
mapEvent
|
|
( actionId,
|
|
actionName,
|
|
PG.ViaJSON headersMap,
|
|
PG.ViaJSON sessionVariables,
|
|
PG.ViaJSON inputPayload
|
|
) =
|
|
ActionLogItem actionId actionName (fromHeadersMap headersMap) sessionVariables inputPayload
|
|
|
|
fromHeadersMap = map ((CI.mk . txtToBs) *** txtToBs) . Map.toList
|
|
|
|
setActionStatusTx :: ActionId -> AsyncActionStatus -> PG.TxE QErr ()
|
|
setActionStatusTx actionId = \case
|
|
AASCompleted responsePayload ->
|
|
PG.unitQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
update hdb_catalog.hdb_action_log
|
|
set response_payload = $1, status = 'completed'
|
|
where id = $2
|
|
|]
|
|
(PG.ViaJSON responsePayload, actionId)
|
|
False
|
|
AASError qerr ->
|
|
PG.unitQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
update hdb_catalog.hdb_action_log
|
|
set errors = $1, status = 'error'
|
|
where id = $2
|
|
|]
|
|
(PG.ViaJSON qerr, actionId)
|
|
False
|
|
|
|
fetchActionResponseTx :: ActionId -> PG.TxE QErr ActionLogResponse
|
|
fetchActionResponseTx actionId = do
|
|
(ca, rp, errs, PG.ViaJSON sessVars) <-
|
|
PG.getRow
|
|
<$> PG.withQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
SELECT created_at, response_payload::json, errors::json, session_variables::json
|
|
FROM hdb_catalog.hdb_action_log
|
|
WHERE id = $1
|
|
|]
|
|
(Identity actionId)
|
|
True
|
|
pure $ ActionLogResponse actionId ca (PG.getViaJSON <$> rp) (PG.getViaJSON <$> errs) sessVars
|
|
|
|
clearActionDataTx :: ActionName -> PG.TxE QErr ()
|
|
clearActionDataTx actionName =
|
|
PG.unitQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
DELETE FROM hdb_catalog.hdb_action_log
|
|
WHERE action_name = $1
|
|
|]
|
|
(Identity actionName)
|
|
True
|
|
|
|
setProcessingActionLogsToPendingTx :: LockedActionIdArray -> PG.TxE QErr ()
|
|
setProcessingActionLogsToPendingTx lockedActions =
|
|
PG.unitQE
|
|
defaultTxErrorHandler
|
|
[PG.sql|
|
|
UPDATE hdb_catalog.hdb_action_log
|
|
SET status = 'created'
|
|
WHERE status = 'processing' AND id = ANY($1::uuid[])
|
|
|]
|
|
(Identity lockedActions)
|
|
False
|