diff --git a/CHANGELOG.md b/CHANGELOG.md index 32312bd57f1..2f370035825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Next release ### Bug fixes and improvements -(Add entries below in the order of server, console, cli, docs, others) +- server: Webhook Transforms can now delete request/response bodies explicitly. ## v2.3.0-beta.1 diff --git a/docs/graphql/core/api-reference/syntax-defs.rst b/docs/graphql/core/api-reference/syntax-defs.rst index 41d9c9437e9..2e9a65a32a7 100644 --- a/docs/graphql/core/api-reference/syntax-defs.rst +++ b/docs/graphql/core/api-reference/syntax-defs.rst @@ -2042,6 +2042,10 @@ RequestTransformation - required - Schema - Description + * - version + - false + - "1" | "2" + - Sets the `RequestTransformation` schema version. Version `1` uses a `String` for the `body` field and Version `2` takes a :ref:`BodyTransform`. `Defaults to version `1`. * - method - false - String @@ -2052,7 +2056,7 @@ RequestTransformation - Change the request URL to this value. * - body - false - - String + - :ref:`BodyTransform` | String - A template script for transforming the request body. * - content_type - false @@ -2092,7 +2096,6 @@ TransformHeaders - Array of (:ref:`HeaderKey`) - Headers to be removed from the request. Content-Type cannot be removed. - .. _HeaderKey: HeaderKey @@ -2113,6 +2116,29 @@ HeaderValue String + +.. _BodyTransform: + +BodyTransform +^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + + * - Key + - required + - Schema + - Description + * - action + - true + - remove | transform + - The action to perform on the request body. + * - template + - false + - String + - The transformation template to be applied to the body. This is + required if the action is `transform`. + .. _TemplateEngine: TemplateEngine @@ -2137,9 +2163,13 @@ ResponseTransformation - required - Schema - Description + * - version + - false + - "1" | "2" + - Sets the `RequestTransformation` schema version. Version `1` uses a `String` for the `body` field and Version `2` takes a :ref:`BodyTransform`. `Defaults to version `1`. * - body - false - - String + - :ref:`BodyTransform` | String - A template script for transforming the response body. * - template_engine - false diff --git a/server/src-lib/Hasura/RQL/DDL/WebhookTransforms.hs b/server/src-lib/Hasura/RQL/DDL/WebhookTransforms.hs index 16248c1cded..83da10424ee 100644 --- a/server/src-lib/Hasura/RQL/DDL/WebhookTransforms.hs +++ b/server/src-lib/Hasura/RQL/DDL/WebhookTransforms.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE DeriveAnyClass #-} + module Hasura.RQL.DDL.WebhookTransforms ( applyRequestTransform, applyResponseTransform, @@ -6,17 +8,18 @@ module Hasura.RQL.DDL.WebhookTransforms mkRequestTransform, mkResponseTransform, RequestMethod (..), + RemoveOrTransform (..), StringTemplateText (..), TemplatingEngine (..), TemplateText (..), - ContentType (..), ReqTransformCtx (..), - TransformHeaders (..), TransformErrorBundle (..), + TransformHeaders (..), MetadataRequestTransform (..), MetadataResponseTransform (..), RequestTransform (..), ResponseTransform (..), + Version (..), ) where @@ -24,13 +27,14 @@ import Control.Lens (traverseOf, view) import Data.Aeson qualified as J import Data.Bifunctor (bimap, first) import Data.Bitraversable +import Data.ByteString qualified as B import Data.ByteString.Lazy qualified as BL import Data.CaseInsensitive qualified as CI import Data.Either.Validation import Data.HashMap.Strict qualified as M -import Data.List (nubBy) import Data.Text qualified as T import Data.Text.Encoding qualified as TE +import Data.Validation qualified as V import Hasura.Incremental (Cacheable) import Hasura.Prelude hiding (first) import Hasura.Session (SessionVariables) @@ -86,13 +90,11 @@ data RespTransformCtx = RespTransformCtx -- use the original request value, unless otherwise indicated. data RequestTransform = RequestTransform { -- | Change the request method to one provided here. Nothing means POST. - reqTransformRequestMethod :: Maybe RequestMethod, + reqTransformRequestMethod :: Maybe (ReqTransformCtx -> Either TransformErrorBundle RequestMethod), -- | A function which contructs a new URL given the request context. reqTransformRequestURL :: Maybe (ReqTransformCtx -> Either TransformErrorBundle Text), -- | A function for transforming the request body. - reqTransformBody :: Maybe (ReqTransformCtx -> Either TransformErrorBundle J.Value), - -- | Change content type to one provided here. - reqTransformContentType :: Maybe ContentType, + reqTransformBody :: Maybe (RemoveOrTransform (ReqTransformCtx -> Either TransformErrorBundle J.Value)), -- | A function which contructs new query parameters given the request context. reqTransformQueryParams :: Maybe (ReqTransformCtx -> Either TransformErrorBundle HTTP.Query), -- | A function which contructs new Headers given the request context. @@ -102,7 +104,7 @@ data RequestTransform = RequestTransform -- | A set of data transformation functions generated from a -- 'MetadataResponseTransform'. 'Nothing' means use the original -- response value. -newtype ResponseTransform = ResponseTransform {respTransformBody :: Maybe (RespTransformCtx -> Either TransformErrorBundle J.Value)} +newtype ResponseTransform = ResponseTransform {respTransformBody :: Maybe (RemoveOrTransform (RespTransformCtx -> Either TransformErrorBundle J.Value))} -- | A de/serializable request transformation template which can be stored in -- the metadata associated with an action/event trigger/etc. and used to produce @@ -119,114 +121,133 @@ newtype ResponseTransform = ResponseTransform {respTransformBody :: Maybe (RespT -- Nothing values mean use the original request value, unless -- otherwise indicated. data MetadataRequestTransform = MetadataRequestTransform - { -- | Change the request method to one provided here. Nothing means POST. - mtRequestMethod :: Maybe RequestMethod, + { -- | The Schema Version + mtVersion :: Version, + -- | Change the request method to one provided here. Nothing means POST. + mtRequestMethod :: Maybe StringTemplateText, -- | Template script for transforming the URL. mtRequestURL :: Maybe StringTemplateText, -- | Template script for transforming the request body. - mtBodyTransform :: Maybe TemplateText, - -- | Replace the Content-Type with this value. Only - -- "application/json" and "application/x-www-form-urlencoded" are - -- allowed. - mtContentType :: Maybe ContentType, + mtBodyTransform :: Maybe (RemoveOrTransform TemplateText), -- | A list of template scripts for constructing new Query Params. mtQueryParams :: Maybe [(StringTemplateText, Maybe StringTemplateText)], - -- | Transform headers as defined here. + -- | A list of template scripts for constructing headers. mtRequestHeaders :: Maybe TransformHeaders, -- | The template engine to use for transformations. Default: Kriti mtTemplatingEngine :: TemplatingEngine } - deriving (Show, Eq, Generic) - -instance NFData MetadataRequestTransform - -instance Cacheable MetadataRequestTransform - -infixr 8 .=? - -(.=?) :: J.ToJSON v => k -> Maybe v -> Maybe (k, J.Value) -(.=?) _ Nothing = Nothing -(.=?) k (Just v) = Just (k, J.toJSON v) + deriving stock (Show, Eq, Generic) + deriving anyclass (NFData, Cacheable) instance J.ToJSON MetadataRequestTransform where toJSON MetadataRequestTransform {..} = - J.object $ - ["template_engine" J..= mtTemplatingEngine] - <> catMaybes - [ "method" .=? mtRequestMethod, - "url" .=? mtRequestURL, - "body" .=? mtBodyTransform, - "content_type" .=? mtContentType, - "query_params" .=? fmap M.fromList mtQueryParams, - "request_headers" .=? mtRequestHeaders + let body = case mtVersion of + V1 -> case mtBodyTransform of + Just (Transform template) -> Just ("body", J.toJSON template) + _ -> Nothing + V2 -> "body" .=? mtBodyTransform + in J.object $ + [ "template_engine" J..= mtTemplatingEngine, + "version" J..= mtVersion ] + <> catMaybes + [ "method" .=? mtRequestMethod, + "url" .=? mtRequestURL, + "query_params" .=? fmap M.fromList mtQueryParams, + "request_headers" .=? mtRequestHeaders, + body + ] instance J.FromJSON MetadataRequestTransform where parseJSON = J.withObject "Object" $ \o -> do + version <- o J..:? "version" J..!= V1 method <- o J..:? "method" url <- o J..:? "url" - body <- o J..:? "body" - contentType <- o J..:? "content_type" + body <- case version of + V1 -> do + template :: (Maybe TemplateText) <- o J..:? "body" + pure $ fmap Transform template + V2 -> o J..:? "body" queryParams' <- o J..:? "query_params" let queryParams = fmap M.toList queryParams' headers <- o J..:? "request_headers" - templateEngine <- o J..:? "template_engine" - let templateEngine' = fromMaybe Kriti templateEngine - pure $ MetadataRequestTransform method url body contentType queryParams headers templateEngine' + templateEngine <- o J..:? "template_engine" J..!= Kriti + pure $ MetadataRequestTransform version method url body queryParams headers templateEngine data MetadataResponseTransform = MetadataResponseTransform - { mrtBodyTransform :: Maybe TemplateText, + { -- | The Schema Version + mrtVersion :: Version, + -- | Template script for transforming the response body. + mrtBodyTransform :: Maybe (RemoveOrTransform TemplateText), + -- | The template engine to use for transformations. Default: Kriti mrtTemplatingEngine :: TemplatingEngine } - deriving (Show, Eq, Generic) - -instance NFData MetadataResponseTransform - -instance Cacheable MetadataResponseTransform + deriving stock (Show, Eq, Generic) + deriving anyclass (NFData, Cacheable) instance J.ToJSON MetadataResponseTransform where toJSON MetadataResponseTransform {..} = - J.object $ - ["template_engine" J..= mrtTemplatingEngine] - <> catMaybes ["body" .=? mrtBodyTransform] + let body = case mrtVersion of + V1 -> case mrtBodyTransform of + Just (Transform template) -> Just ("body", J.toJSON template) + _ -> Nothing + V2 -> "body" .=? mrtBodyTransform + in J.object $ + [ "template_engine" J..= mrtTemplatingEngine, + "version" J..= mrtVersion + ] + <> catMaybes [body] instance J.FromJSON MetadataResponseTransform where parseJSON = J.withObject "Object" $ \o -> do - body <- o J..:? "body" + version <- o J..:? "version" J..!= V1 + body <- case version of + V1 -> do + template :: (Maybe TemplateText) <- o J..:? "body" + pure $ fmap Transform template + V2 -> o J..:? "body" templateEngine <- o J..:? "template_engine" let templateEngine' = fromMaybe Kriti templateEngine - pure $ MetadataResponseTransform body templateEngine' + pure $ MetadataResponseTransform version body templateEngine' -data RequestMethod = GET | POST | PUT | PATCH | DELETE - deriving (Show, Eq, Enum, Bounded, Generic) +data RemoveOrTransform a = Remove | Transform a + deriving stock (Show, Eq, Functor, Generic) + deriving anyclass (NFData, Cacheable) -renderRequestMethod :: RequestMethod -> Text -renderRequestMethod = \case - GET -> "GET" - POST -> "POST" - PUT -> "PUT" - PATCH -> "PATCH" - DELETE -> "DELETE" +instance J.ToJSON a => J.ToJSON (RemoveOrTransform a) where + toJSON = \case + Remove -> J.object ["action" J..= ("remove" :: T.Text)] + Transform a -> + J.object + [ "action" J..= ("transform" :: T.Text), + "template" J..= J.toJSON a + ] + +instance J.FromJSON a => J.FromJSON (RemoveOrTransform a) where + parseJSON = J.withObject "RemoveOrTransform" $ \o -> do + action :: T.Text <- o J..: "action" + if action == "remove" + then pure Remove + else do + "transform" :: T.Text <- o J..: "action" + template <- o J..: "template" + pure $ Transform template + +newtype RequestMethod = RequestMethod (CI.CI T.Text) + deriving stock (Generic) + deriving newtype (Show, Eq) + deriving anyclass (NFData, Cacheable) instance J.ToJSON RequestMethod where - toJSON = J.String . renderRequestMethod + toJSON = J.String . CI.original . coerce instance J.FromJSON RequestMethod where - parseJSON = J.withText "RequestMethod" \case - "GET" -> pure GET - "POST" -> pure POST - "PUT" -> pure PUT - "PATCH" -> pure PATCH - "DELETE" -> pure DELETE - _ -> fail "Invalid Request Method" - -instance NFData RequestMethod - -instance Cacheable RequestMethod + parseJSON = J.withText "RequestMethod" (pure . coerce . CI.mk) -- | Available Template Languages data TemplatingEngine = Kriti - deriving (Show, Eq, Enum, Bounded, Generic) + deriving stock (Show, Eq, Enum, Bounded, Generic) + deriving anyclass (NFData, Cacheable) renderTemplatingEngine :: TemplatingEngine -> Text renderTemplatingEngine _ = "Kriti" @@ -239,18 +260,28 @@ instance J.FromJSON TemplatingEngine where instance J.ToJSON TemplatingEngine where toJSON = J.String . renderTemplatingEngine -instance NFData TemplatingEngine +data Version = V1 | V2 + deriving stock (Show, Eq, Generic) + deriving anyclass (NFData, Cacheable, Hashable) -instance Cacheable TemplatingEngine +instance J.FromJSON Version where + parseJSON v = do + version :: Int <- J.parseJSON v + case version of + 1 -> pure V1 + 2 -> pure V2 + i -> fail $ "expected 1 or 3, encountered " ++ show i + +instance J.ToJSON Version where + toJSON = \case + V1 -> J.toJSON @Int 1 + V2 -> J.toJSON @Int 2 -- | Unparsed Kriti templating code newtype TemplateText = TemplateText {unTemplateText :: T.Text} deriving stock (Show, Eq, Ord, Generic) deriving newtype (Hashable, J.ToJSONKey, J.FromJSONKey) - -instance NFData TemplateText - -instance Cacheable TemplateText + deriving anyclass (NFData, Cacheable) instance J.FromJSON TemplateText where parseJSON = J.withText "TemplateText" \t -> @@ -297,54 +328,22 @@ instance J.FromJSON StringTemplateText where instance J.ToJSON StringTemplateText where toJSON = J.String . coerce --- | Wrap a template with escaped double quotes. -wrapTemplate :: StringTemplateText -> StringTemplateText -wrapTemplate (StringTemplateText t) = StringTemplateText $ "\"" <> t <> "\"" - -data ContentType = JSON | XWWWFORM - deriving (Show, Eq, Enum, Bounded, Generic) - -renderContentType :: ContentType -> Text -renderContentType = \case - JSON -> "application/json" - XWWWFORM -> "application/x-www-form-urlencoded" - -instance J.ToJSON ContentType where - toJSON = J.String . renderContentType - -instance J.FromJSON ContentType where - parseJSON = J.withText "ContentType" \case - "application/json" -> pure JSON - "application/x-www-form-urlencoded" -> pure XWWWFORM - _ -> fail "Invalid ContentType" - -instance NFData ContentType - -instance Cacheable ContentType - -- | This newtype exists solely to anchor a `FromJSON` instance and is -- eliminated in the `TransformHeaders` `FromJSON` instance. newtype HeaderKey = HeaderKey {unHeaderKey :: CI.CI Text} - deriving (Show, Eq, Ord, Generic) - -instance NFData HeaderKey - -instance Cacheable HeaderKey + deriving stock (Show, Eq, Ord, Generic) + deriving anyclass (NFData, Cacheable) instance J.FromJSON HeaderKey where parseJSON = J.withText "HeaderKey" \txt -> case CI.mk txt of - "Content-Type" -> fail "Restricted Header: Content-Type" key -> pure $ HeaderKey key data TransformHeaders = TransformHeaders { addHeaders :: [(CI.CI Text, StringTemplateText)], removeHeaders :: [CI.CI Text] } - deriving (Show, Eq, Ord, Generic) - -instance NFData TransformHeaders - -instance Cacheable TransformHeaders + deriving stock (Show, Eq, Ord, Generic) + deriving anyclass (NFData, Cacheable) instance J.ToJSON TransformHeaders where toJSON TransformHeaders {..} = @@ -364,10 +363,7 @@ instance J.FromJSON TransformHeaders where newtype TransformErrorBundle = TransformErrorBundle {teMessages :: [J.Value]} deriving stock (Show, Eq, Generic) deriving newtype (Semigroup, Monoid, J.ToJSON) - -instance NFData TransformErrorBundle - -instance Cacheable TransformErrorBundle + deriving anyclass (NFData, Cacheable) ------------------------------- --- Constructing Transforms --- @@ -376,84 +372,42 @@ instance Cacheable TransformErrorBundle -- | Construct a `RequestTransform` from its metadata representation. mkRequestTransform :: MetadataRequestTransform -> RequestTransform mkRequestTransform MetadataRequestTransform {..} = - let urlTransform = mkUrlTransform mtTemplatingEngine <$> mtRequestURL + let methodTransform = mkMethodTransform mtTemplatingEngine <$> mtRequestMethod + urlTransform = mkUrlTransform mtTemplatingEngine <$> mtRequestURL queryTransform = mkQueryParamsTransform mtTemplatingEngine <$> mtQueryParams headerTransform = mkHeaderTransform mtTemplatingEngine <$> mtRequestHeaders - bodyTransform = mkReqTemplateTransform mtTemplatingEngine <$> mtBodyTransform - in RequestTransform mtRequestMethod urlTransform bodyTransform mtContentType queryTransform headerTransform + bodyTransform = fmap (mkReqTemplateTransform mtTemplatingEngine) <$> mtBodyTransform + in RequestTransform methodTransform urlTransform bodyTransform queryTransform headerTransform mkResponseTransform :: MetadataResponseTransform -> ResponseTransform mkResponseTransform MetadataResponseTransform {..} = - let bodyTransform = mkRespTemplateTransform mrtTemplatingEngine <$> mrtBodyTransform + let bodyTransform = fmap (mkRespTemplateTransform mrtTemplatingEngine) <$> mrtBodyTransform in ResponseTransform bodyTransform +-- | Transform a String Template +transformST :: TemplatingEngine -> ReqTransformCtx -> StringTemplateText -> Either TransformErrorBundle B.ByteString +transformST engine transformCtx t = + valueToString =<< mkReqTemplateTransform engine (coerce wrapTemplate t) transformCtx + +mkMethodTransform :: TemplatingEngine -> StringTemplateText -> ReqTransformCtx -> Either TransformErrorBundle RequestMethod +mkMethodTransform engine template transformCtx = + fmap (coerce . CI.mk . TE.decodeUtf8) $ transformST engine transformCtx template + mkQueryParamsTransform :: TemplatingEngine -> [(StringTemplateText, Maybe StringTemplateText)] -> ReqTransformCtx -> Either TransformErrorBundle HTTP.Query mkQueryParamsTransform engine templates transformCtx = - let transform t = - case mkReqTemplateTransform engine (coerce wrapTemplate t) transformCtx of - Left err -> Left err - Right (J.String str) -> Right $ str - Right (J.Number num) -> Right $ tshow num - Right (J.Bool True) -> Right $ "true" - Right (J.Bool False) -> Right $ "false" - Right val -> - Left $ - TransformErrorBundle $ - pure $ - J.object - [ "error_code" J..= J.String "TransformationError", - "message" J..= J.String "Query Param Transforms must produce a String, Number, or Boolean value", - "value" J..= val - ] - toValidation :: [(Either TransformErrorBundle T.Text, Maybe (Either TransformErrorBundle T.Text))] -> [(Validation TransformErrorBundle T.Text, Maybe (Validation TransformErrorBundle T.Text))] - toValidation = fmap (bimap eitherToValidation (fmap eitherToValidation)) + let transform = eitherToValidation . transformST engine transformCtx - collectErrors :: [(Either TransformErrorBundle T.Text, Maybe (Either TransformErrorBundle T.Text))] -> Validation TransformErrorBundle [(T.Text, Maybe T.Text)] - collectErrors xs = traverse (bitraverse id sequenceA) (toValidation xs) - - results = fmap (bimap transform (fmap transform)) templates - collectedResults = validationToEither $ collectErrors results - in (fmap . fmap) (bimap TE.encodeUtf8 (fmap TE.encodeUtf8)) collectedResults + transformationResults :: [(Validation TransformErrorBundle B.ByteString, Maybe (Validation TransformErrorBundle B.ByteString))] + transformationResults = fmap (bimap transform (fmap transform)) templates + in validationToEither $ traverse (bitraverse id sequenceA) transformationResults -- | Given a `TransformHeaders` and the `ReqTransformCtx`, Construct a -- function to transform the existing headers. mkHeaderTransform :: TemplatingEngine -> TransformHeaders -> ReqTransformCtx -> Either TransformErrorBundle ([HTTP.Header] -> [HTTP.Header]) -mkHeaderTransform engine TransformHeaders {..} transformCtx = - let transform t = - case mkReqTemplateTransform engine (coerce $ wrapTemplate t) transformCtx of - Left err -> Failure err - Right (J.String str) -> Success $ TE.encodeUtf8 str - Right (J.Number num) -> Success $ TE.encodeUtf8 $ tshow num - Right (J.Bool True) -> Success "true" - Right (J.Bool False) -> Success "false" - Right val -> - Failure $ - TransformErrorBundle $ - pure $ - J.object - [ "error_code" J..= J.String "TransformationError", - "message" J..= J.String ("Header Transforms must produce a String, Number, or Boolean value: " <> tshow val) - ] - - failIfCT key = - if CI.foldedCase key == "content-type" - then - Failure $ - TransformErrorBundle $ - pure $ - J.object - [ "error_code" J..= J.String "TransformationError", - "message" J..= J.String ("Header Transforms cannot add Content-Type" <> CI.original key) - ] - else Success $ CI.map TE.encodeUtf8 key - - toBeAdded = traverse (bitraverse failIfCT transform) addHeaders - toBeRemoved = (fmap . CI.map) TE.encodeUtf8 removeHeaders - - filterHeaders h = filter ((`notElem` toBeRemoved) . fst) h - in case toBeAdded of - Failure err -> throwError err - Success toBeAdded' -> pure (filterHeaders . (toBeAdded' <>)) +mkHeaderTransform engine TransformHeaders {..} ctx = do + add <- V.toEither $ fmap mappend $ traverse (bitraverse (pure . CI.map TE.encodeUtf8) (V.fromEither . transformST engine ctx)) addHeaders + let filter' xs = filter (flip elem (fmap (CI.map TE.encodeUtf8) removeHeaders) . fst) xs + pure $ add . filter' mkUrlTransform :: TemplatingEngine -> StringTemplateText -> ReqTransformCtx -> Either TransformErrorBundle Text mkUrlTransform engine template transformCtx = @@ -532,13 +486,18 @@ buildRespTransformCtx reqCtx respBody = applyRequestTransform :: (HTTP.Request -> ReqTransformCtx) -> RequestTransform -> HTTP.Request -> Either TransformErrorBundle HTTP.Request applyRequestTransform transformCtx' RequestTransform {..} reqData = let transformCtx = transformCtx' reqData - method = fmap (TE.encodeUtf8 . renderRequestMethod) reqTransformRequestMethod + methodFunc :: B.ByteString -> Either TransformErrorBundle B.ByteString + methodFunc method = + case reqTransformRequestMethod of + Nothing -> pure method + Just f -> TE.encodeUtf8 . CI.original . coerce <$> f transformCtx bodyFunc :: Maybe BL.ByteString -> Either TransformErrorBundle (Maybe BL.ByteString) bodyFunc body = case reqTransformBody of Nothing -> pure body - Just f -> pure . J.encode <$> f transformCtx + Just Remove -> pure Nothing + Just (Transform f) -> pure . J.encode <$> f transformCtx urlFunc :: Text -> Either TransformErrorBundle Text urlFunc url = @@ -556,18 +515,12 @@ applyRequestTransform transformCtx' RequestTransform {..} reqData = headerFunc headers = case reqTransformRequestHeaders of Nothing -> pure headers - Just f -> f transformCtx >>= \g -> pure $ g headers - - contentTypeFunc :: [HTTP.Header] -> Either TransformErrorBundle [HTTP.Header] - contentTypeFunc = pure . nubBy (\a b -> fst a == fst b) . (:) ("Content-Type", contentType) - where - contentType = maybe "application/json" (TE.encodeUtf8 . renderContentType) reqTransformContentType + Just f -> f transformCtx <*> pure headers in reqData & traverseOf HTTP.url urlFunc >>= traverseOf HTTP.body bodyFunc >>= traverseOf HTTP.queryParams queryFunc - >>= traverseOf HTTP.method (pure . (`fromMaybe` method)) + >>= traverseOf HTTP.method methodFunc >>= traverseOf HTTP.headers headerFunc - >>= traverseOf HTTP.headers contentTypeFunc -- | At the moment we only transform the body of -- Responses. 'http-client' does not export the constructors for @@ -579,5 +532,39 @@ applyResponseTransform ResponseTransform {..} ctx@RespTransformCtx {..} = bodyFunc body = case respTransformBody of Nothing -> pure body - Just f -> J.encode <$> f ctx + Just Remove -> pure mempty + Just (Transform f) -> J.encode <$> f ctx in bodyFunc (J.encode rtcBody) + +----------------- +--- Utilities --- +----------------- + +valueToString :: MonadError TransformErrorBundle m => J.Value -> m B.ByteString +valueToString = \case + J.String str -> pure $ TE.encodeUtf8 str + J.Number num -> pure $ TE.encodeUtf8 (tshow num) + J.Bool True -> pure "true" + J.Bool False -> pure "false" + val -> throwErrorBundle "Template must produce a String, Number, or Boolean value" (Just val) + +throwErrorBundle :: MonadError TransformErrorBundle m => T.Text -> Maybe J.Value -> m a +throwErrorBundle msg val = + throwError $ + TransformErrorBundle $ + pure $ + J.object $ + [ "error_code" J..= J.String "TransformationError", + "message" J..= J.String msg + ] + <> catMaybes [("value" J..=) <$> val] + +infixr 8 .=? + +(.=?) :: J.ToJSON v => k -> Maybe v -> Maybe (k, J.Value) +(.=?) _ Nothing = Nothing +(.=?) k (Just v) = Just (k, J.toJSON v) + +-- | Wrap a template with escaped double quotes. +wrapTemplate :: StringTemplateText -> StringTemplateText +wrapTemplate (StringTemplateText t) = StringTemplateText $ "\"" <> t <> "\"" diff --git a/server/src-test/Hasura/RQL/WebhookTransformsSpec.hs b/server/src-test/Hasura/RQL/WebhookTransformsSpec.hs index ba2d8fc933c..965c8de2e30 100644 --- a/server/src-test/Hasura/RQL/WebhookTransformsSpec.hs +++ b/server/src-test/Hasura/RQL/WebhookTransformsSpec.hs @@ -13,8 +13,8 @@ import Test.Hspec.Hedgehog spec :: Spec spec = do - it "RequstMethod RoundTrip" $ - hedgehog $ forAll genRequestMethod >>= trippingJSON + it "StringTemplateText RoundTrip" $ + hedgehog $ forAll genStringTemplateText >>= trippingJSON it "TemplateEngine RoundTrip" $ hedgehog $ forAll genTemplatingEngine >>= trippingJSON @@ -22,9 +22,6 @@ spec = do it "TemplateText RoundTrip" $ hedgehog $ forAll genTemplateText >>= trippingJSON - it "ContentType RoundTrip" $ - hedgehog $ forAll genContentType >>= trippingJSON - it "TransformHeaders" $ hedgehog $ do headers <- forAll genTransformHeaders @@ -36,16 +33,13 @@ spec = do hedgehog $ do transform <- forAll genMetadataRequestTransform let sortH TransformHeaders {..} = TransformHeaders (sort addHeaders) (sort removeHeaders) - sortMT mt@MetadataRequestTransform {mtRequestHeaders, mtQueryParams} = mt {mtRequestHeaders = sortH <$> mtRequestHeaders, mtQueryParams = sort <$> mtQueryParams} + let sortMT mt@MetadataRequestTransform {mtRequestHeaders, mtQueryParams} = mt {mtRequestHeaders = sortH <$> mtRequestHeaders, mtQueryParams = sort <$> mtQueryParams} transformMaybe = eitherDecode $ encode transform Right (sortMT transform) === fmap sortMT transformMaybe trippingJSON :: (Show a, Eq a, ToJSON a, FromJSON a, MonadTest m) => a -> m () trippingJSON val = tripping val (toJSON) (fromJSON) -genRequestMethod :: Gen RequestMethod -genRequestMethod = Gen.enumBounded @_ @RequestMethod - genTemplatingEngine :: Gen TemplatingEngine genTemplatingEngine = Gen.enumBounded @_ @TemplatingEngine @@ -59,9 +53,6 @@ genTemplateText = TemplateText . wrap <$> Gen.text (Range.constant 3 20) Gen.alp genStringTemplateText :: Gen StringTemplateText genStringTemplateText = StringTemplateText <$> Gen.text (Range.constant 3 20) Gen.alphaNum -genContentType :: Gen ContentType -genContentType = Gen.enumBounded @_ @ContentType - genTransformHeaders :: Gen TransformHeaders genTransformHeaders = do numHeaders <- Gen.integral $ Range.constant 1 20 @@ -93,19 +84,18 @@ genUrl = do genMetadataRequestTransform :: Gen MetadataRequestTransform genMetadataRequestTransform = do - method <- Gen.maybe genRequestMethod + method <- Gen.maybe genStringTemplateText -- NOTE: At the moment no need to generate valid urls or templates -- but such instances maybe useful in the future. url <- Gen.maybe $ genUrl - bodyTransform <- Gen.maybe $ genTemplateText - contentType <- Gen.maybe $ genContentType + bodyTransform <- Gen.maybe $ fmap Transform $ genTemplateText queryParams <- Gen.maybe $ genQueryParams reqHeaders <- Gen.maybe $ genTransformHeaders MetadataRequestTransform + V2 method url bodyTransform - contentType queryParams reqHeaders <$> genTemplatingEngine diff --git a/server/tests-py/queries/actions/async/schema_setup.yaml b/server/tests-py/queries/actions/async/schema_setup.yaml index a3ad222f8c0..4a3136eaaa2 100644 --- a/server/tests-py/queries/actions/async/schema_setup.yaml +++ b/server/tests-py/queries/actions/async/schema_setup.yaml @@ -72,14 +72,17 @@ args: output_type: UserId handler: http://127.0.0.1:5593/create-user request_transform: + version: 2 template_engine: Kriti - body: | - { - "input": { - "email": "foo@bar.com", - "name": "notClarke" + body: + action: transform + template: | + { + "input": { + "email": "foo@bar.com", + "name": "notClarke" + } } - } - type: create_action args: diff --git a/server/tests-py/queries/actions/sync/schema_setup.yaml b/server/tests-py/queries/actions/sync/schema_setup.yaml index 6db73d745fe..b4f7ce34288 100644 --- a/server/tests-py/queries/actions/sync/schema_setup.yaml +++ b/server/tests-py/queries/actions/sync/schema_setup.yaml @@ -252,16 +252,19 @@ args: output_type: OutObject handler: http://127.0.0.1:5593/mirror-action request_transform: + version: 2 template_engine: Kriti - body: | - { - "input": { - "arg": { - "id": {{ $body.input.arg.name }}, - "name": {{ $body.input.arg.id }} + body: + action: transform + template: | + { + "input": { + "arg": { + "id": {{ $body.input.arg.name }}, + "name": {{ $body.input.arg.id }} + } } } - } - type: create_action args: @@ -285,12 +288,15 @@ args: output_type: OutObjectTransformed handler: http://127.0.0.1:5593/mirror-action response_transform: + version: 2 template_engine: Kriti - body: | - { - "foo": {{ $body.id }}, - "bar": {{ $body.name }} - } + body: + action: transform + template: | + { + "foo": {{ $body.id }}, + "bar": {{ $body.name }} + } - type: create_action args: @@ -333,24 +339,27 @@ args: output_type: NestedOutObjectTransformed! handler: http://127.0.0.1:5593/get-user-by-email-nested response_transform: + version: 2 template_engine: Kriti - body: | - { - "uid": {{ $body.user_id.id }}, - "city0": {{ $body.addresses[0].city }}, - "country0": {{ $body.addresses[0].country }}, - "other_addresses": - {{ range i, x := $body.addresses }} - {{ if i > 0 }} - { - "city": {{ x.city }}, - "country": {{ x.country }} - } - {{ else }} - null + body: + action: transform + template: | + { + "uid": {{ $body.user_id.id }}, + "city0": {{ $body.addresses[0].city }}, + "country0": {{ $body.addresses[0].country }}, + "other_addresses": + {{ range i, x := $body.addresses }} + {{ if i > 0 }} + { + "city": {{ x.city }}, + "country": {{ x.country }} + } + {{ else }} + null + {{ end }} {{ end }} - {{ end }} - } + } - type: create_action args: @@ -445,10 +454,13 @@ args: output_type: '[Result]' handler: http://127.0.0.1:5593/get-results response_transform: + version: 2 template_engine: Kriti - body: | - {{ range i, x := $body.result_ids }} - { - "id": {{ x }} - } - {{ end }} + body: + action: transform + template: | + {{ range i, x := $body.result_ids }} + { + "id": {{ x }} + } + {{ end }} diff --git a/server/tests-py/queries/event_triggers/transform/bad_content_type_transform.yaml b/server/tests-py/queries/event_triggers/transform/bad_content_type_transform.yaml deleted file mode 100644 index 5a37cddf6b9..00000000000 --- a/server/tests-py/queries/event_triggers/transform/bad_content_type_transform.yaml +++ /dev/null @@ -1,26 +0,0 @@ -- description: PG create event trigger - url: /v1/metadata - status: 400 - response: - path: $.args.request_transform.content_type - error: "Error when parsing command create_event_trigger.\nSee our documentation at https://hasura.io/docs/latest/graphql/core/api-reference/metadata-api/index.html#metadata-apis.\nInternal error message: Invalid ContentType" - code: parse-failed - query: - type: pg_create_event_trigger - args: - name: sample_trigger - table: - name: test_t1 - schema: hge_tests - source: default - webhook: http://127.0.0.1:5592 - insert: - columns: '*' - payload: - - id - - first_name - - last_name - replace: false - request_transform: - template_engine: Kriti - content_type: multipart/form-data diff --git a/server/tests-py/queries/event_triggers/transform/basic_transform.yaml b/server/tests-py/queries/event_triggers/transform/basic_transform.yaml index c602b58f154..16653c74b07 100644 --- a/server/tests-py/queries/event_triggers/transform/basic_transform.yaml +++ b/server/tests-py/queries/event_triggers/transform/basic_transform.yaml @@ -20,13 +20,13 @@ - last_name replace: false request_transform: - content_type: application/json + version: 2 template_engine: Kriti - body: "{{ $body.event.data.new }}" + body: + action: transform + template: "{{ $body.event.data.new }}" query_params: "foo": "bar" request_headers: add_headers: foo: "bar" - remove_headers: - - User-Agent diff --git a/server/tests-py/queries/event_triggers/transform/content_type_transform.yaml b/server/tests-py/queries/event_triggers/transform/content_type_transform.yaml deleted file mode 100644 index 9b15ceba527..00000000000 --- a/server/tests-py/queries/event_triggers/transform/content_type_transform.yaml +++ /dev/null @@ -1,23 +0,0 @@ -- description: PG create event trigger - url: /v1/metadata - status: 200 - response: - message: success - query: - type: pg_create_event_trigger - args: - name: sample_trigger - table: - name: test_t1 - schema: hge_tests - source: default - webhook: http://127.0.0.1:5592 - insert: - columns: '*' - payload: - - id - - first_name - - last_name - replace: false - request_transform: - content_type: application/json diff --git a/server/tests-py/queries/event_triggers/transform/url_encoded_transform.yaml b/server/tests-py/queries/event_triggers/transform/url_encoded_transform.yaml deleted file mode 100644 index 6a26dfa19c2..00000000000 --- a/server/tests-py/queries/event_triggers/transform/url_encoded_transform.yaml +++ /dev/null @@ -1,24 +0,0 @@ -- description: PG create event trigger - url: /v1/metadata - status: 200 - response: - message: success - query: - type: pg_create_event_trigger - args: - name: sample_trigger - table: - name: test_t1 - schema: hge_tests - source: default - webhook: http://127.0.0.1:5592 - insert: - columns: '*' - payload: - - id - - first_name - - last_name - replace: false - request_transform: - template_engine: Kriti - content_type: application/x-www-form-urlencoded diff --git a/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_eval.yaml b/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_eval.yaml index bfd7e720d0b..375965b4371 100644 --- a/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_eval.yaml +++ b/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_eval.yaml @@ -22,5 +22,8 @@ body: hello: world request_transform: - body: "{{ $body.world }}" + version: 2 + body: + action: transform + template: "{{ $body.world }}" template_engine: Kriti diff --git a/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_parse.yaml b/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_parse.yaml index f149c451d2b..f1a6acdfcba 100644 --- a/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_parse.yaml +++ b/server/tests-py/queries/v1/metadata/test_webhook_transform_bad_parse.yaml @@ -4,7 +4,7 @@ X-Hasura-Role: admin status: 400 response: - path: "$.args.request_transform.body" + path: "$.args.request_transform.body.template" error: "Unexpected token '$body'." code: "parse-failed" query: @@ -14,5 +14,8 @@ body: hello: world request_transform: - body: "$body.hello }}" + version: 2 + body: + action: transform + template: "$body.hello }}" template_engine: Kriti diff --git a/server/tests-py/queries/v1/metadata/test_webhook_transform_env_reference_success.yaml b/server/tests-py/queries/v1/metadata/test_webhook_transform_env_reference_success.yaml index 3e68c1ed21c..bfea1631bc7 100644 --- a/server/tests-py/queries/v1/metadata/test_webhook_transform_env_reference_success.yaml +++ b/server/tests-py/queries/v1/metadata/test_webhook_transform_env_reference_success.yaml @@ -22,12 +22,16 @@ body: hello: world request_transform: + version: 2 url: "http://www.google.com" template_engine: Kriti - body: "\"hello_{{ $body.hello }}\"" + body: + action: transform + template: "\"hello_{{ $body.hello }}\"" method: POST query_params: "foo": "bar" request_headers: add_headers: foo: "bar" + content-type: "application/json" diff --git a/server/tests-py/queries/v1/metadata/test_webhook_transform_success.yaml b/server/tests-py/queries/v1/metadata/test_webhook_transform_success.yaml index 00dc7abf3ce..74bb9307b18 100644 --- a/server/tests-py/queries/v1/metadata/test_webhook_transform_success.yaml +++ b/server/tests-py/queries/v1/metadata/test_webhook_transform_success.yaml @@ -19,12 +19,16 @@ body: hello: world request_transform: + version: 2 url: "http://www.google.com" template_engine: Kriti - body: "\"hello_{{ $body.hello }}\"" + body: + action: transform + template: "\"hello_{{ $body.hello }}\"" method: POST query_params: "foo": "bar" request_headers: add_headers: foo: "bar" + content-type: "application/json" diff --git a/server/tests-py/queries/v1/metadata/test_webhook_transform_success_old_body_schema.yaml b/server/tests-py/queries/v1/metadata/test_webhook_transform_success_old_body_schema.yaml new file mode 100644 index 00000000000..a6c4fa21813 --- /dev/null +++ b/server/tests-py/queries/v1/metadata/test_webhook_transform_success_old_body_schema.yaml @@ -0,0 +1,31 @@ +- description: Test Webhook Transform Old Body Schema + url: /v1/metadata + headers: + X-Hasura-Role: admin + status: 200 + response: + body: hello_world + headers: + - - content-type + - application/json + - - foo + - bar + method: POST + webhook_url: http://www.google.com?foo=bar + query: + type: test_webhook_transform + args: + webhook_url: http://localhost:1234 + body: + hello: world + request_transform: + url: "http://www.google.com" + template_engine: Kriti + body: "\"hello_{{ $body.hello }}\"" + method: POST + query_params: + "foo": "bar" + request_headers: + add_headers: + foo: "bar" + content-type: "application/json" diff --git a/server/tests-py/queries/v1/metadata/test_webhook_transform_success_remove_body.yaml b/server/tests-py/queries/v1/metadata/test_webhook_transform_success_remove_body.yaml new file mode 100644 index 00000000000..7aa8c618394 --- /dev/null +++ b/server/tests-py/queries/v1/metadata/test_webhook_transform_success_remove_body.yaml @@ -0,0 +1,30 @@ +- description: Test Webhook Transform + url: /v1/metadata + headers: + X-Hasura-Role: admin + status: 200 + response: + headers: + - - foo + - bar + body: + method: GET + webhook_url: http://www.google.com?foo=bar + query: + type: test_webhook_transform + args: + webhook_url: http://localhost:1234 + body: + hello: world + request_transform: + version: 2 + url: "http://www.google.com" + template_engine: Kriti + body: + action: remove + method: GET + query_params: + "foo": "bar" + request_headers: + add_headers: + foo: "bar" diff --git a/server/tests-py/test_events.py b/server/tests-py/test_events.py index e11d7fa0d0d..c20d92d465c 100644 --- a/server/tests-py/test_events.py +++ b/server/tests-py/test_events.py @@ -870,57 +870,3 @@ class TestEventTransform(object): removedHeaders=["user-agent"], webhook_path=expectedPath) assert st_code == 200, resp - - def test_content_type_allows_json(self, hge_ctx, evts_webhook): - # GIVEN - check_query_f(hge_ctx, self.dir() + '/content_type_transform.yaml') - - # WHEN - table = {"schema": "hge_tests", "name": "test_t1"} - insert_row = {"id": 0, "first_name": "Simon", "last_name": "Marlow"} - st_code, resp = insert(hge_ctx, table, insert_row) - - # THEN - expected_event_data = { - "old": None, - "new": insert_row - } - - check_event(hge_ctx, - evts_webhook, - "sample_trigger", - table, - "INSERT", - expected_event_data, - {"Content-Type": "application/json"}) - assert st_code == 200, resp - - def test_content_type_allows_urlencoded(self, hge_ctx, evts_webhook): - # GIVEN - check_query_f(hge_ctx, self.dir() + '/url_encoded_transform.yaml') - - # WHEN - table = {"schema": "hge_tests", "name": "test_t1"} - insert_row = {"id": 0, "first_name": "Simon", "last_name": "Marlow"} - st_code, resp = insert(hge_ctx, table, insert_row) - - # THEN - expected_event_data = { - "old": None, - "new": insert_row - } - - check_event(hge_ctx, - evts_webhook, - "sample_trigger", - table, - "INSERT", - expected_event_data, - {"Content-Type": "application/x-www-form-urlencoded"}) - assert st_code == 200, resp - - def test_content_type_disallows_bad_content_types(self, hge_ctx, evts_webhook): - check_query_f(hge_ctx, self.dir() + '/bad_content_type_transform.yaml') - - def test_transform_headers_disallows_bad_content_types(self, hge_ctx, evts_webhook): - check_query_f(hge_ctx, self.dir() + '/bad_header_transform.yaml') diff --git a/server/tests-py/test_metadata.py b/server/tests-py/test_metadata.py index b0cd22ff02f..dba93d4a786 100644 --- a/server/tests-py/test_metadata.py +++ b/server/tests-py/test_metadata.py @@ -223,6 +223,12 @@ class TestMetadata: def test_webhook_transform_success(self, hge_ctx): check_query_f(hge_ctx, self.dir() + '/test_webhook_transform_success.yaml') + def test_webhook_transform_success_remove_body(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + '/test_webhook_transform_success_remove_body.yaml') + + def test_webhook_transform_success_old_body_schema(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + '/test_webhook_transform_success_old_body_schema.yaml') + def test_webhook_transform_with_url_env_reference_success(self, hge_ctx): check_query_f(hge_ctx, self.dir() + '/test_webhook_transform_env_reference_success.yaml')