2022-03-08 03:42:06 +03:00
|
|
|
{-# LANGUAGE DeriveAnyClass #-}
|
|
|
|
{-# LANGUAGE StandaloneKindSignatures #-}
|
|
|
|
{-# LANGUAGE UndecidableInstances #-}
|
|
|
|
|
2022-03-11 02:22:54 +03:00
|
|
|
-- | The 'Transform' typeclass with various types and helper functions
|
|
|
|
-- for evaluating transformations.
|
2022-03-08 03:42:06 +03:00
|
|
|
module Hasura.RQL.DDL.Webhook.Transform.Class
|
|
|
|
( -- * Transformation Interface and Utilities
|
|
|
|
Transform (..),
|
|
|
|
|
|
|
|
-- ** Error Context
|
|
|
|
TransformErrorBundle (..),
|
|
|
|
throwErrorBundle,
|
|
|
|
|
|
|
|
-- ** Request Transformation Context
|
|
|
|
RequestTransformCtx (..),
|
|
|
|
ResponseTransformCtx (..),
|
|
|
|
mkReqTransformCtx,
|
|
|
|
|
|
|
|
-- * Templating
|
|
|
|
TemplatingEngine (..),
|
|
|
|
Template (..),
|
|
|
|
Version (..),
|
2022-03-11 02:22:54 +03:00
|
|
|
runRequestTemplateTransform,
|
|
|
|
validateRequestTemplateTransform,
|
|
|
|
validateRequestTemplateTransform',
|
2022-03-08 03:42:06 +03:00
|
|
|
|
|
|
|
-- * Unescaped
|
|
|
|
UnescapedTemplate (..),
|
|
|
|
wrapUnescapedTemplate,
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedRequestTemplateTransform,
|
|
|
|
runUnescapedRequestTemplateTransform',
|
|
|
|
runUnescapedResponseTemplateTransform,
|
|
|
|
runUnescapedResponseTemplateTransform',
|
|
|
|
validateRequestUnescapedTemplateTransform,
|
|
|
|
validateRequestUnescapedTemplateTransform',
|
2022-03-08 03:42:06 +03:00
|
|
|
)
|
|
|
|
where
|
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
import Control.Arrow (left)
|
|
|
|
import Control.Lens (bimap, view)
|
|
|
|
import Data.Aeson (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
|
|
|
|
import Data.Aeson qualified as J
|
2022-07-21 10:05:46 +03:00
|
|
|
import Data.Aeson.Kriti.Functions as KFunc
|
2022-03-08 03:42:06 +03:00
|
|
|
import Data.ByteString (ByteString)
|
2022-09-20 05:48:21 +03:00
|
|
|
import Data.ByteString.Builder (toLazyByteString)
|
2022-03-08 03:42:06 +03:00
|
|
|
import Data.ByteString.Builder.Scientific (scientificBuilder)
|
|
|
|
import Data.ByteString.Lazy qualified as LBS
|
|
|
|
import Data.HashMap.Strict qualified as M
|
|
|
|
import Data.Kind (Constraint, Type)
|
|
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
|
|
import Data.Text.Encoding qualified as TE
|
|
|
|
import Data.Validation (Validation, fromEither)
|
|
|
|
import Hasura.Incremental (Cacheable)
|
|
|
|
import Hasura.Prelude
|
2022-07-21 10:05:46 +03:00
|
|
|
import Hasura.Session (SessionVariables)
|
2022-03-08 03:42:06 +03:00
|
|
|
import Kriti.Error qualified as Kriti (CustomFunctionError (..), serialize)
|
|
|
|
import Kriti.Parser qualified as Kriti (parser)
|
|
|
|
import Network.HTTP.Client.Transformable qualified as HTTP
|
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | 'Transform' describes how to reify a defunctionalized transformation for
|
|
|
|
-- a particular request field.
|
|
|
|
type Transform :: Type -> Constraint
|
|
|
|
class Transform a where
|
|
|
|
-- | The associated type 'TransformFn a' is the defunctionalized version
|
|
|
|
-- of some transformation that should be applied to a given request field.
|
|
|
|
--
|
|
|
|
-- In most cases it is some variation on a piece of template text describing
|
|
|
|
-- the transformation.
|
|
|
|
data TransformFn a :: Type
|
|
|
|
|
|
|
|
-- | 'transform' is a function which takes 'TransformFn' of @a@ and reifies
|
|
|
|
-- it into a function of the form:
|
|
|
|
--
|
|
|
|
-- @
|
|
|
|
-- ReqTransformCtx -> a -> m a
|
|
|
|
-- @
|
|
|
|
transform ::
|
|
|
|
MonadError TransformErrorBundle m =>
|
|
|
|
TransformFn a ->
|
|
|
|
RequestTransformCtx ->
|
|
|
|
a ->
|
|
|
|
m a
|
|
|
|
|
2022-03-11 02:22:54 +03:00
|
|
|
-- | Validate a 'TransformFn' of @a@.
|
|
|
|
validate ::
|
|
|
|
TemplatingEngine ->
|
|
|
|
TransformFn a ->
|
|
|
|
Validation TransformErrorBundle ()
|
|
|
|
|
2022-03-08 03:42:06 +03:00
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | We use collect all transformation failures as a '[J.Value]'.
|
|
|
|
newtype TransformErrorBundle = TransformErrorBundle
|
|
|
|
{ tebMessages :: [J.Value]
|
|
|
|
}
|
|
|
|
deriving stock (Eq, Generic, Show)
|
|
|
|
deriving newtype (Monoid, Semigroup, FromJSON, ToJSON)
|
|
|
|
deriving anyclass (Cacheable, NFData)
|
|
|
|
|
|
|
|
-- | A helper function for serializing transformation errors to JSON.
|
|
|
|
throwErrorBundle ::
|
|
|
|
MonadError TransformErrorBundle m =>
|
|
|
|
Text ->
|
|
|
|
Maybe J.Value ->
|
|
|
|
m a
|
|
|
|
throwErrorBundle msg val = do
|
|
|
|
let requiredCtx =
|
|
|
|
[ "error_code" J..= ("TransformationError" :: Text),
|
|
|
|
"message" J..= msg
|
|
|
|
]
|
|
|
|
optionalCtx =
|
|
|
|
[ ("value" J..=) <$> val
|
|
|
|
]
|
|
|
|
err = J.object (requiredCtx <> catMaybes optionalCtx)
|
|
|
|
throwError $ TransformErrorBundle [err]
|
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | Common context that is made available to all request transformations.
|
|
|
|
data RequestTransformCtx = RequestTransformCtx
|
|
|
|
{ rtcBaseUrl :: Maybe J.Value,
|
|
|
|
rtcBody :: J.Value,
|
|
|
|
rtcSessionVariables :: J.Value,
|
|
|
|
rtcQueryParams :: Maybe J.Value,
|
|
|
|
rtcEngine :: TemplatingEngine,
|
2022-03-16 03:39:21 +03:00
|
|
|
rtcFunctions :: M.HashMap Text (J.Value -> Either Kriti.CustomFunctionError J.Value)
|
2022-03-08 03:42:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
instance ToJSON RequestTransformCtx where
|
|
|
|
toJSON RequestTransformCtx {..} =
|
|
|
|
let required =
|
|
|
|
[ "body" J..= rtcBody,
|
|
|
|
"session_variables" J..= rtcSessionVariables
|
|
|
|
]
|
|
|
|
optional =
|
|
|
|
[ ("base_url" J..=) <$> rtcBaseUrl,
|
|
|
|
("query_params" J..=) <$> rtcQueryParams
|
|
|
|
]
|
|
|
|
in J.object (required <> catMaybes optional)
|
|
|
|
|
|
|
|
-- | A smart constructor for constructing the 'RequestTransformCtx'
|
2022-03-16 03:39:21 +03:00
|
|
|
--
|
|
|
|
-- 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!
|
|
|
|
mkReqTransformCtx ::
|
|
|
|
Text ->
|
|
|
|
Maybe SessionVariables ->
|
|
|
|
TemplatingEngine ->
|
|
|
|
HTTP.Request ->
|
2022-03-08 03:42:06 +03:00
|
|
|
RequestTransformCtx
|
2022-03-16 03:39:21 +03:00
|
|
|
mkReqTransformCtx url sessionVars rtcEngine reqData =
|
|
|
|
let rtcBaseUrl = Just $ J.toJSON url
|
|
|
|
rtcBody =
|
|
|
|
let mBody = view HTTP.body reqData >>= J.decode @J.Value
|
|
|
|
in fromMaybe J.Null mBody
|
|
|
|
rtcSessionVariables = J.toJSON sessionVars
|
|
|
|
rtcQueryParams =
|
|
|
|
let queryParams =
|
|
|
|
view HTTP.queryParams reqData & fmap \(key, val) ->
|
|
|
|
(TE.decodeUtf8 key, fmap TE.decodeUtf8 val)
|
|
|
|
in Just $ J.toJSON queryParams
|
|
|
|
in RequestTransformCtx
|
|
|
|
{ rtcBaseUrl,
|
|
|
|
rtcBody,
|
|
|
|
rtcSessionVariables,
|
|
|
|
rtcQueryParams,
|
|
|
|
rtcEngine,
|
2022-07-21 10:05:46 +03:00
|
|
|
rtcFunctions = KFunc.sessionFunctions sessionVars
|
2022-03-16 03:39:21 +03:00
|
|
|
}
|
2022-03-08 03:42:06 +03:00
|
|
|
|
|
|
|
-- | Common context that is made available to all response transformations.
|
|
|
|
data ResponseTransformCtx = ResponseTransformCtx
|
|
|
|
{ responseTransformBody :: J.Value,
|
|
|
|
responseTransformReqCtx :: J.Value,
|
2022-03-16 03:39:21 +03:00
|
|
|
responseTransformFunctions :: M.HashMap Text (J.Value -> Either Kriti.CustomFunctionError J.Value),
|
2022-03-08 03:42:06 +03:00
|
|
|
responseTransformEngine :: TemplatingEngine
|
|
|
|
}
|
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | Available templating engines.
|
|
|
|
data TemplatingEngine
|
|
|
|
= Kriti
|
|
|
|
deriving stock (Bounded, Enum, Eq, Generic, Show)
|
|
|
|
deriving anyclass (Cacheable, NFData)
|
|
|
|
|
|
|
|
-- XXX(jkachmar): We need roundtrip tests for these instances.
|
|
|
|
instance FromJSON TemplatingEngine where
|
|
|
|
parseJSON =
|
|
|
|
J.genericParseJSON
|
|
|
|
J.defaultOptions
|
|
|
|
{ J.tagSingleConstructors = True
|
|
|
|
}
|
|
|
|
|
|
|
|
-- XXX(jkachmar): We need roundtrip tests for these instances.
|
|
|
|
instance ToJSON TemplatingEngine where
|
|
|
|
toJSON =
|
|
|
|
J.genericToJSON
|
|
|
|
J.defaultOptions
|
|
|
|
{ J.tagSingleConstructors = True
|
|
|
|
}
|
|
|
|
|
|
|
|
toEncoding =
|
|
|
|
J.genericToEncoding
|
|
|
|
J.defaultOptions
|
|
|
|
{ J.tagSingleConstructors = True
|
|
|
|
}
|
|
|
|
|
2022-03-11 02:22:54 +03:00
|
|
|
-- | Textual transformation template.
|
2022-03-08 03:42:06 +03:00
|
|
|
newtype Template = Template
|
|
|
|
{ unTemplate :: Text
|
|
|
|
}
|
|
|
|
deriving stock (Eq, Generic, Ord, Show)
|
|
|
|
deriving newtype (Hashable, FromJSONKey, ToJSONKey)
|
|
|
|
deriving anyclass (Cacheable, NFData)
|
|
|
|
|
|
|
|
instance J.FromJSON Template where
|
2022-03-11 02:22:54 +03:00
|
|
|
parseJSON = J.withText "Template" (pure . Template)
|
2022-03-08 03:42:06 +03:00
|
|
|
|
|
|
|
instance J.ToJSON Template where
|
|
|
|
toJSON = J.String . coerce
|
|
|
|
|
|
|
|
-- | A helper function for executing transformations from a 'Template'
|
2022-03-11 02:22:54 +03:00
|
|
|
-- and a 'RequestTransformCtx'.
|
|
|
|
--
|
|
|
|
-- NOTE: This and all related funtions are hard-coded to Kriti at the
|
|
|
|
-- moment. When we add additional template engines this function will
|
|
|
|
-- need to take a 'TemplatingEngine' parameter.
|
|
|
|
runRequestTemplateTransform ::
|
2022-03-08 03:42:06 +03:00
|
|
|
Template ->
|
|
|
|
RequestTransformCtx ->
|
|
|
|
Either TransformErrorBundle J.Value
|
2022-03-11 02:22:54 +03:00
|
|
|
runRequestTemplateTransform template RequestTransformCtx {rtcEngine = Kriti, ..} =
|
2022-03-08 03:42:06 +03:00
|
|
|
let context =
|
|
|
|
[ ("$body", rtcBody),
|
|
|
|
("$session_variables", rtcSessionVariables)
|
|
|
|
]
|
|
|
|
<> catMaybes
|
|
|
|
[ ("$query_params",) <$> rtcQueryParams,
|
|
|
|
("$base_url",) <$> rtcBaseUrl
|
|
|
|
]
|
2022-07-21 10:05:46 +03:00
|
|
|
eResult = KFunc.runKritiWith (unTemplate $ template) context rtcFunctions
|
2022-03-08 03:42:06 +03:00
|
|
|
in eResult & left \kritiErr ->
|
2022-07-21 10:05:46 +03:00
|
|
|
let renderedErr = J.toJSON kritiErr
|
2022-03-08 03:42:06 +03:00
|
|
|
in TransformErrorBundle [renderedErr]
|
|
|
|
|
2022-03-11 02:22:54 +03:00
|
|
|
-- TODO: Should this live in 'Hasura.RQL.DDL.Webhook.Transform.Validation'?
|
|
|
|
validateRequestTemplateTransform ::
|
|
|
|
TemplatingEngine ->
|
|
|
|
Template ->
|
|
|
|
Either TransformErrorBundle ()
|
|
|
|
validateRequestTemplateTransform Kriti (Template template) =
|
|
|
|
bimap packBundle (const ()) $ Kriti.parser $ TE.encodeUtf8 template
|
|
|
|
where
|
|
|
|
packBundle = TransformErrorBundle . pure . J.toJSON . Kriti.serialize
|
|
|
|
|
|
|
|
validateRequestTemplateTransform' ::
|
|
|
|
TemplatingEngine ->
|
|
|
|
Template ->
|
|
|
|
Validation TransformErrorBundle ()
|
|
|
|
validateRequestTemplateTransform' engine =
|
|
|
|
fromEither . validateRequestTemplateTransform engine
|
|
|
|
|
|
|
|
-- | A helper function for executing transformations from a 'Template'
|
|
|
|
-- and a 'ResponseTransformCtx'.
|
|
|
|
--
|
|
|
|
-- NOTE: This and all related funtions are hard-coded to Kriti at the
|
|
|
|
-- moment. When we add additional template engines this function will
|
|
|
|
-- need to take a 'TemplatingEngine' parameter.
|
|
|
|
runResponseTemplateTransform ::
|
2022-03-08 03:42:06 +03:00
|
|
|
Template ->
|
|
|
|
ResponseTransformCtx ->
|
|
|
|
Either TransformErrorBundle J.Value
|
2022-03-11 02:22:54 +03:00
|
|
|
runResponseTemplateTransform template ResponseTransformCtx {responseTransformEngine = Kriti, ..} =
|
2022-03-08 03:42:06 +03:00
|
|
|
let context = [("$body", responseTransformBody), ("$request", responseTransformReqCtx)]
|
2022-07-21 10:05:46 +03:00
|
|
|
eResult = KFunc.runKritiWith (unTemplate $ template) context responseTransformFunctions
|
2022-03-08 03:42:06 +03:00
|
|
|
in eResult & left \kritiErr ->
|
2022-07-21 10:05:46 +03:00
|
|
|
let renderedErr = J.toJSON kritiErr
|
2022-03-08 03:42:06 +03:00
|
|
|
in TransformErrorBundle [renderedErr]
|
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | 'RequestTransform' Versioning
|
|
|
|
data Version
|
|
|
|
= V1
|
|
|
|
| V2
|
|
|
|
deriving stock (Eq, Generic, Show)
|
|
|
|
deriving anyclass (Cacheable, Hashable, NFData)
|
|
|
|
|
|
|
|
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 2, encountered " ++ show i
|
|
|
|
|
|
|
|
instance J.ToJSON Version where
|
|
|
|
toJSON = \case
|
|
|
|
V1 -> J.toJSON @Int 1
|
|
|
|
V2 -> J.toJSON @Int 2
|
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-- | Validated textual transformation template /for string
|
|
|
|
-- interpolation only/.
|
|
|
|
--
|
|
|
|
-- This is necessary due to Kriti not distinguishing between string
|
|
|
|
-- literals and string templates.
|
|
|
|
newtype UnescapedTemplate = UnescapedTemplate
|
|
|
|
{ getUnescapedTemplate :: Text
|
|
|
|
}
|
|
|
|
deriving stock (Eq, Generic, Ord, Show)
|
|
|
|
deriving newtype (Hashable, FromJSONKey, ToJSONKey)
|
|
|
|
deriving anyclass (Cacheable, NFData)
|
|
|
|
|
|
|
|
instance J.FromJSON UnescapedTemplate where
|
2022-03-11 02:22:54 +03:00
|
|
|
parseJSON = J.withText "Template" (pure . UnescapedTemplate)
|
2022-03-08 03:42:06 +03:00
|
|
|
|
|
|
|
instance J.ToJSON UnescapedTemplate where
|
|
|
|
toJSON = J.String . coerce
|
|
|
|
|
|
|
|
-- | Wrap an 'UnescapedTemplate' with escaped double quotes.
|
|
|
|
wrapUnescapedTemplate :: UnescapedTemplate -> Template
|
|
|
|
wrapUnescapedTemplate (UnescapedTemplate txt) = Template $ "\"" <> txt <> "\""
|
|
|
|
|
|
|
|
-- | A helper function for executing Kriti transformations from a
|
2022-03-11 02:22:54 +03:00
|
|
|
-- 'UnescapedTemplate' and a 'RequestTrasformCtx'.
|
|
|
|
--
|
|
|
|
-- The difference from 'runRequestTemplateTransform' is that this
|
|
|
|
-- function will wrap the template text in double quotes before
|
|
|
|
-- running Kriti.
|
|
|
|
runUnescapedRequestTemplateTransform ::
|
2022-03-08 03:42:06 +03:00
|
|
|
RequestTransformCtx ->
|
|
|
|
UnescapedTemplate ->
|
|
|
|
Either TransformErrorBundle ByteString
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedRequestTemplateTransform context unescapedTemplate = do
|
2022-03-08 03:42:06 +03:00
|
|
|
result <-
|
2022-03-11 02:22:54 +03:00
|
|
|
runRequestTemplateTransform
|
2022-03-08 03:42:06 +03:00
|
|
|
(wrapUnescapedTemplate unescapedTemplate)
|
|
|
|
context
|
|
|
|
encodeScalar result
|
|
|
|
|
|
|
|
-- | Run a Kriti transformation with an unescaped template in
|
|
|
|
-- 'Validation' instead of 'Either'.
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedRequestTemplateTransform' ::
|
2022-03-08 03:42:06 +03:00
|
|
|
RequestTransformCtx ->
|
|
|
|
UnescapedTemplate ->
|
|
|
|
Validation TransformErrorBundle ByteString
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedRequestTemplateTransform' context unescapedTemplate =
|
2022-03-08 03:42:06 +03:00
|
|
|
fromEither $
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedRequestTemplateTransform context unescapedTemplate
|
|
|
|
|
|
|
|
-- TODO: Should this live in 'Hasura.RQL.DDL.Webhook.Transform.Validation'?
|
|
|
|
validateRequestUnescapedTemplateTransform ::
|
|
|
|
TemplatingEngine ->
|
|
|
|
UnescapedTemplate ->
|
|
|
|
Either TransformErrorBundle ()
|
|
|
|
validateRequestUnescapedTemplateTransform engine =
|
|
|
|
validateRequestTemplateTransform engine . wrapUnescapedTemplate
|
|
|
|
|
|
|
|
validateRequestUnescapedTemplateTransform' ::
|
|
|
|
TemplatingEngine ->
|
|
|
|
UnescapedTemplate ->
|
|
|
|
Validation TransformErrorBundle ()
|
|
|
|
validateRequestUnescapedTemplateTransform' engine =
|
|
|
|
fromEither . validateRequestUnescapedTemplateTransform engine
|
2022-03-08 03:42:06 +03:00
|
|
|
|
2022-03-11 02:22:54 +03:00
|
|
|
-- | Run an 'UnescapedTemplate' with a 'ResponseTransformCtx'.
|
|
|
|
runUnescapedResponseTemplateTransform ::
|
2022-03-08 03:42:06 +03:00
|
|
|
ResponseTransformCtx ->
|
|
|
|
UnescapedTemplate ->
|
|
|
|
Either TransformErrorBundle ByteString
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedResponseTemplateTransform context unescapedTemplate = do
|
|
|
|
result <- runResponseTemplateTransform (wrapUnescapedTemplate unescapedTemplate) context
|
2022-03-08 03:42:06 +03:00
|
|
|
encodeScalar result
|
|
|
|
|
2022-03-11 02:22:54 +03:00
|
|
|
-- | Run an 'UnescapedTemplate' with a 'ResponseTransformCtx' in 'Validation'.
|
|
|
|
runUnescapedResponseTemplateTransform' ::
|
2022-03-08 03:42:06 +03:00
|
|
|
ResponseTransformCtx ->
|
|
|
|
UnescapedTemplate ->
|
|
|
|
Validation TransformErrorBundle ByteString
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedResponseTemplateTransform' context unescapedTemplate =
|
2022-03-08 03:42:06 +03:00
|
|
|
fromEither $
|
2022-03-11 02:22:54 +03:00
|
|
|
runUnescapedResponseTemplateTransform context unescapedTemplate
|
2022-03-08 03:42:06 +03:00
|
|
|
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
-- Utility functions.
|
|
|
|
|
|
|
|
-- | Encode a JSON Scalar Value as a 'ByteString'.
|
|
|
|
-- If a non-Scalar value is provided, will return a 'TrnasformErrorBundle'
|
|
|
|
encodeScalar ::
|
|
|
|
MonadError TransformErrorBundle m =>
|
|
|
|
J.Value ->
|
|
|
|
m ByteString
|
|
|
|
encodeScalar = \case
|
|
|
|
J.String str -> pure $ encodeUtf8 str
|
|
|
|
J.Number num ->
|
|
|
|
pure . LBS.toStrict . toLazyByteString $ scientificBuilder num
|
|
|
|
J.Bool True -> pure "true"
|
|
|
|
J.Bool False -> pure "false"
|
|
|
|
val ->
|
|
|
|
throwErrorBundle "Template must produce a String, Number, or Boolean value" (Just val)
|