mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-11-20 20:15:16 +03:00
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:
parent
e27e5b7ffe
commit
7f1f0606ed
@ -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
|
||||
|
@ -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`.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -99,7 +99,8 @@ emptyServeOptionsRaw =
|
||||
rsoTriggersErrorLogLevelStatus = Nothing,
|
||||
rsoAsyncActionsFetchBatchSize = Nothing,
|
||||
rsoPersistedQueries = Nothing,
|
||||
rsoPersistedQueriesTtl = Nothing
|
||||
rsoPersistedQueriesTtl = Nothing,
|
||||
rsoRemoteSchemaResponsePriority = Nothing
|
||||
}
|
||||
|
||||
mkServeOptionsSpec :: Hspec.Spec
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
type: add_remote_schema
|
||||
args:
|
||||
name: my-remote-schema
|
||||
definition:
|
||||
url: "{{GRAPHQL_SERVICE}}"
|
||||
forward_client_headers: true
|
@ -0,0 +1,3 @@
|
||||
type: remove_remote_schema
|
||||
args:
|
||||
name: my-remote-schema
|
@ -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}`);
|
||||
});
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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
44
server/tests-py/test_remote_schema_prioritize_data.py
Normal file
44
server/tests-py/test_remote_schema_prioritize_data.py
Normal 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')
|
43
server/tests-py/test_remote_schema_prioritize_none.py
Normal file
43
server/tests-py/test_remote_schema_prioritize_none.py
Normal 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')
|
Loading…
Reference in New Issue
Block a user