graphql-engine/rfcs/transforms.md
Solomon Bothwell 4e05bdcaec Feature/request transform string interpolation
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2443
Co-authored-by: Tirumarai Selvan <8663570+tirumaraiselvan@users.noreply.github.com>
GitOrigin-RevId: d7d68984d0ae1403bb414572e9704c01ed27deab
2021-09-29 08:14:29 +00:00

10 KiB

Motivation

Hasura integrates with external HTTP APIs via a variety of features: actions, event triggers, scheduled triggers. Since the client request that these external APIs expect may be different from what Hasura generates, we want to be able to transform these requests before invoking the external API.

Spec

We will introduce a key called transforms in the configuration for actions, event triggers and scheduled triggers.

The schema for transforms is described here:

data RequestMethod = GET | POST | PUT | PATCH | DELETE
data ContentType = JSON | XWWWFORM
newtype TemplatingEngine = KritiLang
newtype TemplateText = TemplateText T.Text

data Transforms
  = Transforms
  { requestMethod ::  Maybe RequestMethod
 -- ^ Change the request method to one provided here. Nothing means POST.

  , requestURL    ::  Maybe TemplateText
 -- ^ Change the request URL to one provided here. Nothing means original URL.

  , request_body  ::  Maybe TemplateText
 -- ^ Change the request_body to one provided here. Nothing means original payload. Only applicable for POST, PUT, PATCH methods
 -- The encoding of the content is defined by the 'request_content_type' field defined below.

  , request_content_type :: Maybe ContentType
 -- ^ Only the following Content-Types are allowed (default: application/json):
 -- 1) application/json
 -- 2) application/x-www-form-urlencoded

  , query_params  ::  Maybe (HashMap Text TemplateText)   # Key-value based templating
 -- ^ Add query params provided here to URL
 -- Each param is URL encoded

  , request_headers :: Maybe TransformHeaders
 -- ^ Tranform headers as defined here.
 -- Certain headers cannot be transformed (e.g. `Content-Type` and should return a warning)

   , templating_engine :: Maybe TemplatingEngine
 -- ^ The template engine to use for transformations. Default: go-basic
  }

-- | The order of evaluation is removeHeaders and then addHeaders
type HeaderName = Text
data TransformHeaders
  = TransformHeaders
  { addHeaders :: [(HeaderName, TemplateText}]
  , removeHeaders :: [HeaderName]
  }

Templating Language

We want to be able to use a templating language which can construct JSON payloads (for POST body) as well as raw strings (for URL transform)

We are going to go with a custom templating language which is a subset of Go templating language. We will call this go-basic.

go-basic will initially consist of string templating for objects and arrays, for loops, if/then/else statements, and basic predicate functions (==, <, >, &&, ||).

String Templating

Useful for example for requestURL or headers transform.

# $url has the original URL
$url/{{.event.author_id}}

For Loops

{
    "articles": [
{{ range .event.author.articles }}
      {
        "id": {{$.id}},
        "title": {{$.title}}
      }
{{ end }}
}

For loops will be declared with the range identifier and a template fragment referrencing the array to be looped over. The current array element is brought in scope be accessed as $.

We will also support enumeration of array elements with a special syntax:

{{ $index, $article := .event.author.articles }}

This binds--with the scope of the loop--the array element as $article and it's array index as $index.

The loop is terminated with the end identifier.

If/Then/Else

If statements can be constructed as follows:

{{if $index}}
, "{{$article}}"
{{else}}
  "{{$article}}"
{{end}}

We will also provide the following basic predicate functions:

$.x == $.y
$.x >  $.y
$.x <  $.y
$.x && $.y
$.x || $.y

A more complete example

{
  "author": {
    "name": "{{$.event.name}}",
    "age": "{{$.event.age}}",
    "articles": [
      {{range $index, $article := $.event.author.articles}}
        {{if $index > 5}}
        , "{{$article}}"
        {{else}}
          "{{$article}}"
        {{end}}
      {{end}}
    ]
  }
}

Implementation Notes

We can define an extended json Value type which includes our primitives:

data Accessor = Obj String | Arr Int
  deriving (Show, Eq)

data ValueExt =
  -- Core Aeson Terms
    Object (M.HashMap Text ValueExt)
  | Array (V.Vector ValueExt)
  | String Text
  | Number Scientific
  | Boolean Bool
  | Null
  -- Extended Terms
  | Path [Accessor]
  | Iff ValueExt ValueExt ValueExt
  | Eq ValueExt ValueExt
  | Gt ValueExt ValueExt
  | Lt ValueExt ValueExt
  | AND ValueExt ValueExt
  | OR ValueExt ValueExt
  | Member ValueExt ValueExt
  | Range (Maybe Text) Text [Accessor] ValueExt
  -- ^ {{ range i, x := $.foo.bar }}
  deriving (Show, Eq, Read)

Here is an example of go-basic and it's parsed AST:

exampleJson = [r|
{
  "author": {
    "name": {{$.event.name}},
    "age": {{$.event.age}},
    "articles": [
{{ range _, $article $.event.author.articles }}
      {
        "id": {{$article.id}},
        "title": {{$article.title}}
      }
{{ end }}
    ]
  }
}
|]

exampleAst :: ValueExt
exampleAst =
  Object $ M.fromList
  [ ("author"
    , Object $ M.fromList
      [ ("name", Path [Obj "event", Obj "name"])
      , ("age", Path [Obj "event", Obj "age"])
      , ("articles"
        , Range Nothing "$article" (Path [Obj "$", Obj "event", Obj "author", Obj "articles"])
            [ Object $ M.fromList
                [ ("id", Path [Obj "$article", Obj "id"])
                , ("title", Path [Obj "$article", Obj "title"])
                ]
            ]

        )
      ]
    )
  ]

Then we write an evaluation function with the type:

eval :: ValueExt -> Value -> Either Err Value

JSON is untyped and we will want to be aware of the likely runtime errors:

  1. Predicates are applied to non-boolean values.
  2. The template reference in a loop declaration references an Array.
  3. Out of bound array accessors and undefined object accessors.

We will have to wait until runtime to catch these sorts of errors.

Alternatives

An alternative templating language is jsonnet. The main reason for choosing go-basic is the ease of the syntax

Examples

  1. Transform the request body:
transforms:
   request_body:
     {
       "key1": "{{$.value1}}",
       "key2": "{{$.value2}}",
       "key3": "{{$.session['x-hasura-user-id']}}"
     }
  1. Tranform the URL based on data in the request body:
transforms:
   request_url: "$url/{{$.input['country']}}"
  1. Transform the method. All webhook integrations (actions, event triggers, scheduled triggers) are POST method based but we can transform the method to GET as shown below. Note that, for GET methods we should be able to map the payload to the query params and they should be URL encoded.
transforms:
   request_method: GET
   query_params:
     param1: {{$.value1}}
     param2: {{$.value2}}
  1. Transform the request headers. For example, below we add a custom header from the event payload and modify the User-Agent. As noted in the spec, we first remove headers and then add headers.
transforms:
   request_headers:
     add_headers: [{"x-cutom-id": "{{$.event.user_id}}"}, { "User-Agent": myapp-server}]
     remove_headers: [User-Agent]
  1. Change the request content type. The following example changes the content type to x-www-form-urlencoded. Note that we still supply the request_body as a JSON. Every top-level field/value is converted into a form parameter (and value is url encoded).
transforms:
   request_body:
     {
       "key1": "{{$.value1}}",
       "key2": "{{$.value2}}",
       "key3": "{{$session.x-hasura-user-id}}"
     }
   request_content_type: x-www-form-urlencoded

This transforms the body to key1={{.value1}}&key2={{.value2}}&key3={{$session.x-hasura-user-id}}. The console can show the output for a given tranformation hence making it clear what is finally going in the body.

Real-world example

  1. Integrating notion.so API via Hasura Action

Action definition:

type Mutation {
  addNotionItem (
    databaseId: String!
    properties: jsonb!
    children: jsonb
  ): ItemOutput
}

type ItemOutput {
  id : String!
  object : String!
  created_time : timestamptz!
  last_edited_time : timestamptz!
}

The transform is given by:

   request_body:
     {
       "parent": {
          "database_id": "{{$.action.input.databaseId}}",
       }.
       "properties": "{{$.action.input.properties}}",
       "children":
         {{ if $.action.input.children }}
           {{$.action.input.children}}
         {{ else }}
           null
         {{ end }}
     }

Source: https://glitch.com/edit/#!/butternut-grizzly-fluorine?path=src%2Fserver.js%3A1%3A0

Debugging

The server also provides a v1/metadata type called test_webhook_transformer which takes the transformer spec and a payload and returns the transformed request.

type: test_http_transformer
args:
  webhook_url: http://httbin.org
  payload:
    event:
      user_id: 1
      username: bob
      password: cat
  transformer:
    request_method: GET
    request_url: $url/{{event.user_id}}
    query_params:
      param1: "{{$.event.username}}"
      param2: "{{$.event.password}}"

Output:

request_method: GET
request_url: http://httbin.org/1?param1=bob&param2=cat

For console editor integration we also provide a second endpoint validate_go-basic_template which takes a template and a json value. This endpoint will attempt to parse and evaluate the template with the json data and return the results.

{
    "type" : "test_webhook_transform",
    "args" : {
        "webhook_url": "https://localhost:1234",
        "body": { "hello": "world" },
        "request_transform": {
          "body": "{{ $.hello }}",
          "template_engine": "Kriti"
         }
    }
}

Happy Output:

{
  "payload": "world",
  "headers": [
    [
      "content-type",
      "application/json"
    ]
  ],
  "method": "GET",
  "webhook_url": "https://localhost:1234/"
}

Failure Output:

{
  "payload": {
    "error_code": "TypeErrorCode",
    "source_position": {
      "end_column": 15,
      "start_line": 1,
      "end_line": 1,
      "start_column": 12
    },
    "message": "Type Error: Expected object"
  },
  "headers": [
    [
      "content-type",
      "application/json"
    ]
  ],
  "method": "GET",
  "webhook_url": "https://localhost:1234/"
}

Console

The console DX is crucial for making the feature successful. We need a way to test a template with sample data. For this, the console needs to generate sample data (payload) for a given webhook. After this, the console takes the provided template and passes it to debugging endpoint described in the previous section.