add config to prioritize data/error for remote schema fields

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/10528
Co-authored-by: Rob Dominguez <24390149+robertjdominguez@users.noreply.github.com>
GitOrigin-RevId: 49d0d7cbcc73fb0876d7ac3f5a1a8ff61ff800e8
This commit is contained in:
paritosh-08 2023-12-19 18:58:55 +05:30 committed by hasura-bot
parent e27e5b7ffe
commit 7f1f0606ed
30 changed files with 484 additions and 33 deletions

View File

@ -703,6 +703,32 @@ remote-schema-permissions)
kill_hge_servers
;;
remote-schema-prioritize-data)
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH REMOTE SCHEMA PRIORITIZE DATA/ERRORS ########>\n"
export HASURA_GRAPHQL_ADMIN_SECRET="HGE$RANDOM$RANDOM"
run_hge_with_args serve
wait_for_port 8080
pytest "${PYTEST_COMMON_ARGS[@]}" \
test_remote_schema_prioritize_none.py
kill_hge_servers
export HASURA_GRAPHQL_REMOTE_SCHEMA_PRIORITIZE_DATA=true
run_hge_with_args serve
wait_for_port 8080
pytest "${PYTEST_COMMON_ARGS[@]}" \
test_remote_schema_prioritize_data.py
unset HASURA_GRAPHQL_REMOTE_SCHEMA_PRIORITIZE_DATA
kill_hge_servers
;;
function-permissions)
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH FUNCTION PERMISSIONS ENABLED ########>\n"
export HASURA_GRAPHQL_INFER_FUNCTION_PERMISSIONS=false

View File

@ -978,6 +978,19 @@ The Redis URL to use for [query caching](/caching/enterprise-caching.mdx) and
| **Example** | `redis://username:password@host:port/db` |
| **Supported in** | Enterprise Edition only |
### Remote Schema prioritize data
Setting this will prioritize `data` or `errors` if both fields are present in the Remote Schema response.
| | |
| ------------------- | ---------------------------------------------- |
| **Flag** | `--remote-schema-prioritize-data` |
| **Env var** | `HASURA_GRAPHQL_REMOTE_SCHEMA_PRIORITIZE_DATA` |
| **Accepted values** | Boolean |
| **Options** | `true` or `false` |
| **Default** | `false` |
| **Supported in** | CE, Enterprise Edition, Cloud |
### Schema Sync Poll Interval
The interval, in milliseconds, to poll Metadata storage for updates. To disable, set this value to `0`.

View File

@ -316,7 +316,8 @@ serveOptions =
soTriggersErrorLogLevelStatus = Init._default Init.triggersErrorLogLevelStatusOption,
soAsyncActionsFetchBatchSize = Init._default Init.asyncActionsFetchBatchSizeOption,
soPersistedQueries = Init._default Init.persistedQueriesOption,
soPersistedQueriesTtl = Init._default Init.persistedQueriesTtlOption
soPersistedQueriesTtl = Init._default Init.persistedQueriesTtlOption,
soRemoteSchemaResponsePriority = Init._default Init.remoteSchemaResponsePriorityOption
}
-- | What log level should be used by the engine; this is not exported, and

View File

@ -171,7 +171,8 @@ data AppContext = AppContext
acAsyncActionsFetchInterval :: OptionalInterval,
acApolloFederationStatus :: ApolloFederationStatus,
acCloseWebsocketsOnMetadataChangeStatus :: CloseWebsocketsOnMetadataChangeStatus,
acSchemaSampledFeatureFlags :: SchemaSampledFeatureFlags
acSchemaSampledFeatureFlags :: SchemaSampledFeatureFlags,
acRemoteSchemaResponsePriority :: RemoteSchemaResponsePriority
}
-- | Collection of the LoggerCtx, the regular Logger and the PGLogger
@ -292,7 +293,8 @@ buildAppContextRule = proc (ServeOptions {..}, env, _keys, checkFeatureFlag) ->
acAsyncActionsFetchInterval = soAsyncActionsFetchInterval,
acApolloFederationStatus = soApolloFederationStatus,
acCloseWebsocketsOnMetadataChangeStatus = soCloseWebsocketsOnMetadataChangeStatus,
acSchemaSampledFeatureFlags = schemaSampledFeatureFlags
acSchemaSampledFeatureFlags = schemaSampledFeatureFlags,
acRemoteSchemaResponsePriority = soRemoteSchemaResponsePriority
}
where
buildEventEngineCtx = Inc.cache proc (httpPoolSize, fetchInterval, fetchBatchSize) -> do

View File

@ -36,9 +36,11 @@ import Data.ByteString.Lazy qualified as LBS
import Data.Dependent.Map qualified as DM
import Data.Environment qualified as Env
import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap
import Data.List.NonEmpty qualified as NE
import Data.Monoid (Any (..))
import Data.Text qualified as T
import Data.Text.Extended (toTxt, (<>>))
import Data.Vector qualified as Vec
import Hasura.Backends.DataConnector.Agent.Client (AgentLicenseKey)
import Hasura.Backends.Postgres.Instances.Transport (runPGMutationTransaction)
import Hasura.Base.Error
@ -89,7 +91,7 @@ import Hasura.Server.Prometheus
PrometheusMetrics (..),
)
import Hasura.Server.Telemetry.Counters qualified as Telem
import Hasura.Server.Types (ModelInfoLogState (..), MonadGetPolicies (..), ReadOnlyMode (..), RequestId (..))
import Hasura.Server.Types (ModelInfoLogState (..), MonadGetPolicies (..), ReadOnlyMode (..), RemoteSchemaResponsePriority (..), RequestId (..))
import Hasura.Services
import Hasura.Session (SessionVariable, SessionVariableValue, SessionVariables, UserInfo (..), filterSessionVariables)
import Hasura.Tracing (MonadTrace, attachMetadata)
@ -309,6 +311,7 @@ runGQ ::
SchemaCache ->
Init.AllowListStatus ->
ReadOnlyMode ->
RemoteSchemaResponsePriority ->
PrometheusMetrics ->
L.Logger L.Hasura ->
Maybe (CredentialCache AgentLicenseKey) ->
@ -320,7 +323,7 @@ runGQ ::
GQLReqUnparsed ->
ResponseInternalErrorsConfig ->
m (GQLQueryOperationSuccessLog, HttpResponse (Maybe GQResponse, EncJSON))
runGQ env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey reqId userInfo ipAddress reqHeaders queryType reqUnparsed responseErrorsConfig = do
runGQ env sqlGenCtx sc enableAL readOnlyMode remoteSchemaResponsePriority prometheusMetrics logger agentLicenseKey reqId userInfo ipAddress reqHeaders queryType reqUnparsed responseErrorsConfig = do
getModelInfoLogStatus' <- runGetModelInfoLogStatus
modelInfoLogStatus <- liftIO getModelInfoLogStatus'
let gqlMetrics = pmGraphQLRequestMetrics prometheusMetrics
@ -557,7 +560,7 @@ runGQ env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicen
runRemoteGQ fieldName rsi resultCustomizer gqlReq remoteJoins = Tracing.newSpan ("Remote schema query for root field " <>> fieldName) $ do
(telemTimeIO_DT, remoteResponseHeaders, resp) <-
doQErr $ E.execRemoteGQ env tracesPropagator userInfo reqHeaders (rsDef rsi) gqlReq
value <- extractFieldFromResponse fieldName resultCustomizer resp
value <- extractFieldFromResponse remoteSchemaResponsePriority fieldName resultCustomizer resp
(finalResponse, modelInfo) <-
doQErr
$ RJ.processRemoteJoins
@ -663,34 +666,77 @@ coalescePostgresMutations plan = do
_ -> Nothing
Just (oneSourceConfig, oneResolvedConnectionTemplate, mutations)
data RemoteGraphQLResponse
= -- | "data" is omitted or `null` and "errors" is non-empty list
RGROnlyErrors (NonEmpty J.Value)
| -- | "data" is present and non-null, "errors" is omitted
RGROnlyData JO.Value
| -- | "data" is present and non-null, "errors" is non-empty list
RGRDataAndErrors JO.Value (NonEmpty J.Value)
data GraphQLResponse
= GraphQLResponseErrors [J.Value]
| GraphQLResponseData JO.Value
decodeGraphQLResponse :: LBS.ByteString -> Either Text GraphQLResponse
decodeGraphQLResponse bs = do
-- | This function decodes the response from a remote server:
--
-- 1. First, errors are fetched from the response. Absence of errors field and `errors: null` both implies that there
-- are no errors.
-- 2. Next, the data field is fetched from the response.
-- 3. If a non-null data field is present in the response and there are no errors, then the data field is returned.
-- 4. If a non-null data field is not present in the response and there are errors, then the errors are thrown.
-- 5. If the data field is not present and there are no errors, then an error is thrown.
-- 6. If both data and errors are present, then we need to decide which one to pick based on the priority.
decodeGraphQLResponse :: RemoteSchemaResponsePriority -> LBS.ByteString -> Either Text GraphQLResponse
decodeGraphQLResponse remoteSchemaResponsePriority bs = do
val <- mapLeft T.pack $ JO.eitherDecode bs
valObj <- JO.asObject val
case JO.lookup "errors" valObj of
Just (JO.Array errs) -> Right $ GraphQLResponseErrors (toList $ JO.fromOrdered <$> errs)
Just _ -> Left "Invalid \"errors\" field in response from remote"
Nothing -> do
dataVal <- JO.lookup "data" valObj `onNothing` Left "Missing \"data\" field in response from remote"
Right $ GraphQLResponseData dataVal
response <- buildRemoteGraphQLResponse val
case response of
RGROnlyErrors errs -> Right $ GraphQLResponseErrors $ toList errs
RGROnlyData d -> Right $ GraphQLResponseData d
RGRDataAndErrors d errs ->
-- Both data (non-null) and errors (non-empty) is present, we need to decide which one to pick based on the
-- priority
case remoteSchemaResponsePriority of
RemoteSchemaResponseData -> Right $ GraphQLResponseData d
RemoteSchemaResponseErrors -> Right $ GraphQLResponseErrors $ toList errs
buildRemoteGraphQLResponse :: JO.Value -> Either Text RemoteGraphQLResponse
buildRemoteGraphQLResponse response = do
responseObj <- JO.asObject response
errors <-
case JO.lookup "errors" responseObj of
-- Absence of errors field and errors: null both implies that there are no errors
Just (JO.Array errs) -> do
neErrors <- maybeToEither "Empty \"errors\" field in response from remote" $ NE.nonEmpty $ Vec.toList errs
pure $ Just neErrors
Just JO.Null -> pure Nothing
Nothing -> pure Nothing
Just _ -> Left "Invalid \"errors\" field in response from remote"
case (JO.lookup "data" responseObj, errors) of
-- According to spec, If the data entry in the response is not present, the errors entry in the response must not be
-- empty.
(Nothing, Nothing) -> Left "Missing \"data\" field with no errors in response from remote"
(Nothing, Just nonEmptyErrors) -> Right $ RGROnlyErrors $ JO.fromOrdered <$> nonEmptyErrors
(Just JO.Null, Nothing) -> Left "Received null \"data\" field with no errors in response from remote"
(Just JO.Null, Just nonEmptyErrors) -> Right $ RGROnlyErrors $ JO.fromOrdered <$> nonEmptyErrors
(Just dataVal, Nothing) -> Right $ RGROnlyData dataVal
(Just dataVal, Just nonEmptyErrors) -> Right $ RGRDataAndErrors dataVal $ JO.fromOrdered <$> nonEmptyErrors
extractFieldFromResponse ::
forall m.
(Monad m) =>
RemoteSchemaResponsePriority ->
RootFieldAlias ->
ResultCustomizer ->
LBS.ByteString ->
ExceptT (Either GQExecError QErr) m JO.Value
extractFieldFromResponse fieldName resultCustomizer resp = do
extractFieldFromResponse remoteSchemaResponsePriority fieldName resultCustomizer resp = do
let fieldName' = G.unName $ _rfaAlias fieldName
dataVal <-
applyResultCustomizer resultCustomizer
<$> do
graphQLResponse <- decodeGraphQLResponse resp `onLeft` do400
graphQLResponse <- decodeGraphQLResponse remoteSchemaResponsePriority resp `onLeft` do400
case graphQLResponse of
GraphQLResponseErrors errs -> doGQExecError errs
GraphQLResponseData d -> pure d
@ -747,6 +793,7 @@ runGQBatched ::
Maybe (CredentialCache AgentLicenseKey) ->
RequestId ->
ResponseInternalErrorsConfig ->
RemoteSchemaResponsePriority ->
UserInfo ->
Wai.IpAddress ->
[HTTP.Header] ->
@ -754,10 +801,10 @@ runGQBatched ::
-- | the batched request with unparsed GraphQL query
GQLBatchedReqs (GQLReq GQLQueryText) ->
m (HttpLogGraphQLInfo, HttpResponse EncJSON)
runGQBatched env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey reqId responseErrorsConfig userInfo ipAddress reqHdrs queryType query =
runGQBatched env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey reqId responseErrorsConfig remoteSchemaResponsePriority userInfo ipAddress reqHdrs queryType query =
case query of
GQLSingleRequest req -> do
(gqlQueryOperationLog, httpResp) <- runGQ env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey reqId userInfo ipAddress reqHdrs queryType req responseErrorsConfig
(gqlQueryOperationLog, httpResp) <- runGQ env sqlGenCtx sc enableAL readOnlyMode remoteSchemaResponsePriority prometheusMetrics logger agentLicenseKey reqId userInfo ipAddress reqHdrs queryType req responseErrorsConfig
let httpLoggingGQInfo = (CommonHttpLogMetadata L.RequestModeSingle (Just (GQLSingleRequest (GQLQueryOperationSuccess gqlQueryOperationLog))), (PQHSetSingleton (gqolParameterizedQueryHash gqlQueryOperationLog)))
pure (httpLoggingGQInfo, snd <$> httpResp)
GQLBatchedReqs reqs -> do
@ -770,7 +817,7 @@ runGQBatched env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger age
flip HttpResponse []
. encJFromList
. map (either (encJFromJEncoding . encodeGQErr includeInternal) _hrBody)
responses <- for reqs \req -> fmap (req,) $ try $ (fmap . fmap . fmap) snd $ runGQ env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey reqId userInfo ipAddress reqHdrs queryType req responseErrorsConfig
responses <- for reqs \req -> fmap (req,) $ try $ (fmap . fmap . fmap) snd $ runGQ env sqlGenCtx sc enableAL readOnlyMode remoteSchemaResponsePriority prometheusMetrics logger agentLicenseKey reqId userInfo ipAddress reqHdrs queryType req responseErrorsConfig
let requestsOperationLogs = map fst $ rights $ map snd responses
batchOperationLogs =
map

View File

@ -103,7 +103,7 @@ import Hasura.Server.Prometheus
PrometheusMetrics (..),
)
import Hasura.Server.Telemetry.Counters qualified as Telem
import Hasura.Server.Types (GranularPrometheusMetricsState (..), ModelInfoLogState (..), MonadGetPolicies (..), RequestId, getRequestId)
import Hasura.Server.Types (GranularPrometheusMetricsState (..), ModelInfoLogState (..), MonadGetPolicies (..), RemoteSchemaResponsePriority, RequestId, getRequestId)
import Hasura.Services.Network
import Hasura.Session
import Hasura.Tracing qualified as Tracing
@ -483,6 +483,7 @@ onStart enabledLogTypes agentLicenseKey serverEnv wsConn shouldCaptureVariables
env <- liftIO $ acEnvironment <$> getAppContext appStateRef
sqlGenCtx <- liftIO $ acSQLGenCtx <$> getAppContext appStateRef
enableAL <- liftIO $ acEnableAllowlist <$> getAppContext appStateRef
remoteSchemaResponsePriority <- liftIO $ acRemoteSchemaResponsePriority <$> getAppContext appStateRef
(reqParsed, queryParts) <- Tracing.newSpan "Parse GraphQL" $ do
reqParsedE <- lift $ E.checkGQLExecution userInfo (reqHdrs, ipAddress) enableAL sc q requestId
@ -562,7 +563,7 @@ onStart enabledLogTypes agentLicenseKey serverEnv wsConn shouldCaptureVariables
pure $ (AnnotatedResponsePart telemTimeIO_DT Telem.Local finalResponse [], modelInfo)
E.ExecStepRemote rsi resultCustomizer gqlReq remoteJoins -> do
logQueryLog logger $ QueryLog q Nothing requestId QueryLogKindRemoteSchema
runRemoteGQ requestId q fieldName userInfo reqHdrs rsi resultCustomizer gqlReq remoteJoins tracesPropagator
runRemoteGQ requestId q fieldName userInfo reqHdrs rsi resultCustomizer gqlReq remoteJoins tracesPropagator remoteSchemaResponsePriority
E.ExecStepAction actionExecPlan _ remoteJoins -> do
logQueryLog logger $ QueryLog q Nothing requestId QueryLogKindAction
(time, (resp, _), modelInfo) <- doQErr $ do
@ -654,7 +655,7 @@ onStart enabledLogTypes agentLicenseKey serverEnv wsConn shouldCaptureVariables
pure $ (AnnotatedResponsePart time Telem.Empty resp $ fromMaybe [] hdrs, modelInfo)
E.ExecStepRemote rsi resultCustomizer gqlReq remoteJoins -> do
logQueryLog logger $ QueryLog q Nothing requestId QueryLogKindRemoteSchema
runRemoteGQ requestId q fieldName userInfo reqHdrs rsi resultCustomizer gqlReq remoteJoins tracesPropagator
runRemoteGQ requestId q fieldName userInfo reqHdrs rsi resultCustomizer gqlReq remoteJoins tracesPropagator remoteSchemaResponsePriority
E.ExecStepRaw json -> do
logQueryLog logger $ QueryLog q Nothing requestId QueryLogKindIntrospection
(,[]) <$> buildRaw json
@ -824,13 +825,14 @@ onStart enabledLogTypes agentLicenseKey serverEnv wsConn shouldCaptureVariables
GQLReqOutgoing ->
Maybe RJ.RemoteJoins ->
Tracing.HttpPropagator ->
RemoteSchemaResponsePriority ->
ExceptT (Either GQExecError QErr) (ExceptT () m) (AnnotatedResponsePart, [ModelInfoPart])
runRemoteGQ requestId reqUnparsed fieldName userInfo reqHdrs rsi resultCustomizer gqlReq remoteJoins tracesPropagator = Tracing.newSpan ("Remote schema query for root field " <>> fieldName) $ do
runRemoteGQ requestId reqUnparsed fieldName userInfo reqHdrs rsi resultCustomizer gqlReq remoteJoins tracesPropagator remoteSchemaResponsePriority = Tracing.newSpan ("Remote schema query for root field " <>> fieldName) $ do
env <- liftIO $ acEnvironment <$> getAppContext appStateRef
(telemTimeIO_DT, _respHdrs, resp) <-
doQErr
$ E.execRemoteGQ env tracesPropagator userInfo reqHdrs (rsDef rsi) gqlReq
value <- hoist lift $ extractFieldFromResponse fieldName resultCustomizer resp
value <- hoist lift $ extractFieldFromResponse remoteSchemaResponsePriority fieldName resultCustomizer resp
(finalResponse, modelInfo) <-
doQErr
$ RJ.processRemoteJoins

View File

@ -588,7 +588,7 @@ v1Alpha1GQHandler queryType query = do
reqHeaders <- asks hcReqHeaders
ipAddress <- asks hcSourceIpAddress
requestId <- asks hcRequestId
GH.runGQBatched acEnvironment acSQLGenCtx schemaCache acEnableAllowlist appEnvEnableReadOnlyMode appEnvPrometheusMetrics (_lsLogger appEnvLoggers) appEnvLicenseKeyCache requestId acResponseInternalErrorsConfig userInfo ipAddress reqHeaders queryType query
GH.runGQBatched acEnvironment acSQLGenCtx schemaCache acEnableAllowlist appEnvEnableReadOnlyMode appEnvPrometheusMetrics (_lsLogger appEnvLoggers) appEnvLicenseKeyCache requestId acResponseInternalErrorsConfig acRemoteSchemaResponsePriority userInfo ipAddress reqHeaders queryType query
v1GQHandler ::
( MonadIO m,
@ -954,7 +954,7 @@ httpApp setupHook appStateRef AppEnv {..} consoleType ekgStore closeWebsocketsOn
Spock.PATCH -> pure EP.PATCH
other -> throw400 BadRequest $ "Method " <> tshow other <> " not supported."
_ -> throw400 BadRequest $ "Nonstandard method not allowed for REST endpoints"
fmap JSONResp <$> runCustomEndpoint acEnvironment acSQLGenCtx schemaCache acEnableAllowlist appEnvEnableReadOnlyMode appEnvPrometheusMetrics (_lsLogger appEnvLoggers) appEnvLicenseKeyCache requestId userInfo reqHeaders ipAddress req endpoints responseErrorsConfig
fmap JSONResp <$> runCustomEndpoint acEnvironment acSQLGenCtx schemaCache acEnableAllowlist appEnvEnableReadOnlyMode acRemoteSchemaResponsePriority appEnvPrometheusMetrics (_lsLogger appEnvLoggers) appEnvLicenseKeyCache requestId userInfo reqHeaders ipAddress req endpoints responseErrorsConfig
-- See Issue #291 for discussion around restified feature
Spock.hookRouteAll ("api" <//> "rest" <//> Spock.wildcard) $ \wildcard -> do

View File

@ -223,6 +223,7 @@ mkServeOptions sor@ServeOptionsRaw {..} = do
soAsyncActionsFetchBatchSize <- withOptionDefault rsoAsyncActionsFetchBatchSize asyncActionsFetchBatchSizeOption
soPersistedQueries <- withOptionDefault rsoPersistedQueries persistedQueriesOption
soPersistedQueriesTtl <- withOptionDefault rsoPersistedQueriesTtl persistedQueriesTtlOption
soRemoteSchemaResponsePriority <- withOptionDefault rsoRemoteSchemaResponsePriority remoteSchemaResponsePriorityOption
pure ServeOptions {..}
-- | Fetch Postgres 'Query.ConnParams' components from the environment

View File

@ -68,6 +68,7 @@ module Hasura.Server.Init.Arg.Command.Serve
asyncActionsFetchBatchSizeOption,
persistedQueriesOption,
persistedQueriesTtlOption,
remoteSchemaResponsePriorityOption,
-- * Pretty Printer
serveCmdFooter,
@ -162,6 +163,7 @@ serveCommandParser =
<*> parseAsyncActionsFetchBatchSize
<*> parsePersistedQueries
<*> parsePersistedQueriesTtl
<*> parseRemoteSchemaResponsePriority
--------------------------------------------------------------------------------
-- Serve Options
@ -1311,6 +1313,22 @@ parsePersistedQueriesTtl =
<> Opt.help (Config._helpMessage persistedQueriesTtlOption)
)
remoteSchemaResponsePriorityOption :: Config.Option (Types.RemoteSchemaResponsePriority)
remoteSchemaResponsePriorityOption =
Config.Option
{ Config._default = Types.RemoteSchemaResponseErrors,
Config._envVar = "HASURA_GRAPHQL_REMOTE_SCHEMA_PRIORITIZE_DATA",
Config._helpMessage = "Prioritize data over errors for remote schema responses (default: false)."
}
parseRemoteSchemaResponsePriority :: Opt.Parser (Maybe Types.RemoteSchemaResponsePriority)
parseRemoteSchemaResponsePriority =
(bool Nothing (Just Types.RemoteSchemaResponseData))
<$> Opt.switch
( Opt.long "remote-schema-prioritize-data"
<> Opt.help (Config._helpMessage remoteSchemaResponsePriorityOption)
)
--------------------------------------------------------------------------------
-- Pretty Printer

View File

@ -328,7 +328,8 @@ data ServeOptionsRaw impl = ServeOptionsRaw
rsoTriggersErrorLogLevelStatus :: Maybe Server.Types.TriggersErrorLogLevelStatus,
rsoAsyncActionsFetchBatchSize :: Maybe Int,
rsoPersistedQueries :: Maybe Server.Types.PersistedQueriesState,
rsoPersistedQueriesTtl :: Maybe Int
rsoPersistedQueriesTtl :: Maybe Int,
rsoRemoteSchemaResponsePriority :: Maybe Server.Types.RemoteSchemaResponsePriority
}
-- | Whether or not to serve Console assets.
@ -634,7 +635,8 @@ data ServeOptions impl = ServeOptions
soTriggersErrorLogLevelStatus :: Server.Types.TriggersErrorLogLevelStatus,
soAsyncActionsFetchBatchSize :: Int,
soPersistedQueries :: Server.Types.PersistedQueriesState,
soPersistedQueriesTtl :: Int
soPersistedQueriesTtl :: Int,
soRemoteSchemaResponsePriority :: Server.Types.RemoteSchemaResponsePriority
}
-- | 'ResponseInternalErrorsConfig' represents the encoding of the

View File

@ -387,3 +387,6 @@ instance FromEnv Server.Types.TriggersErrorLogLevelStatus where
instance FromEnv Server.Types.PersistedQueriesState where
fromEnv = fmap (bool Server.Types.PersistedQueriesDisabled Server.Types.PersistedQueriesEnabled) . fromEnv @Bool
instance FromEnv Server.Types.RemoteSchemaResponsePriority where
fromEnv = fmap (bool Server.Types.RemoteSchemaResponseErrors Server.Types.RemoteSchemaResponseData) . fromEnv @Bool

View File

@ -118,6 +118,7 @@ runCustomEndpoint ::
SchemaCache ->
Init.AllowListStatus ->
ReadOnlyMode ->
RemoteSchemaResponsePriority ->
PrometheusMetrics ->
L.Logger L.Hasura ->
Maybe (CredentialCache AgentLicenseKey) ->
@ -129,7 +130,7 @@ runCustomEndpoint ::
EndpointTrie GQLQueryWithText ->
Init.ResponseInternalErrorsConfig ->
m (HttpLogGraphQLInfo, HttpResponse EncJSON)
runCustomEndpoint env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey requestId userInfo reqHeaders ipAddress RestRequest {..} endpoints responseErrorsConfig = do
runCustomEndpoint env sqlGenCtx sc enableAL readOnlyMode remoteSchemaResponsePriority prometheusMetrics logger agentLicenseKey requestId userInfo reqHeaders ipAddress RestRequest {..} endpoints responseErrorsConfig = do
-- First match the path to an endpoint.
case matchPath reqMethod (T.split (== '/') reqPath) endpoints of
MatchFound (queryx :: EndpointMetadata GQLQueryWithText) matches ->
@ -159,7 +160,7 @@ runCustomEndpoint env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logge
-- with the query string from the schema cache, and pass it
-- through to the /v1/graphql endpoint.
(httpLoggingMetadata, handlerResp) <- do
(gqlOperationLog, resp) <- GH.runGQ env sqlGenCtx sc enableAL readOnlyMode prometheusMetrics logger agentLicenseKey requestId userInfo ipAddress reqHeaders E.QueryHasura (mkPassthroughRequest queryx resolvedVariables) responseErrorsConfig
(gqlOperationLog, resp) <- GH.runGQ env sqlGenCtx sc enableAL readOnlyMode remoteSchemaResponsePriority prometheusMetrics logger agentLicenseKey requestId userInfo ipAddress reqHeaders E.QueryHasura (mkPassthroughRequest queryx resolvedVariables) responseErrorsConfig
let httpLoggingGQInfo = (CommonHttpLogMetadata RequestModeNonBatchable Nothing, (PQHSetSingleton (gqolParameterizedQueryHash gqlOperationLog)))
return (httpLoggingGQInfo, fst <$> resp)
case sequence handlerResp of

View File

@ -31,6 +31,7 @@ module Hasura.Server.Types
ExtPersistedQueryRequest (..),
ExtQueryReqs (..),
MonadGetPolicies (..),
RemoteSchemaResponsePriority (..),
)
where
@ -360,3 +361,17 @@ instance (MonadGetPolicies m) => MonadGetPolicies (StateT w m) where
runGetApiTimeLimit = lift runGetApiTimeLimit
runGetPrometheusMetricsGranularity = lift runGetPrometheusMetricsGranularity
runGetModelInfoLogStatus = lift $ runGetModelInfoLogStatus
-- | The priority of the response to be sent to the client for remote schema fields if there is both errors as well as
-- data in the remote response.
-- Read more about how we decode the remote response at `decodeGraphQLResp` in `Hasura.GraphQL.Transport.HTTP`
--
-- If there is both errors and data in the remote response, then:
--
-- * If the priority is set to `RemoteSchemaResponseData`, then the data is sent to the client.
-- * If the priority is set to `RemoteSchemaResponseErrors`, then the errors are sent to the client.
data RemoteSchemaResponsePriority
= -- | Data from the remote schema is sent
RemoteSchemaResponseData
| -- | Errors from the remote schema is sent
RemoteSchemaResponseErrors

View File

@ -99,7 +99,8 @@ emptyServeOptionsRaw =
rsoTriggersErrorLogLevelStatus = Nothing,
rsoAsyncActionsFetchBatchSize = Nothing,
rsoPersistedQueries = Nothing,
rsoPersistedQueriesTtl = Nothing
rsoPersistedQueriesTtl = Nothing,
rsoRemoteSchemaResponsePriority = Nothing
}
mkServeOptionsSpec :: Hspec.Spec

View File

@ -98,7 +98,8 @@ serveOptions =
soTriggersErrorLogLevelStatus = Init._default Init.triggersErrorLogLevelStatusOption,
soAsyncActionsFetchBatchSize = Init._default Init.asyncActionsFetchBatchSizeOption,
soPersistedQueries = Init._default Init.persistedQueriesOption,
soPersistedQueriesTtl = Init._default Init.persistedQueriesTtlOption
soPersistedQueriesTtl = Init._default Init.persistedQueriesTtlOption,
soRemoteSchemaResponsePriority = Init._default Init.remoteSchemaResponsePriorityOption
}
-- | What log level should be used by the engine; this is not exported, and

View File

@ -0,0 +1,22 @@
- description: Query from the remote schema returning errors and data
url: /v1/graphql
status: 200
headers:
x-fake-operation-name: DataAndError
response:
data:
test:
- id: 1
created_at: '2023-12-04T08:14:52.1851+00:00'
- id: 2
created_at: '2023-12-04T08:14:52.680052+00:00'
- id: 3
created_at: '2023-12-04T08:14:53.335059+00:00'
query:
query: |
query DataAndError {
test {
id
created_at
}
}

View File

@ -0,0 +1,22 @@
- description: Query from the remote schema returning data only
url: /v1/graphql
status: 200
headers:
x-fake-operation-name: DataOnly
response:
data:
test:
- id: 1
created_at: '2023-12-04T08:14:52.1851+00:00'
- id: 2
created_at: '2023-12-04T08:14:52.680052+00:00'
- id: 3
created_at: '2023-12-04T08:14:53.335059+00:00'
query:
query: |
query DataOnly {
test {
id
created_at
}
}

View File

@ -0,0 +1,20 @@
- description: Query from the remote schema returning errors only
url: /v1/graphql
status: 200
headers:
x-fake-operation-name: ErrorOnly
response:
data:
errors:
- extensions:
code: validation-failed
path: "$.selectionSet.test_lol"
message: 'field ''test'' not found in type: ''query_root'''
query:
query: |
query ErrorOnly {
test {
id
created_at
}
}

View File

@ -0,0 +1,20 @@
- description: Query from the remote schema returning errors and data
url: /v1/graphql
status: 200
headers:
x-fake-operation-name: DataAndError
response:
data:
errors:
- extensions:
code: validation-failed
path: "$.selectionSet.test_lol"
message: 'field ''test'' not found in type: ''query_root'''
query:
query: |
query DataAndError {
test {
id
created_at
}
}

View File

@ -0,0 +1,22 @@
- description: Query from the remote schema returning data only
url: /v1/graphql
status: 200
headers:
x-fake-operation-name: DataOnly
response:
data:
test:
- id: 1
created_at: '2023-12-04T08:14:52.1851+00:00'
- id: 2
created_at: '2023-12-04T08:14:52.680052+00:00'
- id: 3
created_at: '2023-12-04T08:14:53.335059+00:00'
query:
query: |
query DataOnly {
test {
id
created_at
}
}

View File

@ -0,0 +1,20 @@
- description: Query from the remote schema returning errors only
url: /v1/graphql
status: 200
headers:
x-fake-operation-name: ErrorOnly
response:
data:
errors:
- extensions:
code: validation-failed
path: "$.selectionSet.test_lol"
message: 'field ''test'' not found in type: ''query_root'''
query:
query: |
query ErrorOnly {
test {
id
created_at
}
}

View File

@ -0,0 +1,6 @@
type: add_remote_schema
args:
name: my-remote-schema
definition:
url: "{{GRAPHQL_SERVICE}}"
forward_client_headers: true

View File

@ -0,0 +1,3 @@
type: remove_remote_schema
args:
name: my-remote-schema

View File

@ -0,0 +1,39 @@
const express = require('express');
const path = require("path");
const fs = require('fs');
const app = express();
app.use(express.json());
app.post('/', (req, res) => {
let fileName;
switch(req.body.operationName) {
case "IntrospectionQuery":
fileName = 'introspection.json';
break;
default:
switch(req.header('x-fake-operation-name')) {
case "DataOnly":
fileName = 'data_only.json';
break;
case "ErrorOnly":
fileName = 'error_only.json';
break;
case "DataAndError":
fileName = 'data_and_error.json';
break;
default:
throw new Error("expected a header `x-fake-operation-name` to be from the list [DataOnly, ErrorOnly, DataAndError]");
}
}
fs.readFile(path.resolve(__dirname, 'returns_data_and_errors_responses', fileName), 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
res.json(JSON.parse(data));
});
});
let port = process.env.PORT || 4000;
app.listen(port, () => {
console.log(`🚀 Server ready at http://localhost::${port}`);
});

View File

@ -0,0 +1,27 @@
{
"data": {
"test": [
{
"id": 1,
"created_at": "2023-12-04T08:14:52.1851+00:00"
},
{
"id": 2,
"created_at": "2023-12-04T08:14:52.680052+00:00"
},
{
"id": 3,
"created_at": "2023-12-04T08:14:53.335059+00:00"
}
]
},
"errors": [
{
"message": "field 'test' not found in type: 'query_root'",
"extensions": {
"path": "$.selectionSet.test_lol",
"code": "validation-failed"
}
}
]
}

View File

@ -0,0 +1,18 @@
{
"data": {
"test": [
{
"id": 1,
"created_at": "2023-12-04T08:14:52.1851+00:00"
},
{
"id": 2,
"created_at": "2023-12-04T08:14:52.680052+00:00"
},
{
"id": 3,
"created_at": "2023-12-04T08:14:53.335059+00:00"
}
]
}
}

View File

@ -0,0 +1,11 @@
{
"errors": [
{
"message": "field 'test' not found in type: 'query_root'",
"extensions": {
"path": "$.selectionSet.test_lol",
"code": "validation-failed"
}
}
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
import pytest
from conftest import extract_server_address_from
from remote_server import NodeGraphQL
from validate import check_query_f
pytestmark = [
pytest.mark.admin_secret,
pytest.mark.hge_env('HASURA_GRAPHQL_REMOTE_SCHEMA_PRIORITIZE_DATA', 'true'),
]
@pytest.fixture(scope='class')
@pytest.mark.early
def fake_graphql_service(worker_id: str, hge_fixture_env: dict[str, str]):
(_, port) = extract_server_address_from('GRAPHQL_SERVICE')
server = NodeGraphQL(worker_id, 'remote_schemas/nodejs/returns_data_and_errors.js', port=port)
server.start()
print(f'{fake_graphql_service.__name__} server started on {server.url}')
hge_fixture_env['GRAPHQL_SERVICE'] = server.url
yield server
server.stop()
use_test_fixtures = pytest.mark.usefixtures(
'fake_graphql_service',
'per_method_tests_db_state',
)
@use_test_fixtures
class TestRemoteSchemaPrioritizeData:
@classmethod
def dir(cls):
return "queries/remote_schemas/validate_data_errors_prioritization/"
def test_data_only_query(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + 'data_prioritization/test_data_only_query.yaml')
def test_error_only_query(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + 'data_prioritization/test_error_only_query.yaml')
def test_data_and_errors_query(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + 'data_prioritization/test_data_and_errors_query.yaml')

View File

@ -0,0 +1,43 @@
#!/usr/bin/env python3
import pytest
from conftest import extract_server_address_from
from remote_server import NodeGraphQL
from validate import check_query_f
pytestmark = [
pytest.mark.admin_secret,
]
@pytest.fixture(scope='class')
@pytest.mark.early
def fake_graphql_service(worker_id: str, hge_fixture_env: dict[str, str]):
(_, port) = extract_server_address_from('GRAPHQL_SERVICE')
server = NodeGraphQL(worker_id, 'remote_schemas/nodejs/returns_data_and_errors.js', port=port)
server.start()
print(f'{fake_graphql_service.__name__} server started on {server.url}')
hge_fixture_env['GRAPHQL_SERVICE'] = server.url
yield server
server.stop()
use_test_fixtures = pytest.mark.usefixtures(
'fake_graphql_service',
'per_method_tests_db_state',
)
@use_test_fixtures
class TestRemoteSchemaPrioritizeErrors:
@classmethod
def dir(cls):
return "queries/remote_schemas/validate_data_errors_prioritization/"
def test_data_only_query(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + 'no_prioritization/test_data_only_query.yaml')
def test_error_only_query(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + 'no_prioritization/test_error_only_query.yaml')
def test_data_and_errors_query(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + 'no_prioritization/test_data_and_errors_query.yaml')