graphql-engine/server/src-lib/Hasura/GraphQL/Execute/RemoteJoin/RemoteSchema.hs
Samir Talwar 342391f39d Upgrade Ormolu to v0.5.
This upgrades the version of Ormolu required by the HGE repository to v0.5.0.1, and reformats all code accordingly.

Ormolu v0.5 reformats code that uses infix operators. This is mostly useful, adding newlines and indentation to make it clear which operators are applied first, but in some cases, it's unpleasant. To make this easier on the eyes, I had to do the following:

* Add a few fixity declarations (search for `infix`)
* Add parentheses to make precedence clear, allowing Ormolu to keep everything on one line
* Rename `relevantEq` to `(==~)` in #6651 and set it to `infix 4`
* Add a few _.ormolu_ files (thanks to @hallettj for helping me get started), mostly for Autodocodec operators that don't have explicit fixity declarations

In general, I think these changes are quite reasonable. They mostly affect indentation.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6675
GitOrigin-RevId: cd47d87f1d089fb0bc9dcbbe7798dbceedcd7d83
2022-11-02 20:55:13 +00:00

357 lines
15 KiB
Haskell

-- | How to construct and execute a call to a remote schema for a remote join.
--
-- There are three steps required to do this:
-- 1. construct the call: given the requested fields, the phantom fields, the
-- values extracted by the LHS, construct a GraphQL query
-- 2. execute that GraphQL query over the network
-- 3. build a index of the variables out of the response
--
-- This can be done as one function, but we also export the individual steps for
-- debugging / test purposes. We congregate all intermediary state in the opaque
-- 'RemoteSchemaCall' type.
module Hasura.GraphQL.Execute.RemoteJoin.RemoteSchema
( -- * Executing a remote join
makeRemoteSchemaJoinCall,
-- * Individual steps
RemoteSchemaCall,
buildRemoteSchemaCall,
executeRemoteSchemaCall,
buildJoinIndex,
)
where
import Control.Lens (view, _2, _3)
import Data.Aeson qualified as A
import Data.Aeson.Ordered qualified as AO
import Data.ByteString.Lazy qualified as BL
import Data.HashMap.Strict.Extended 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.Base.ErrorMessage (fromErrorMessage)
import Hasura.GraphQL.Execute.Remote
( getVariableDefinitionAndValue,
resolveRemoteVariable,
runVariableCache,
)
import Hasura.GraphQL.Execute.RemoteJoin.Types
import Hasura.GraphQL.Parser qualified as P
import Hasura.GraphQL.Transport.HTTP.Protocol (GQLReq (..), GQLReqOutgoing)
import Hasura.Prelude
import Hasura.RQL.IR.RemoteSchema (convertSelectionSet)
import Hasura.RQL.Types.Common
import Hasura.RQL.Types.ResultCustomization
import Hasura.RemoteSchema.SchemaCache
import Hasura.Session
import Language.GraphQL.Draft.Syntax qualified as G
-------------------------------------------------------------------------------
-- Executing a remote join
-- | Construct and execute a call to a remote schema for a remote join.
makeRemoteSchemaJoinCall ::
(MonadError QErr m) =>
-- | Function to send a request over the network.
(GQLReqOutgoing -> m BL.ByteString) ->
-- | User information.
UserInfo ->
-- | Information about that remote join.
RemoteSchemaJoin ->
-- | Mapping from 'JoinArgumentId' to its corresponding 'JoinArgument'.
IntMap.IntMap JoinArgument ->
-- | The resulting join index (see 'buildJoinIndex') if any.
m (Maybe (IntMap.IntMap AO.Value))
makeRemoteSchemaJoinCall networkFunction userInfo remoteSchemaJoin joinArguments = do
-- step 1: construct the internal intermediary representation
maybeRemoteCall <- buildRemoteSchemaCall remoteSchemaJoin joinArguments userInfo
-- if there actually is a remote call:
for maybeRemoteCall \remoteCall -> do
-- step 2: execute it over the network
responseValue <- executeRemoteSchemaCall networkFunction remoteCall
-- step 3: build the join index
buildJoinIndex remoteCall responseValue
-------------------------------------------------------------------------------
-- Internal representation
-- | Intermediate type containing all of the information required to perform
-- a remote schema call, constructed from the static join information.
data RemoteSchemaCall = RemoteSchemaCall
{ rscCustomizer :: ResultCustomizer,
rscGQLRequest :: GQLReqOutgoing,
rscResponsePaths :: IntMap.IntMap ResponsePath
}
-- | 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)
deriving stock (Eq, Show)
-------------------------------------------------------------------------------
-- Step 1: building the remote call
-- | Constructs a 'RemoteSchemaCall' from some static information, such as the
-- definition of the join, and dynamic information such as the user's
-- information and the map of join arguments.
buildRemoteSchemaCall ::
(MonadError QErr m) =>
RemoteSchemaJoin ->
IntMap.IntMap JoinArgument ->
UserInfo ->
m (Maybe RemoteSchemaCall)
buildRemoteSchemaCall RemoteSchemaJoin {..} arguments userInfo = do
-- for each join argument, we generate a unique field, with the alias
-- "f" <> argumentId
fields <- flip IntMap.traverseWithKey arguments $ \argumentId (JoinArgument argument) -> do
graphqlArgs <- fmap Map.fromList $
for (Map.toList argument) \(FieldName columnName, value) -> do
graphQLName <- parseGraphQLName columnName
graphQLValue <- ordJSONValueToGValue value
pure (graphQLName, graphQLValue)
-- Creating the alias should never fail.
let aliasText = T.pack $ "f" <> show argumentId
alias <-
G.mkName aliasText
`onNothing` throw500 ("'" <> aliasText <> "' is not a valid GraphQL name!")
let responsePath = alias NE.:| fmap fcName (NE.tail _rsjFieldCall)
rootField = fcName $ NE.head _rsjFieldCall
resultCustomizer = applyAliasMapping (singletonAliasMapping rootField alias) _rsjResultCustomizer
gqlField <- fieldCallsToField _rsjArgs graphqlArgs (convertSelectionSet _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 customizer gqlRequest responsePath
-- | Fold nested 'FieldCall's into a bare 'Field', inserting the passed
-- selection set at the leaf of the tree we construct.
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
G.Name ->
NonEmpty FieldCall ->
m (G.Field G.NoFragments RemoteSchemaVariable)
fieldCallsToField rrArguments variables finalSelSet topAlias =
fmap (\f -> f {G._fAlias = Just topAlias}) . nest
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
Just f -> do
s <- nest f
pure (templatedArguments, [G.SelectionField s])
Nothing -> do
arguments <-
Map.unionWithM
combineValues
graphQLarguments
-- converting (G.Value Void) -> (G.Value Variable) to merge the
-- 'rrArguments' with the 'variables'
templatedArguments
pure (arguments, finalSelSet)
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"
-- | Create an argument map using the inputs taken from the left hand side.
createArguments ::
(MonadError QErr m) =>
Map.HashMap G.Name (G.Value Void) ->
RemoteArguments ->
m (HashMap G.Name (G.Value Void))
createArguments variables (RemoteArguments arguments) =
toEither (traverse substituteVariables arguments)
`onLeft` (\errors -> throw400 Unexpected $ "Found errors: " <> commaSeparated errors)
where
substituteVariables = \case
G.VVariable variableName ->
Map.lookup variableName variables
`onNothing` Failure ["Value for variable " <> variableName <<> " not provided"]
G.VList listValue ->
fmap G.VList (traverse substituteVariables listValue)
G.VObject objectValue ->
fmap G.VObject (traverse substituteVariables 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
-- | Combine two GraphQL values together.
--
-- This is used to combine different input arguments into one. This function can
-- only combine objects or lists pairwise, and fails if it has to combine any
-- other combination of values.
--
-- >>> combineValues (Object (fromList [("id", Number 1)]) (Object (fromList [("name", String "foo")])
-- Object (fromList [("id", Number 1), ("name", String "foo")])
combineValues ::
MonadError QErr m =>
G.Name ->
G.Value RemoteSchemaVariable ->
G.Value RemoteSchemaVariable ->
m (G.Value RemoteSchemaVariable)
combineValues name v1 v2 = case (v1, v2) of
(G.VObject l, G.VObject r) -> G.VObject <$> Map.unionWithM combineValues l r
(G.VList l, G.VList r) -> pure $ G.VList $ l <> r
(l, r) ->
throw500 $
"combineValues: cannot combine values ("
<> tshow l
<> ") and ("
<> tshow r
<> ") for field "
<> G.unName name
<> "; lists can only be merged with lists, objects can only be merged with objects"
-- | Craft a GraphQL query document from the list of fields.
fieldsToRequest :: NonEmpty (G.Field G.NoFragments P.Variable) -> GQLReqOutgoing
fieldsToRequest gFields =
GQLReq
{ _grOperationName = Nothing,
_grVariables =
if Map.null variableInfos
then Nothing
else Just $ mapKeys G._vdName variableInfos,
_grQuery =
G.TypedOperationDefinition
{ G._todSelectionSet =
-- convert from Field Variable to Field Name
NE.toList $ G.SelectionField . fmap P.getName <$> gFields,
G._todVariableDefinitions = Map.keys variableInfos,
G._todType = G.OperationTypeQuery,
G._todName = Nothing,
G._todDirectives = []
}
}
where
variableInfos :: HashMap G.VariableDefinition A.Value
variableInfos = Map.fromList $ concatMap (foldMap getVariableInfo) gFields
getVariableInfo :: P.Variable -> [(G.VariableDefinition, A.Value)]
getVariableInfo = pure . fmap snd . getVariableDefinitionAndValue
------------------------------------------------------------------------------
-- Step 2: sending the call over the network
-- | Sends the call over the network, and parse the resulting ByteString.
executeRemoteSchemaCall ::
(MonadError QErr m) =>
-- | Function to send a request over the network.
(GQLReqOutgoing -> m BL.ByteString) ->
-- | Information about that call.
RemoteSchemaCall ->
-- | Resulting JSON object
m AO.Object
executeRemoteSchemaCall networkFunction (RemoteSchemaCall customizer request _) = do
responseBody <- networkFunction request
responseJSON <-
AO.eitherDecode responseBody
`onLeft` (\e -> throw500 $ "Remote server response is not valid JSON: " <> T.pack e)
responseObject <- AO.asObject responseJSON `onLeft` throw500
let errors = AO.lookup "errors" responseObject
if
| isNothing errors || errors == Just AO.Null ->
case AO.lookup "data" responseObject of
Nothing -> throw500 "\"data\" field not found in remote response"
Just v ->
let v' = applyResultCustomizer customizer v
in AO.asObject v' `onLeft` throw500
| otherwise ->
throwError
(err400 Unexpected "Errors from remote server")
{ qeInternal = Just $ ExtraInternal $ A.object ["errors" A..= (AO.fromOrdered <$> errors)]
}
-------------------------------------------------------------------------------
-- Step 3: extracting the join index
-- | Construct a join index from the remote source's 'AO.Value' response.
--
-- This function extracts from the 'RemoteJoinCall' a mapping from
-- 'JoinArgumentId' to 'ResponsePath': from an integer that uniquely identifies
-- a join argument to the "path" at which we expect that value in the
-- response. With it, and with the actual reponse JSON value obtained from the
-- remote server, it constructs a corresponding mapping of, for each argument,
-- its extracted value.
--
-- 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).
buildJoinIndex ::
forall m.
(MonadError QErr m) =>
RemoteSchemaCall ->
AO.Object ->
m (IntMap.IntMap AO.Value)
buildJoinIndex RemoteSchemaCall {..} response =
for rscResponsePaths $ \(ResponsePath path) ->
go (AO.Object response) (map G.unName . NE.toList $ path)
where
go :: AO.Value -> [Text] -> m AO.Value
go value path = case path of
[] -> pure value
k : ks -> case value of
AO.Object obj -> do
objValue <-
AO.lookup k obj
`onNothing` throw500 ("failed to lookup key '" <> toTxt k <> "' in response")
go objValue ks
_ ->
throw500 $
"unexpected non-object json value found while path not empty: "
<> commaSeparated path
-------------------------------------------------------------------------------
-- Local helpers
-- 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"
ordJSONValueToGValue :: (MonadError QErr n) => AO.Value -> n (G.Value Void)
ordJSONValueToGValue =
either (throw400 ValidationFailed . fromErrorMessage) pure . P.jsonToGraphQL . AO.fromOrdered