graphql-engine/server/src-lib/Hasura/RQL/DDL/Webhook/Transform.hs
jkachmar adb648b429 server: Rework internal webhook request transform components
## Description

Some of the documentation/organizational changes I was putting into the suggestions for #3624 were a bit too convoluted for GitHub's suggestion interface, so I'm putting them here instead.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/3910
Co-authored-by: Solomon <24038+solomon-b@users.noreply.github.com>
GitOrigin-RevId: 06e0cb08bd18e7f8b21452df0697cfd80bc56fde
2022-03-23 20:24:44 +00:00

435 lines
17 KiB
Haskell

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE UndecidableInstances #-}
-- | Webhook Transformations are data transformations used to modify
-- HTTP Requests/Responses before requests are executed and after
-- responses are received.
--
-- Transformations are supplied by users as part of the Metadata for a
-- particular Action or EventTrigger as a 'RequestTransform'
-- record. Per-field Transformations are stored as data
-- (defunctionalized), often in the form of a Kriti template, and then
-- converted into actual functions (reified) at runtime by the
-- 'Transform' typeclass.
--
-- We take a Higher Kinded Data (HKD) approach to representing the
-- transformations. 'RequestFields' is an HKD which can represent the
-- actual request data as 'RequestFields Identity' or the
-- defunctionalized transforms as 'RequestFields (WithOptional
-- TransformFn)'.
--
-- We can then traverse over the entire 'RequestFields' HKD to reify
-- all the fields at once and apply them to our actual request
-- data.
--
-- NOTE: We don't literally use 'traverse' or the HKD equivalent
-- 'btraverse', but you can think of this operation morally as a
-- traversal. See 'applyRequestTransform' for implementation details.
module Hasura.RQL.DDL.Webhook.Transform
( -- * Request Transformation
RequestFields (..),
RequestTransform (..),
RequestTransformFns,
applyRequestTransform,
-- * Request Transformation Context
RequestTransformCtx (..),
TransformErrorBundle (..),
-- * Optional Functor
WithOptional (..),
withOptional,
-- * Old Style Response Transforms
MetadataResponseTransform (..),
ResponseTransform (..),
ResponseTransformCtx (..),
applyResponseTransform,
buildRespTransformCtx,
mkResponseTransform,
)
where
-------------------------------------------------------------------------------
import Control.Lens (Lens', lens, set, traverseOf, view)
import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson.Extended qualified as J
import Data.Bifunctor (first)
import Data.ByteString.Lazy qualified as BL
import Data.CaseInsensitive qualified as CI
import Data.Coerce (Coercible)
import Data.Functor.Barbie (AllBF, ApplicativeB, ConstraintsB, FunctorB, TraversableB)
import Data.Functor.Barbie qualified as B
import Data.HashMap.Strict qualified as M
import Data.Text.Encoding qualified as TE
import Data.Validation qualified as V
import Hasura.Incremental (Cacheable)
import Hasura.Prelude hiding (first)
import Hasura.RQL.DDL.Webhook.Transform.Body (Body (..), BodyTransformFn, TransformFn (BodyTransformFn_))
import Hasura.RQL.DDL.Webhook.Transform.Body qualified as Body
import Hasura.RQL.DDL.Webhook.Transform.Class
import Hasura.RQL.DDL.Webhook.Transform.Headers (Headers (..), HeadersTransformFn (..), TransformFn (HeadersTransformFn_))
import Hasura.RQL.DDL.Webhook.Transform.Method
import Hasura.RQL.DDL.Webhook.Transform.QueryParams
import Hasura.RQL.DDL.Webhook.Transform.Url
import Hasura.Session (SessionVariables, getSessionVariableValue, mkSessionVariable)
import Kriti qualified (runKriti)
import Kriti.Error qualified as Kriti (CustomFunctionError (CustomFunctionError), serialize)
import Network.HTTP.Client.Transformable qualified as HTTP
-------------------------------------------------------------------------------
-- | 'RequestTransform' is the metadata representation of a request
-- transformation. It consists of a record of higher kinded data (HKD)
-- along with some regular data. We seperate the HKD data into its own
-- record field called 'requestFields' which we nest inside our
-- non-HKD record. The actual transformation operations are contained
-- in the HKD.
data RequestTransform = RequestTransform
{ version :: Version,
requestFields :: RequestFields (WithOptional TransformFn),
templateEngine :: TemplatingEngine
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData, Cacheable)
instance FromJSON RequestTransform where
parseJSON = J.withObject "RequestTransform" \o -> do
version <- o J..:? "version" J..!= V1
method <- o J..:? "method"
url <- o J..:? "url"
body <- case version of
V1 -> do
template :: Maybe Template <- o J..:? "body"
pure $ fmap Body.ModifyAsJSON template
V2 -> o J..:? "body"
queryParams <- o J..:? "query_params"
headers <- o J..:? "request_headers"
let requestFields =
RequestFields
{ method = withOptional @MethodTransformFn method,
url = withOptional @UrlTransformFn url,
body = withOptional @BodyTransformFn body,
queryParams = withOptional @QueryParamsTransformFn queryParams,
requestHeaders = withOptional @HeadersTransformFn headers
}
templateEngine <- o J..:? "template_engine" J..!= Kriti
pure $ RequestTransform {..}
instance ToJSON RequestTransform where
toJSON RequestTransform {..} =
let RequestFields {..} = requestFields
body' = case version of
V1 -> case (getOptional body) of
Just (BodyTransformFn_ (Body.ModifyAsJSON template)) ->
Just ("body", J.toJSON template)
_ -> Nothing
V2 -> "body" J..=? getOptional body
in J.object $
[ "version" J..= version,
"template_engine" J..= templateEngine
]
<> catMaybes
[ "method" J..=? getOptional method,
"url" J..=? getOptional url,
"query_params" J..=? getOptional queryParams,
"request_headers" J..=? getOptional requestHeaders,
body'
]
-------------------------------------------------------------------------------
-- | Defunctionalized Webhook Transformation
--
-- We represent a defunctionalized request transformation by parameterizing
-- our HKD with 'WithOptional'@ @'TransformFn', which marks each of the fields
-- as optional and supplies the appropriate transformation function to them if
-- if they are provided.
type RequestTransformFns = RequestFields (WithOptional TransformFn)
-- | Actual Request Data
--
-- We represent the actual request data by parameterizing our HKD with
-- 'Identity', which allows us to trivially unwrap the fields (which should
-- exist after any transformations have been applied).
type RequestData = RequestFields Identity
-- | This is our HKD type. It is a record with fields for each
-- component of an 'HTTP.Request' we wish to transform.
data RequestFields f = RequestFields
{ method :: f Method,
url :: f Url,
body :: f Body,
queryParams :: f QueryParams,
requestHeaders :: f Headers
}
deriving stock (Generic)
deriving anyclass (FunctorB, ApplicativeB, TraversableB, ConstraintsB)
deriving stock instance
AllBF Show f RequestFields =>
Show (RequestFields f)
deriving stock instance
AllBF Eq f RequestFields =>
Eq (RequestFields f)
deriving anyclass instance
AllBF NFData f RequestFields =>
NFData (RequestFields f)
deriving anyclass instance
AllBF Cacheable f RequestFields =>
Cacheable (RequestFields f)
-- NOTE: It is likely that we can derive these instances. Possibly if
-- we move the aeson instances onto the *Transform types.
instance FromJSON RequestTransformFns where
parseJSON = J.withObject "RequestTransformFns" $ \o -> do
method <- o J..:? "method"
url <- o J..:? "url"
body <- o J..:? "body"
queryParams <- o J..:? "query_params"
headers <- o J..:? "request_headers"
pure $
RequestFields
{ method = withOptional @MethodTransformFn method,
url = withOptional @UrlTransformFn url,
body = withOptional @BodyTransformFn body,
queryParams = withOptional @QueryParamsTransformFn queryParams,
requestHeaders = withOptional @HeadersTransformFn headers
}
instance ToJSON RequestTransformFns where
toJSON RequestFields {..} =
J.object . catMaybes $
[ "method" J..=? getOptional method,
"url" J..=? getOptional url,
"body" J..=? getOptional body,
"query_params" J..=? getOptional queryParams,
"request_headers" J..=? getOptional requestHeaders
]
-------------------------------------------------------------------------------
-- TODO(SOLOMON): Add lens law unit tests
-- | A 'Lens\'' for viewing a 'HTTP.Request' as our 'RequestData' HKD; it does
-- so by wrapping each of the matching request fields in a corresponding
-- 'TransformFn'.
--
-- XXX: This function makes internal usage of 'TE.decodeUtf8', which throws an
-- impure exception when the supplied 'ByteString' cannot be decoded into valid
-- UTF8 text!
requestL :: Lens' HTTP.Request RequestData
requestL = lens getter setter
where
getter :: HTTP.Request -> RequestData
getter req =
RequestFields
{ method = coerce $ CI.mk $ TE.decodeUtf8 $ view HTTP.method req,
url = coerce $ view HTTP.url req,
body = coerce $ JSONBody $ J.decode =<< view HTTP.body req,
queryParams = coerce $ view HTTP.queryParams req,
requestHeaders = coerce $ view HTTP.headers req
}
serializeBody :: Body -> Maybe BL.ByteString
serializeBody = \case
JSONBody body -> fmap J.encode body
RawBody "" -> Nothing
RawBody bs -> Just bs
setter :: HTTP.Request -> RequestData -> HTTP.Request
setter req RequestFields {..} =
req & set HTTP.method (TE.encodeUtf8 $ CI.original $ coerce method)
& set HTTP.body (serializeBody $ coerce body)
& set HTTP.url (coerce url)
& set HTTP.queryParams (unQueryParams $ coerce queryParams)
& set HTTP.headers (coerce requestHeaders)
-- | Transform an 'HTTP.Request' with a 'RequestTransform'.
--
-- Note: we pass in the request url explicitly for use in the
-- 'ReqTransformCtx'. We do this so that we can ensure that the url
-- is syntactically identical to what the use submits. If we use the
-- parsed request from the 'HTTP.Request' term then it is possible
-- that the url is semantically equivalent but syntactically
-- different. An example of this is the presence or lack of a trailing
-- slash on the URL path. This important when performing string
-- interpolation on the request url.
applyRequestTransform ::
forall m.
MonadError TransformErrorBundle m =>
(HTTP.Request -> RequestTransformCtx) ->
RequestTransformFns ->
HTTP.Request ->
m HTTP.Request
applyRequestTransform mkCtx transformations request =
traverseOf
requestL
(transformReqData (mkCtx request))
request
where
-- Apply all of the provided request transformation functions to the
-- request data extracted from the given 'HTTP.Request'.
transformReqData ctx reqData =
B.bsequence' $
B.bzipWithC @Transform
(transformField ctx)
transformations
reqData
-- Apply a transformation to some request data, if it exists; otherwise
-- return the original request data.
transformField ctx (WithOptional maybeFn) (Identity a) =
case maybeFn of
Nothing -> pure a
Just fn -> transform fn ctx a
-------------------------------------------------------------------------------
-- | Enrich a 'Functor' @f@ with optionality; this is primarily useful when
-- one wants to annotate fields as optional when using the Higher-Kinded Data
-- pattern.
--
-- 'WithOptional'@ f@ is equivalent to @Compose Maybe f@.
newtype WithOptional f result = WithOptional
{ getOptional :: Maybe (f result)
}
deriving stock (Eq, Functor, Foldable, Generic, Show)
deriving newtype (FromJSON, ToJSON)
deriving newtype instance
(Cacheable (f result)) =>
Cacheable (WithOptional f result)
deriving newtype instance
(NFData (f result)) =>
NFData (WithOptional f result)
-- | 'WithOptional' smart constructor for the special case of optional values
-- that are representationally equivalent to some "wrapper" type.
--
-- For example:
-- @
-- withOptional \@HeaderTransformsAction headers == WithOptional $ fmap HeadersTransform headers
-- @
--
-- In other words: this function observes the isomorphism between @'Maybe' a@
-- and @'WithOptional' f b@ if an isomorphism exists between @a@ and @f b@.
withOptional ::
forall a b f.
Coercible a (f b) =>
Maybe a ->
WithOptional f b
withOptional = coerce
-------------------------------------------------------------------------------
-- TODO(SOLOMON): Rewrite with HKD
-- | A set of data transformation functions generated from a
-- 'MetadataResponseTransform'. 'Nothing' means use the original
-- response value.
data ResponseTransform = ResponseTransform
{ respTransformBody :: Maybe (ResponseTransformCtx -> Either TransformErrorBundle J.Value),
respTransformTemplateEngine :: TemplatingEngine
}
data MetadataResponseTransform = MetadataResponseTransform
{ mrtVersion :: Version,
mrtBodyTransform :: Maybe BodyTransformFn,
mrtTemplatingEngine :: TemplatingEngine
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData, Cacheable)
instance J.FromJSON MetadataResponseTransform where
parseJSON = J.withObject "MetadataResponseTransform" $ \o -> do
mrtVersion <- o J..:? "version" J..!= V1
mrtBodyTransform <- case mrtVersion of
V1 -> do
template :: (Maybe Template) <- o J..:? "body"
pure $ fmap Body.ModifyAsJSON template
V2 -> o J..:? "body"
templateEngine <- o J..:? "template_engine"
let mrtTemplatingEngine = fromMaybe Kriti templateEngine
pure $ MetadataResponseTransform {..}
instance J.ToJSON MetadataResponseTransform where
toJSON MetadataResponseTransform {..} =
let body = case mrtVersion of
V1 -> case mrtBodyTransform of
Just (Body.ModifyAsJSON template) -> Just ("body", J.toJSON template)
_ -> Nothing
V2 -> "body" J..=? mrtBodyTransform
in J.object $
[ "template_engine" J..= mrtTemplatingEngine,
"version" J..= mrtVersion
]
<> catMaybes [body]
-- | A helper function for constructing the 'RespTransformCtx'
buildRespTransformCtx :: Maybe RequestTransformCtx -> Maybe SessionVariables -> TemplatingEngine -> BL.ByteString -> ResponseTransformCtx
buildRespTransformCtx reqCtx sessionVars engine respBody =
ResponseTransformCtx
{ responseTransformBody = fromMaybe J.Null $ J.decode @J.Value respBody,
responseTransformReqCtx = J.toJSON reqCtx,
responseTransformEngine = engine,
responseTransformFunctions = M.singleton "getSessionVariable" getSessionVar
}
where
getSessionVar :: J.Value -> Either Kriti.CustomFunctionError J.Value
getSessionVar inp = case inp of
J.String txt ->
case sessionVarValue of
Just x -> Right $ J.String x
Nothing -> Left . Kriti.CustomFunctionError $ "Session variable \"" <> txt <> "\" not found"
where
sessionVarValue = sessionVars >>= getSessionVariableValue (mkSessionVariable txt)
_ -> Left $ Kriti.CustomFunctionError "Session variable name should be a string"
-- | Construct a Template Transformation function for Responses
--
-- XXX: This function makes internal usage of 'TE.decodeUtf8', which throws an
-- impure exception when the supplied 'ByteString' cannot be decoded into valid
-- UTF8 text!
mkRespTemplateTransform ::
TemplatingEngine ->
BodyTransformFn ->
ResponseTransformCtx ->
Either TransformErrorBundle J.Value
mkRespTemplateTransform _ Body.Remove _ = pure J.Null
mkRespTemplateTransform engine (Body.ModifyAsJSON (Template template)) ResponseTransformCtx {..} =
let context = [("$body", responseTransformBody), ("$request", responseTransformReqCtx)]
in case engine of
Kriti -> first (TransformErrorBundle . pure . J.toJSON . Kriti.serialize) $ Kriti.runKriti template context
mkRespTemplateTransform engine (Body.ModifyAsFormURLEncoded formTemplates) context =
case engine of
Kriti -> do
result <-
liftEither . V.toEither . for formTemplates $
runUnescapedResponseTemplateTransform' context
pure . J.String . TE.decodeUtf8 . BL.toStrict $ Body.foldFormEncoded result
mkResponseTransform :: MetadataResponseTransform -> ResponseTransform
mkResponseTransform MetadataResponseTransform {..} =
let bodyTransform = mkRespTemplateTransform mrtTemplatingEngine <$> mrtBodyTransform
in ResponseTransform bodyTransform mrtTemplatingEngine
-- | At the moment we only transform the body of
-- Responses. 'http-client' does not export the constructors for
-- 'Response'. If we want to transform then we will need additional
-- 'apply' functions.
applyResponseTransform ::
ResponseTransform ->
ResponseTransformCtx ->
Either TransformErrorBundle BL.ByteString
applyResponseTransform ResponseTransform {..} ctx@ResponseTransformCtx {..} =
let bodyFunc :: BL.ByteString -> Either TransformErrorBundle BL.ByteString
bodyFunc body =
case respTransformBody of
Nothing -> pure body
Just f -> J.encode <$> f ctx
in bodyFunc (J.encode responseTransformBody)