Skip to content

Commit

Permalink
server: add "extensions" field to action webhook error schema
Browse files Browse the repository at this point in the history
  • Loading branch information
evertedsphere authored and hasura-bot committed Sep 17, 2021
1 parent d916326 commit 8bfcd9a
Show file tree
Hide file tree
Showing 24 changed files with 274 additions and 42 deletions.
6 changes: 3 additions & 3 deletions CHANGELOG.md
@@ -1,7 +1,6 @@
# Hasura GraphQL Engine Changelog

## Next release

(Add entries below in the order of server, console, cli, docs, others)

- server: add webhook transformations for Actions and EventTriggers
Expand All @@ -14,6 +13,8 @@
- server: prevent empty subscription roots in the schema (#6898)
- console: support tracking of functions with return a single row

- server: support `extensions` field in error responses from action webhook endpoints (fix #4001)

## v2.0.9

- server: fix export_metadata V2 bug which included cron triggers with `include_in_metadata: false`
Expand Down Expand Up @@ -107,8 +108,7 @@ generally, JSON-style aggregates.
- server: Support computed fields in query filter (`where` argument) (close #7100)
- server: add a `$.detail.operation.request_mode` field to `http-log` which takes the values `"single"` or `"batched"` to log whether a GraphQL request was executed on its own or as part of a batch
- server: add `query` field to `http-log` and `websocket-log` in non-error cases
- server: Add global limit to BigQuery via the `global_select_limit`
field in the connection configuration
- server: Add global limit to BigQuery via the `global_select_limit` field in the connection configuration
- server: include action and event names in log output
- server: log all HTTP errors in remote schema calls as `remote-schema-error` with details
- server: For BigQuery, make `global_select_limit` configuration optional with a default value of
Expand Down
22 changes: 20 additions & 2 deletions docs/graphql/core/actions/action-handlers.rst
Expand Up @@ -65,10 +65,28 @@ An error object looks like:
{
"message": "<mandatory-error-message>",
"code": "<optional-error-code>"
"extensions": "<optional-json-object>"
}
The HTTP status code must be ``4xx`` for an error response.
where ``extensions`` is an optional JSON value.

If present, ``extensions`` should be a JSON object, which may have a status code
field ``code``, along with other data you may want to add to your errors:

.. code-block:: json
{
"code": "<optional-error-code>",
"optionalField1": "<custom-data-here>"
}
The HTTP status code must be ``4xx`` in order to indicate an error response.

For backwards compatibility with previous versions of Hasura, the ``code`` field
may also be supplied at the root of the error object, i.e. at ``$.code``. This
will be deprecated in a future release, and providing ``code`` within
``extensions`` is preferred.



Example
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/Backends/Postgres/Connection.hs
Expand Up @@ -221,7 +221,7 @@ enablePgcryptoExtension = do
_ -> requiredError
where
requiredError =
(err500 PostgresError requiredMessage) { qeInternal = Just $ toJSON e }
(err500 PostgresError requiredMessage) { qeInternal = Just $ ExtraInternal $ toJSON e }
requiredMessage =
"pgcrypto extension is required, but it could not be created;"
<> " encountered unknown postgres error"
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/Backends/Postgres/DDL/RunSQL.hs
Expand Up @@ -167,7 +167,7 @@ runRunSQL q@RunSQL{..} = do
fmap (encJFromJValue @RunSQLRes) . liftTx . Q.multiQE rawSqlErrHandler . Q.fromText
where
rawSqlErrHandler txe =
(err400 PostgresError "query execution failed") { qeInternal = Just $ toJSON txe }
(err400 PostgresError "query execution failed") { qeInternal = Just $ ExtraInternal $ toJSON txe }

-- | @'withMetadataCheck' cascade action@ runs @action@ and checks if the schema changed as a
-- result. If it did, it checks to ensure the changes do not violate any integrity constraints, and
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/Backends/Postgres/Execute/Types.hs
Expand Up @@ -75,7 +75,7 @@ defaultTxErrorHandler = mkTxErrorHandler (const False)
mkTxErrorHandler :: (PGErrorType -> Bool) -> Q.PGTxErr -> QErr
mkTxErrorHandler isExpectedError txe = fromMaybe unexpectedError expectedError
where
unexpectedError = (internalError "database query error") { qeInternal = Just $ J.toJSON txe }
unexpectedError = (internalError "database query error") { qeInternal = Just $ ExtraInternal $ J.toJSON txe }
expectedError = uncurry err400 <$> do
errorDetail <- Q.getPGStmtErr txe
message <- Q.edMessage errorDetail
Expand Down
63 changes: 44 additions & 19 deletions server/src-lib/Hasura/Base/Error.hs
Expand Up @@ -3,6 +3,7 @@
module Hasura.Base.Error
( Code(..)
, QErr(..)
, QErrExtra(..)
, encodeJSONPath
, encodeQErr
, encodeGQLErr
Expand Down Expand Up @@ -152,23 +153,41 @@ data QErr
, qeStatus :: !N.Status
, qeError :: !Text
, qeCode :: !Code
, qeInternal :: !(Maybe Value)
, qeInternal :: !(Maybe QErrExtra)
} deriving (Show, Eq)

-- | Extra context for a QErr, which can either be information from an internal
-- error (e.g. from Postgres, or from a network operation timing out), or
-- context provided when an external service or operation fails, for instance, a
-- webhook error response may provide additional context in the `extensions`
-- key.
data QErrExtra
= ExtraExtensions !Value
| ExtraInternal !Value
deriving (Show, Eq)

instance ToJSON QErrExtra where
toJSON = \case
ExtraExtensions v -> v
ExtraInternal v -> v

instance ToJSON QErr where
toJSON (QErr jPath _ msg code Nothing) =
object
[ "path" .= encodeJSONPath jPath
, "error" .= msg
, "code" .= code
]
toJSON (QErr jPath _ msg code (Just ie)) =
object
[ "path" .= encodeJSONPath jPath
, "error" .= msg
, "code" .= code
, "internal" .= ie
]
toJSON (QErr jPath _ msg code (Just extra)) = object $
case extra of
ExtraInternal e -> err ++ [ "internal" .= e ]
ExtraExtensions{} -> err
where
err =
[ "path" .= encodeJSONPath jPath
, "error" .= msg
, "code" .= code
]

noInternalQErrEnc :: QErr -> Value
noInternalQErrEnc (QErr jPath _ msg code _) =
Expand All @@ -179,18 +198,24 @@ noInternalQErrEnc (QErr jPath _ msg code _) =
]

encodeGQLErr :: Bool -> QErr -> Value
encodeGQLErr includeInternal (QErr jPath _ msg code mIE) =
encodeGQLErr includeInternal (QErr jPath _ msg code maybeExtra) =
object
[ "message" .= msg
, "extensions" .= extnsObj
]
where
extnsObj = object $ bool codeAndPath
(codeAndPath ++ internal) includeInternal
codeAndPath = [ "code" .= code
, "path" .= encodeJSONPath jPath
]
internal = maybe [] (\ie -> ["internal" .= ie]) mIE
appendIf cond a b = if cond then a ++ b else a

extnsObj = case maybeExtra of
Nothing -> object codeAndPath
-- if an `extensions` key is given in the error response from the webhook,
-- we ignore the `code` key regardless of whether the `extensions` object
-- contains a `code` field:
Just (ExtraExtensions v) -> v
Just (ExtraInternal v) ->
object $ appendIf includeInternal codeAndPath ["internal" .= v]
codeAndPath = ["path" .= encodeJSONPath jPath,
"code" .= code]

-- whether internal should be included or not
encodeQErr :: Bool -> QErr -> Value
Expand Down Expand Up @@ -226,17 +251,17 @@ instance Q.FromPGConnErr QErr where
fromPGConnErr c
| "too many clients" `T.isInfixOf` (Q.getConnErr c) =
let e = err500 PostgresMaxConnectionsError "max connections reached on postgres"
in e {qeInternal = Just $ toJSON c}
in e {qeInternal = Just $ ExtraInternal $ toJSON c}

fromPGConnErr c =
let e = err500 PostgresError "connection error"
in e {qeInternal = Just $ toJSON c}
in e {qeInternal = Just $ ExtraInternal $ toJSON c}

-- Postgres Transaction error
instance Q.FromPGTxErr QErr where
fromPGTxErr txe =
let e = err500 PostgresError "postgres tx error"
in e {qeInternal = Just $ toJSON txe}
in e {qeInternal = Just $ ExtraInternal $ toJSON txe}

err400 :: Code -> Text -> QErr
err400 c t = QErr [] N.status400 t c Nothing
Expand Down Expand Up @@ -284,7 +309,7 @@ internalError = err500 Unexpected

throw500WithDetail :: (QErrM m) => Text -> Value -> m a
throw500WithDetail t detail =
throwError $ (err500 Unexpected t) {qeInternal = Just detail}
throwError $ (err500 Unexpected t) {qeInternal = Just $ ExtraInternal detail}

modifyQErr :: (QErrM m)
=> (QErr -> QErr) -> m a -> m a
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/GraphQL/Execute.hs
Expand Up @@ -220,7 +220,7 @@ checkQueryInAllowlist enableAL userInfo req sc =
where
modErr e =
let msg = "query is not in any of the allowlists"
in e{qeInternal = Just $ J.object [ "message" J..= J.String msg]}
in e{qeInternal = Just $ ExtraInternal $ J.object [ "message" J..= J.String msg]}

isQueryInAllowlist q = HS.member gqlQuery
where
Expand Down
6 changes: 3 additions & 3 deletions server/src-lib/Hasura/GraphQL/Execute/Action.hs
Expand Up @@ -439,7 +439,7 @@ callWebhook env manager outputType outputFields reqHeaders confHeaders
addInternalToErr e =
let actionInternalError = J.toJSON $
ActionInternalError (J.String "unexpected response") requestInfo $ Just responseInfo
in e{qeInternal = Just actionInternalError}
in e{qeInternal = Just $ ExtraInternal actionInternalError}

if | HTTP.statusIsSuccessful responseStatus -> do
let expectingArray = isListType outputType
Expand All @@ -457,10 +457,10 @@ callWebhook env manager outputType outputFields reqHeaders confHeaders
pure (webhookResponse, mkSetCookieHeaders responseWreq)

| HTTP.statusIsClientError responseStatus -> do
ActionWebhookErrorResponse message maybeCode <-
ActionWebhookErrorResponse message maybeCode maybeExtensions <-
modifyQErr addInternalToErr $ decodeValue responseValue
let code = maybe Unexpected ActionWebhookCode maybeCode
qErr = QErr [] responseStatus message code Nothing
qErr = QErr [] responseStatus message code (ExtraExtensions <$> maybeExtensions)
throwError qErr

| otherwise -> do
Expand Down
5 changes: 3 additions & 2 deletions server/src-lib/Hasura/GraphQL/Execute/Action/Types.hs
Expand Up @@ -73,8 +73,9 @@ $(J.deriveJSON (J.aesonDrop 4 J.snakeCase) ''ActionWebhookPayload)

data ActionWebhookErrorResponse
= ActionWebhookErrorResponse
{ _awerMessage :: !Text
, _awerCode :: !(Maybe Text)
{ _awerMessage :: !Text
, _awerCode :: !(Maybe Text)
, _awerExtensions :: !(Maybe J.Value)
} deriving (Show, Eq)
$(J.deriveJSON (J.aesonDrop 5 J.snakeCase) ''ActionWebhookErrorResponse)

Expand Down
Expand Up @@ -150,7 +150,7 @@ getRemoteSchemaResponse env manager requestHeaders userInfo (RemoteSchemaCall rs
in AO.asObject v' `onLeft` throw500
| otherwise ->
throwError (err400 Unexpected "Errors from remote server") {
qeInternal = Just $ A.object ["errors" A..= (AO.fromOrdered <$> errors)]
qeInternal = Just $ ExtraInternal $ A.object ["errors" A..= (AO.fromOrdered <$> errors)]
}

-- | Attempt to extract a deeply nested value from a remote source's 'AO.Value'
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/GraphQL/RemoteServer.hs
Expand Up @@ -527,7 +527,7 @@ throwRemoteSchemaWithInternal
=> Text -> a -> m b
throwRemoteSchemaWithInternal msg v =
let err = err400 RemoteSchemaError msg
in throwError err{qeInternal = Just $ J.toJSON v}
in throwError err{qeInternal = Just $ ExtraInternal $ J.toJSON v}

throwRemoteSchemaHttp
:: QErrM m
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/RQL/DDL/Metadata.hs
Expand Up @@ -405,7 +405,7 @@ runDropInconsistentMetadata _ = do
let droppableInconsistentObjects = filter droppableInconsistentMetadata newInconsistentObjects
unless (null droppableInconsistentObjects) $
throwError (err400 Unexpected "cannot continue due to new inconsistent metadata")
{ qeInternal = Just $ toJSON newInconsistentObjects }
{ qeInternal = Just $ ExtraInternal $ toJSON newInconsistentObjects }
return successMsg

purgeMetadataObj :: MetadataObjId -> MetadataModifier
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/RQL/DDL/Schema/Cache/Dependencies.hs
Expand Up @@ -71,7 +71,7 @@ performIteration iterationNumber cache inconsistencies dependencies = do
-- unless we did something very wrong, so halt the process and abort with some
-- debugging information.
throwError (err500 Unexpected "schema dependency resolution failed to terminate")
{ qeInternal = Just $ object
{ qeInternal = Just $ ExtraInternal $ object
[ "inconsistent_objects" .= object
[ "old" .= inconsistencies
, "new" .= newInconsistencies ]
Expand Down
20 changes: 15 additions & 5 deletions server/src-lib/Hasura/RQL/Types/SchemaCache/Build.hs
Expand Up @@ -110,7 +110,17 @@ withRecordInconsistency f = proc (e, (metadataObject, s)) -> do
result <- runErrorA f -< (e, s)
case result of
Left err -> do
recordInconsistency -< ((qeInternal err, metadataObject), qeError err)
case qeInternal err of
Just (ExtraExtensions exts) ->
-- the QErr type contains an optional qeInternal :: Maybe QErrExtra field, which either stores an error coming
-- from an action webhook (ExtraExtensions) or an internal error thrown somewhere within graphql-engine.
--
-- if we do have an error here, it should be an internal error and hence never be of the former case:
recordInconsistency -< ((Just (toJSON exts), metadataObject), "withRecordInconsistency: unexpected ExtraExtensions")
Just (ExtraInternal internal) ->
recordInconsistency -< ((Just (toJSON internal), metadataObject), qeError err)
Nothing ->
recordInconsistency -< ((Nothing, metadataObject), qeError err)
returnA -< Nothing
Right v -> returnA -< Just v
{-# INLINABLE withRecordInconsistency #-}
Expand Down Expand Up @@ -235,11 +245,11 @@ buildSchemaCacheFor objectId metadataModifier = do

for_ (M.lookup objectId newInconsistentObjects) $ \matchingObjects -> do
let reasons = commaSeparated $ imReason <$> matchingObjects
throwError (err400 InvalidConfiguration reasons) { qeInternal = Just $ toJSON matchingObjects }
throwError (err400 InvalidConfiguration reasons) { qeInternal = Just $ ExtraInternal $ toJSON matchingObjects }

unless (null newInconsistentObjects) $
throwError (err400 Unexpected "cannot continue due to new inconsistent metadata")
{ qeInternal = Just $ toJSON (nub . concatMap toList $ M.elems newInconsistentObjects) }
{ qeInternal = Just $ ExtraInternal $ toJSON (nub . concatMap toList $ M.elems newInconsistentObjects) }

-- | Like 'buildSchemaCache', but fails if there is any inconsistent metadata.
buildSchemaCacheStrict :: (QErrM m, CacheRWM m, MetadataM m) => m ()
Expand All @@ -249,7 +259,7 @@ buildSchemaCacheStrict = do
let inconsObjs = scInconsistentObjs sc
unless (null inconsObjs) $ do
let err = err400 Unexpected "cannot continue due to inconsistent metadata"
throwError err{ qeInternal = Just $ toJSON inconsObjs }
throwError err{ qeInternal = Just $ ExtraInternal $ toJSON inconsObjs }

-- | Executes the given action, and if any new 'InconsistentMetadata's are added to the schema
-- cache as a result of its execution, raises an error.
Expand All @@ -264,6 +274,6 @@ withNewInconsistentObjsCheck action = do
nub $ concatMap toList $ M.elems (currentObjects `diffInconsistentObjects` originalObjects)
unless (null newInconsistentObjects) $
throwError (err500 Unexpected "cannot continue due to newly found inconsistent metadata")
{ qeInternal = Just $ toJSON newInconsistentObjects }
{ qeInternal = Just $ ExtraInternal $ toJSON newInconsistentObjects }

pure result
8 changes: 8 additions & 0 deletions server/tests-py/context.py
Expand Up @@ -349,10 +349,18 @@ def do_POST(self):
resp, status = self.get_users_by_email(False)
self._send_response(status, resp)

elif req_path == "/intentional-error":
resp, status = self.intentional_error()
self._send_response(status, resp)

else:
self.send_response(HTTPStatus.NO_CONTENT)
self.end_headers()

def intentional_error(self):
blob = self.req_json['input']['blob']
return blob, HTTPStatus.BAD_REQUEST

def create_user(self):
email_address = self.req_json['input']['email']
name = self.req_json['input']['name']
Expand Down
@@ -0,0 +1,24 @@
description: Run get_user_by_email query action with invalid email
url: /v1/graphql
status: 200
query:
query: |
query {
intentional_error(
blob: {
message: "intentionally generated error"
code: "toplevel-error"
extensions: { foo: "bar", code: "action-error" }
}
) {
id
}
}
response:
errors:
- extensions:
foo: bar
code: action-error
message: intentionally generated error
17 changes: 17 additions & 0 deletions server/tests-py/queries/actions/sync/extensions_code_nothing.yaml
@@ -0,0 +1,17 @@
description: Run get_user_by_email query action with invalid email
url: /v1/graphql
status: 200
query:
query: |
query {
intentional_error(blob: { message: "intentionally generated error" }) {
id
}
}
response:
errors:
- extensions:
path: $
code: unexpected
message: intentionally generated error

0 comments on commit 8bfcd9a

Please sign in to comment.