2021-08-06 16:39:00 +03:00
|
|
|
module Hasura.GraphQL.Execute.RemoteJoin.RemoteSchema
|
2021-09-24 01:56:37 +03:00
|
|
|
( buildRemoteSchemaCall,
|
|
|
|
RemoteSchemaCall (..),
|
|
|
|
getRemoteSchemaResponse,
|
|
|
|
buildJoinIndex,
|
|
|
|
)
|
|
|
|
where
|
2021-08-06 16:39:00 +03:00
|
|
|
|
2021-09-24 01:56:37 +03:00
|
|
|
import Control.Lens (view, _2, _3)
|
|
|
|
import Data.Aeson qualified as A
|
|
|
|
import Data.Aeson.Ordered qualified as AO
|
|
|
|
import Data.Environment qualified as Env
|
|
|
|
import Data.HashMap.Strict qualified as Map
|
|
|
|
import Data.IntMap.Strict qualified as IntMap
|
|
|
|
import Data.List.NonEmpty qualified as NE
|
|
|
|
import Data.Text qualified as T
|
|
|
|
import Data.Text.Extended (commaSeparated, toTxt, (<<>))
|
|
|
|
import Data.Validation (Validation (..), toEither)
|
|
|
|
import Hasura.Base.Error
|
|
|
|
import Hasura.GraphQL.Execute.Remote
|
|
|
|
( collectVariablesFromSelectionSet,
|
|
|
|
resolveRemoteVariable,
|
|
|
|
runVariableCache,
|
|
|
|
)
|
|
|
|
import Hasura.GraphQL.Execute.RemoteJoin.Types
|
|
|
|
import Hasura.GraphQL.Parser qualified as P
|
|
|
|
import Hasura.GraphQL.RemoteServer (execRemoteGQ)
|
|
|
|
import Hasura.GraphQL.Transport.HTTP.Protocol (GQLReq (..), GQLReqOutgoing)
|
|
|
|
import Hasura.Prelude
|
|
|
|
import Hasura.RQL.Types
|
|
|
|
import Hasura.Session
|
|
|
|
import Hasura.Tracing qualified as Tracing
|
|
|
|
import Language.GraphQL.Draft.Syntax qualified as G
|
|
|
|
import Network.HTTP.Client qualified as HTTP
|
|
|
|
import Network.HTTP.Types qualified as N
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- XXX(jkachmar): Think about reworking 'ResponsePath' to be 'Alias, Maybe [G.Name]'
|
2021-09-24 01:56:37 +03:00
|
|
|
|
2021-08-06 16:39:00 +03:00
|
|
|
-- | Used to extract the value from a remote schema response.
|
|
|
|
--
|
|
|
|
-- For example: if a remote relationship is defined to retrieve data from some
|
|
|
|
-- deeply nested field, this is the path towards that deeply nested field.
|
|
|
|
newtype ResponsePath = ResponsePath (NE.NonEmpty G.Name)
|
2021-09-24 01:56:37 +03:00
|
|
|
-- (Alias, Maybe [G.Name])
|
2021-08-06 16:39:00 +03:00
|
|
|
deriving stock (Eq, Show)
|
|
|
|
|
|
|
|
-- | The name that we generate when performing a remote join, which shall always
|
|
|
|
-- be the first field in a 'ResponsePath'.
|
|
|
|
type Alias = G.Name
|
|
|
|
|
|
|
|
-- NOTE: Ideally this should be done at the remote relationship validation
|
|
|
|
-- layer.
|
|
|
|
--
|
|
|
|
-- When validating remote relationships, we should store the validated names so
|
|
|
|
-- that we don't need to continually re-validate them downstream.
|
|
|
|
parseGraphQLName :: (MonadError QErr m) => Text -> m G.Name
|
|
|
|
parseGraphQLName txt =
|
|
|
|
G.mkName txt `onNothing` (throw400 RemoteSchemaError $ errMsg)
|
|
|
|
where
|
|
|
|
errMsg = txt <> " is not a valid GraphQL name"
|
|
|
|
|
|
|
|
-- | Intermediate type containing all of the information required to perform
|
|
|
|
-- a remote schema call.
|
|
|
|
--
|
|
|
|
-- See 'buildRemoteSchemaCall' for details.
|
2021-09-24 01:56:37 +03:00
|
|
|
data RemoteSchemaCall = RemoteSchemaCall
|
|
|
|
{ _rscInfo :: !RemoteSchemaInfo,
|
2021-10-29 17:42:07 +03:00
|
|
|
_rscCustomizer :: !ResultCustomizer,
|
2021-09-24 01:56:37 +03:00
|
|
|
_rscGQLRequest :: !GQLReqOutgoing,
|
|
|
|
_rscResponsePaths :: !(IntMap.IntMap ResponsePath)
|
|
|
|
}
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- | Constructs an outgoing response from the remote relationships definition
|
|
|
|
-- (i.e. 'RemoteSchemaJoin') and the arguments collected from the database's
|
|
|
|
-- response.
|
|
|
|
--
|
|
|
|
-- NOTE: We need to pass along some additional information with the raw outgoing
|
|
|
|
-- GraphQL request, hence the 'RemoteSchemaCall' type.
|
2021-09-24 01:56:37 +03:00
|
|
|
buildRemoteSchemaCall ::
|
|
|
|
(MonadError QErr m) =>
|
|
|
|
UserInfo ->
|
|
|
|
RemoteSchemaJoin ->
|
|
|
|
IntMap.IntMap JoinArgument ->
|
|
|
|
m (Maybe RemoteSchemaCall)
|
|
|
|
buildRemoteSchemaCall userInfo RemoteSchemaJoin {..} arguments = do
|
2021-08-06 16:39:00 +03:00
|
|
|
-- for each join argument, we generate a unique field, with the alias
|
|
|
|
-- "f" <> argumentId
|
|
|
|
fields <- flip IntMap.traverseWithKey arguments $ \argumentId (JoinArgument argument) -> do
|
2021-09-24 01:56:37 +03:00
|
|
|
graphqlArgs <- fmap Map.fromList $
|
|
|
|
for (Map.toList argument) $
|
|
|
|
\(FieldName columnName, value) ->
|
|
|
|
(,) <$> parseGraphQLName columnName <*> ordJSONValueToGValue value
|
|
|
|
let alias = G.unsafeMkName $ T.pack $ "f" <> show argumentId
|
|
|
|
responsePath = alias NE.:| map fcName (toList $ NE.tail _rsjFieldCall)
|
|
|
|
rootField = fcName $ NE.head _rsjFieldCall
|
|
|
|
resultCustomizer = applyAliasMapping (singletonAliasMapping rootField alias) _rsjResultCustomizer
|
2021-08-06 16:39:00 +03:00
|
|
|
gqlField <- fieldCallsToField _rsjArgs graphqlArgs _rsjSelSet alias _rsjFieldCall
|
|
|
|
pure (gqlField, responsePath, resultCustomizer)
|
|
|
|
|
|
|
|
-- this constructs the actual GraphQL Request that can be sent to the remote
|
|
|
|
for (NE.nonEmpty $ IntMap.elems fields) $ \neFields -> do
|
|
|
|
gqlRequest <-
|
|
|
|
fmap fieldsToRequest . runVariableCache . for neFields $
|
|
|
|
\(field, _, _) -> traverse (resolveRemoteVariable userInfo) field
|
|
|
|
let customizer = foldMap (view _3) fields
|
|
|
|
responsePath = fmap (ResponsePath . view _2) fields
|
|
|
|
pure $ RemoteSchemaCall _rsjRemoteSchema customizer gqlRequest responsePath
|
|
|
|
|
|
|
|
-- | Construct a 'JoinIndex' from the remote source's 'AO.Value' response.
|
|
|
|
--
|
|
|
|
-- If the response does not have value at any of the provided 'ResponsePath's,
|
|
|
|
-- throw a generic 'QErr'.
|
|
|
|
--
|
|
|
|
-- NOTE(jkachmar): If we switch to an 'Applicative' validator, we can collect
|
|
|
|
-- more than one missing 'ResponsePath's (rather than short-circuiting on the
|
|
|
|
-- first missing value).
|
2021-09-24 01:56:37 +03:00
|
|
|
buildJoinIndex ::
|
|
|
|
(Monad m, MonadError QErr m) =>
|
|
|
|
AO.Object ->
|
|
|
|
IntMap.IntMap ResponsePath ->
|
|
|
|
m JoinIndex
|
2021-08-06 16:39:00 +03:00
|
|
|
buildJoinIndex response responsePaths =
|
|
|
|
for responsePaths $ \path -> extractAtPath (AO.Object response) path
|
|
|
|
|
2021-09-24 01:56:37 +03:00
|
|
|
getRemoteSchemaResponse ::
|
2021-10-13 19:38:56 +03:00
|
|
|
( MonadError QErr m,
|
2021-09-24 01:56:37 +03:00
|
|
|
MonadIO m,
|
|
|
|
Tracing.MonadTrace m
|
|
|
|
) =>
|
|
|
|
Env.Environment ->
|
|
|
|
HTTP.Manager ->
|
|
|
|
[N.Header] ->
|
|
|
|
UserInfo ->
|
|
|
|
RemoteSchemaCall ->
|
|
|
|
m AO.Object
|
2021-08-06 16:39:00 +03:00
|
|
|
getRemoteSchemaResponse env manager requestHeaders userInfo (RemoteSchemaCall rsi customizer req _) = do
|
|
|
|
(_, _, respBody) <- execRemoteGQ env manager userInfo requestHeaders (rsDef rsi) req
|
2021-09-24 01:56:37 +03:00
|
|
|
resp <-
|
|
|
|
AO.eitherDecode respBody
|
|
|
|
`onLeft` (\e -> throw500 $ "Remote server response is not valid JSON: " <> T.pack e)
|
2021-08-06 16:39:00 +03:00
|
|
|
respObj <- AO.asObject resp `onLeft` throw500
|
|
|
|
let errors = AO.lookup "errors" respObj
|
|
|
|
if
|
2021-09-24 01:56:37 +03:00
|
|
|
| isNothing errors || errors == Just AO.Null ->
|
|
|
|
case AO.lookup "data" respObj of
|
|
|
|
Nothing -> throw500 "\"data\" field not found in remote response"
|
|
|
|
Just v ->
|
2021-10-29 17:42:07 +03:00
|
|
|
let v' = applyResultCustomizer customizer v
|
2021-09-24 01:56:37 +03:00
|
|
|
in AO.asObject v' `onLeft` throw500
|
|
|
|
| otherwise ->
|
|
|
|
throwError
|
|
|
|
(err400 Unexpected "Errors from remote server")
|
|
|
|
{ qeInternal = Just $ ExtraInternal $ A.object ["errors" A..= (AO.fromOrdered <$> errors)]
|
|
|
|
}
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- | Attempt to extract a deeply nested value from a remote source's 'AO.Value'
|
|
|
|
-- response, according to the JSON path provided by 'ResponsePath'.
|
2021-09-24 01:56:37 +03:00
|
|
|
extractAtPath ::
|
|
|
|
forall m.
|
|
|
|
MonadError QErr m =>
|
|
|
|
AO.Value ->
|
|
|
|
ResponsePath ->
|
|
|
|
m AO.Value
|
2021-08-06 16:39:00 +03:00
|
|
|
extractAtPath initValue (ResponsePath rPath) =
|
|
|
|
go initValue (map G.unName . NE.toList $ rPath)
|
|
|
|
where
|
|
|
|
go :: AO.Value -> [Text] -> m AO.Value
|
|
|
|
go value path = case path of
|
|
|
|
[] -> pure value
|
2021-09-24 01:56:37 +03:00
|
|
|
k : ks -> case value of
|
2021-08-06 16:39:00 +03:00
|
|
|
AO.Object obj -> do
|
2021-09-24 01:56:37 +03:00
|
|
|
objValue <-
|
|
|
|
AO.lookup k obj
|
|
|
|
`onNothing` throw500 ("failed to lookup key '" <> toTxt k <> "' in response")
|
2021-08-06 16:39:00 +03:00
|
|
|
go objValue ks
|
|
|
|
_ ->
|
2021-09-24 01:56:37 +03:00
|
|
|
throw500 $
|
|
|
|
"unexpected non-object json value found while path not empty: "
|
|
|
|
<> commaSeparated path
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
ordJSONValueToGValue :: (MonadError QErr n) => AO.Value -> n (G.Value Void)
|
|
|
|
ordJSONValueToGValue =
|
|
|
|
either (throw400 ValidationFailed) pure . P.jsonToGraphQL . AO.fromOrdered
|
|
|
|
|
|
|
|
convertFieldWithVariablesToName :: G.Field G.NoFragments P.Variable -> G.Field G.NoFragments G.Name
|
|
|
|
convertFieldWithVariablesToName = fmap P.getName
|
|
|
|
|
|
|
|
inputValueToJSON :: P.InputValue Void -> A.Value
|
|
|
|
inputValueToJSON = \case
|
2021-09-24 01:56:37 +03:00
|
|
|
P.JSONValue j -> j
|
2021-08-06 16:39:00 +03:00
|
|
|
P.GraphQLValue g -> graphQLValueToJSON g
|
|
|
|
|
|
|
|
-- | TODO: Documentation.
|
2021-09-24 01:56:37 +03:00
|
|
|
collectVariablesFromValue ::
|
|
|
|
G.Value P.Variable -> HashMap G.VariableDefinition A.Value
|
2021-08-06 16:39:00 +03:00
|
|
|
collectVariablesFromValue = foldMap' \var@(P.Variable _ gType val) ->
|
2021-09-24 01:56:37 +03:00
|
|
|
let name = P.getName var
|
|
|
|
jsonVal = inputValueToJSON val
|
|
|
|
defaultVal = getDefaultValue val
|
|
|
|
in Map.singleton (G.VariableDefinition name gType defaultVal) jsonVal
|
2021-08-06 16:39:00 +03:00
|
|
|
where
|
|
|
|
getDefaultValue :: P.InputValue Void -> Maybe (G.Value Void)
|
|
|
|
getDefaultValue = \case
|
2021-09-24 01:56:37 +03:00
|
|
|
P.JSONValue _ -> Nothing
|
2021-08-06 16:39:00 +03:00
|
|
|
P.GraphQLValue g -> Just g
|
|
|
|
|
|
|
|
-- | TODO: Documentation.
|
2021-09-24 01:56:37 +03:00
|
|
|
collectVariablesFromField ::
|
|
|
|
G.Field G.NoFragments P.Variable -> HashMap G.VariableDefinition A.Value
|
2021-08-06 16:39:00 +03:00
|
|
|
collectVariablesFromField (G.Field _ _ arguments _ selSet) =
|
2021-09-24 01:56:37 +03:00
|
|
|
let argumentVariables = fmap collectVariablesFromValue arguments
|
|
|
|
selSetVariables =
|
|
|
|
(fmap . fmap) snd $ collectVariablesFromSelectionSet selSet
|
|
|
|
in fold' (Map.elems argumentVariables) <> Map.fromList selSetVariables
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- | TODO: Documentation.
|
|
|
|
--
|
|
|
|
-- Extension of the documentation required for 'collectVariablesFromField' and
|
|
|
|
-- 'collectVariablesFromValue'.
|
|
|
|
fieldsToRequest :: NonEmpty (G.Field G.NoFragments P.Variable) -> GQLReqOutgoing
|
|
|
|
fieldsToRequest gFields =
|
|
|
|
let variableInfos = foldMap collectVariablesFromField gFields
|
2021-09-24 01:56:37 +03:00
|
|
|
in GQLReq
|
|
|
|
{ _grOperationName = Nothing,
|
|
|
|
_grVariables =
|
|
|
|
mapKeys G._vdName variableInfos <$ guard (not $ Map.null variableInfos),
|
|
|
|
_grQuery =
|
|
|
|
G.TypedOperationDefinition
|
|
|
|
{ G._todSelectionSet =
|
|
|
|
NE.toList $ G.SelectionField . convertFieldWithVariablesToName <$> gFields,
|
|
|
|
G._todVariableDefinitions = Map.keys variableInfos,
|
|
|
|
G._todType = G.OperationTypeQuery,
|
|
|
|
G._todName = Nothing,
|
|
|
|
G._todDirectives = []
|
|
|
|
}
|
|
|
|
}
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- | Fold nested 'FieldCall's into a bare 'Field', inserting the passed
|
|
|
|
-- selection set at the leaf of the tree we construct.
|
2021-09-24 01:56:37 +03:00
|
|
|
fieldCallsToField ::
|
|
|
|
forall m.
|
|
|
|
MonadError QErr m =>
|
|
|
|
-- | user input arguments to the remote join field
|
|
|
|
Map.HashMap G.Name (P.InputValue RemoteSchemaVariable) ->
|
|
|
|
-- | Contains the values of the variables that have been defined in the remote join definition
|
|
|
|
Map.HashMap G.Name (G.Value Void) ->
|
|
|
|
-- | Inserted at leaf of nested FieldCalls
|
|
|
|
G.SelectionSet G.NoFragments RemoteSchemaVariable ->
|
|
|
|
-- | Top-level name to set for this Field
|
|
|
|
Alias ->
|
|
|
|
NonEmpty FieldCall ->
|
|
|
|
m (G.Field G.NoFragments RemoteSchemaVariable)
|
2021-08-06 16:39:00 +03:00
|
|
|
fieldCallsToField rrArguments variables finalSelSet topAlias =
|
2021-09-24 01:56:37 +03:00
|
|
|
fmap (\f -> f {G._fAlias = Just topAlias}) . nest
|
2021-08-06 16:39:00 +03:00
|
|
|
where
|
|
|
|
-- almost: `foldr nest finalSelSet`
|
|
|
|
nest :: NonEmpty FieldCall -> m (G.Field G.NoFragments RemoteSchemaVariable)
|
|
|
|
nest ((FieldCall name remoteArgs) :| rest) = do
|
|
|
|
templatedArguments <- convert <$> createArguments variables remoteArgs
|
|
|
|
graphQLarguments <- traverse peel rrArguments
|
|
|
|
(args, selSet) <- case NE.nonEmpty rest of
|
2021-09-24 01:56:37 +03:00
|
|
|
Just f -> do
|
|
|
|
s <- nest f
|
|
|
|
pure (templatedArguments, [G.SelectionField s])
|
|
|
|
Nothing ->
|
|
|
|
let arguments =
|
|
|
|
Map.unionWith
|
|
|
|
mergeValue
|
|
|
|
graphQLarguments
|
|
|
|
-- converting (G.Value Void) -> (G.Value Variable) to merge the
|
|
|
|
-- 'rrArguments' with the 'variables'
|
|
|
|
templatedArguments
|
|
|
|
in pure (arguments, finalSelSet)
|
2021-08-06 16:39:00 +03:00
|
|
|
pure $ G.Field Nothing name args [] selSet
|
|
|
|
|
|
|
|
convert :: Map.HashMap G.Name (G.Value Void) -> Map.HashMap G.Name (G.Value RemoteSchemaVariable)
|
|
|
|
convert = fmap G.literal
|
|
|
|
|
|
|
|
peel :: P.InputValue RemoteSchemaVariable -> m (G.Value RemoteSchemaVariable)
|
|
|
|
peel = \case
|
|
|
|
P.GraphQLValue v -> pure v
|
|
|
|
P.JSONValue _ ->
|
|
|
|
-- At this point, it is theoretically impossible that we have
|
|
|
|
-- unpacked a variable into a JSONValue, as there's no "outer
|
|
|
|
-- scope" at which this value could have been peeled.
|
|
|
|
-- FIXME: check that this is correct!
|
|
|
|
throw500 "internal error: encountered an already expanded variable when folding remote field arguments"
|
2021-09-24 01:56:37 +03:00
|
|
|
|
|
|
|
-- FIXME: better error message
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- This is a kind of "deep merge".
|
|
|
|
-- For e.g. suppose the input argument of the remote field is something like:
|
|
|
|
-- `where: { id : 1}`
|
|
|
|
-- And during execution, client also gives the input arg: `where: {name: "tiru"}`
|
|
|
|
-- We need to merge the input argument to where: {id : 1, name: "tiru"}
|
|
|
|
mergeValue :: G.Value RemoteSchemaVariable -> G.Value RemoteSchemaVariable -> G.Value RemoteSchemaVariable
|
|
|
|
mergeValue lVal rVal = case (lVal, rVal) of
|
|
|
|
(G.VList l, G.VList r) ->
|
|
|
|
G.VList $ l <> r
|
|
|
|
(G.VObject l, G.VObject r) ->
|
|
|
|
G.VObject $ Map.unionWith mergeValue l r
|
2021-09-24 01:56:37 +03:00
|
|
|
(_, _) ->
|
|
|
|
error $
|
|
|
|
"can only merge a list with another list or an "
|
|
|
|
<> "object with another object"
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- | Create an argument map using the inputs taken from the hasura database.
|
2021-09-24 01:56:37 +03:00
|
|
|
createArguments ::
|
|
|
|
(MonadError QErr m) =>
|
|
|
|
Map.HashMap G.Name (G.Value Void) ->
|
|
|
|
RemoteArguments ->
|
|
|
|
m (HashMap G.Name (G.Value Void))
|
2021-08-06 16:39:00 +03:00
|
|
|
createArguments variables (RemoteArguments arguments) =
|
2021-09-24 01:56:37 +03:00
|
|
|
toEither (substituteVariables variables arguments)
|
|
|
|
`onLeft` (\errors -> throw400 Unexpected $ "Found errors: " <> commaSeparated errors)
|
2021-08-06 16:39:00 +03:00
|
|
|
|
|
|
|
-- | Substitute values in the argument list.
|
2021-09-24 01:56:37 +03:00
|
|
|
substituteVariables ::
|
|
|
|
-- | Values of the variables to substitute.
|
|
|
|
HashMap G.Name (G.Value Void) ->
|
|
|
|
-- | Template which contains the variables.
|
|
|
|
HashMap G.Name (G.Value G.Name) ->
|
|
|
|
Validation [Text] (HashMap G.Name (G.Value Void))
|
2021-08-06 16:39:00 +03:00
|
|
|
substituteVariables values = traverse go
|
|
|
|
where
|
|
|
|
go = \case
|
|
|
|
G.VVariable variableName ->
|
|
|
|
Map.lookup variableName values
|
2021-09-24 01:56:37 +03:00
|
|
|
`onNothing` Failure ["Value for variable " <> variableName <<> " not provided"]
|
2021-08-06 16:39:00 +03:00
|
|
|
G.VList listValue ->
|
|
|
|
fmap G.VList (traverse go listValue)
|
|
|
|
G.VObject objectValue ->
|
|
|
|
fmap G.VObject (traverse go objectValue)
|
|
|
|
G.VInt i -> pure $ G.VInt i
|
|
|
|
G.VFloat d -> pure $ G.VFloat d
|
|
|
|
G.VString txt -> pure $ G.VString txt
|
|
|
|
G.VEnum e -> pure $ G.VEnum e
|
|
|
|
G.VBoolean b -> pure $ G.VBoolean b
|
|
|
|
G.VNull -> pure $ G.VNull
|