Repository: adnanh/webhook
Pull request #446 (2020-07-29 14:53:51)

Add support for top-level JSON array in payload
Detect if leading character in JSON payload is an array bracket.  If
found, decode payload into an interface{} and then save the results into
payload["root"].  References to payload values would need to reference
the leading, "virtual" root node (i.e. "root.0.name").

moorereason: @adnanh, this is a proposed solution to #215.  Needs docs, but I wanted to get your feedback on the concept before I keep going.  Thoughts?
adnanh: Looks good!
moorereason: I've added this to the 2.8.0 milestone since I consider it an enhancement more than a bugfix.  I'll work on adding docs to this PR.

Patch:
diff --git a/docs/Hook-Examples.md b/docs/Hook-Examples.md
index 23896a7..cb4145e 100644
--- a/docs/Hook-Examples.md
+++ b/docs/Hook-Examples.md
@@ -425,6 +425,57 @@ Travis sends webhooks as `payload=<JSON_STRING>`, so the payload needs to be par
 ]
 ```
 
+## JSON Array Payload
+
+If the JSON payload is an array instead of an object, `webhook` will process the payload and place it into a "root" object.
+Therefore, references to payload values must begin with `root.`.
+
+For example, given the following payload (taken from the Sendgrid Event Webhook documentation):
+```json
+[
+  {
+    "email": "example@test.com",
+    "timestamp": 1513299569,
+    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
+    "event": "processed",
+    "category": "cat facts",
+    "sg_event_id": "sg_event_id",
+    "sg_message_id": "sg_message_id"
+  },
+  {
+    "email": "example@test.com",
+    "timestamp": 1513299569,
+    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
+    "event": "deferred",
+    "category": "cat facts",
+    "sg_event_id": "sg_event_id",
+    "sg_message_id": "sg_message_id",
+    "response": "400 try again later",
+    "attempt": "5"
+  }
+]
+```
+
+A reference to the second item in the array would look like this:
+```json
+[
+  {
+    "id": "sendgrid",
+    "execute-command": "{{ .Hookecho }}",
+    "trigger-rule": {
+      "match": {
+        "type": "value",
+        "parameter": {
+          "source": "payload",
+          "name": "root.1.event"
+        },
+        "value": "deferred"
+      }
+    }
+  }
+]
+```
+
 ## XML Payload
 
 Given the following payload:
diff --git a/webhook.go b/webhook.go
index 1af2ce5..6a258c4 100644
--- a/webhook.go
+++ b/webhook.go
@@ -16,6 +16,7 @@ import (
 	"path/filepath"
 	"strings"
 	"time"
+	"unicode"
 
 	"github.com/adnanh/webhook/internal/hook"
 	"github.com/adnanh/webhook/internal/middleware"
@@ -141,7 +142,7 @@ func main() {
 	}
 
 	if *logPath != "" {
-		file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+		file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
 		if err != nil {
 			logQueue = append(logQueue, fmt.Sprintf("error opening log file %q: %v", *logPath, err))
 			// we'll bail out below
@@ -385,9 +386,29 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
 		decoder := json.NewDecoder(bytes.NewReader(body))
 		decoder.UseNumber()
 
-		err := decoder.Decode(&payload)
-		if err != nil {
-			log.Printf("[%s] error parsing JSON payload %+v\n", rid, err)
+		var firstChar byte
+		for i := 0; i < len(body); i++ {
+			if unicode.IsSpace(rune(body[i])) {
+				continue
+			}
+			firstChar = body[i]
+			break
+		}
+
+		if firstChar == byte('[') {
+			var arrayPayload interface{}
+			err := decoder.Decode(&arrayPayload)
+			if err != nil {
+				log.Printf("[%s] error parsing JSON array payload %+v\n", rid, err)
+			}
+
+			payload = make(map[string]interface{}, 1)
+			payload["root"] = arrayPayload
+		} else {
+			err := decoder.Decode(&payload)
+			if err != nil {
+				log.Printf("[%s] error parsing JSON payload %+v\n", rid, err)
+			}
 		}
 
 	case strings.Contains(contentType, "x-www-form-urlencoded"):
@@ -648,7 +669,7 @@ func handleHook(h *hook.Hook, rid string, headers, query, payload *map[string]in
 	return string(out), err
 }
 
-func writeHttpResponseCode(w http.ResponseWriter, rid string, hookId string, responseCode int) {
+func writeHttpResponseCode(w http.ResponseWriter, rid, hookId string, responseCode int) {
 	// Check if the given return code is supported by the http package
 	// by testing if there is a StatusText for this code.
 	if len(http.StatusText(responseCode)) > 0 {


Test patch:
diff --git a/test/hooks.json.tmpl b/test/hooks.json.tmpl
index 71673ce..7fe7c8c 100644
--- a/test/hooks.json.tmpl
+++ b/test/hooks.json.tmpl
@@ -168,6 +168,22 @@
       ],
     }
   },
+  {
+    "id": "sendgrid",
+    "execute-command": "{{ .Hookecho }}",
+    "command-working-directory": "/",
+    "response-message": "success",
+    "trigger-rule": {
+      "match": {
+        "type": "value",
+        "parameter": {
+          "source": "payload",
+          "name": "root.0.event"
+        },
+        "value": "processed"
+      }
+    }
+  },
   {
     "id": "plex",
     "execute-command": "{{ .Hookecho }}",
diff --git a/test/hooks.yaml.tmpl b/test/hooks.yaml.tmpl
index 7c6e201..ace4277 100644
--- a/test/hooks.yaml.tmpl
+++ b/test/hooks.yaml.tmpl
@@ -97,6 +97,18 @@
           name: "app.messages.message.#text"
         value: "Hello!!"
 
+- id: sendgrid
+  execute-command: '{{ .Hookecho }}'
+  command-working-directory: /
+  response-message: success
+  trigger-rule:
+    match:
+      type: value
+      parameter:
+        source: payload
+        name: root.0.event
+      value: processed
+
 - id: plex
   trigger-rule:
     match:
diff --git a/webhook_test.go b/webhook_test.go
index 6dda780..4671c24 100644
--- a/webhook_test.go
+++ b/webhook_test.go
@@ -547,6 +547,28 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
 		`success`,
 		``,
 	},
+	{
+		"payload-json-array",
+		"sendgrid",
+		nil,
+		"POST",
+		nil,
+		"application/json",
+		`[
+  {
+    "email": "example@test.com",
+    "timestamp": 1513299569,
+    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
+    "event": "processed",
+    "category": "cat facts",
+    "sg_event_id": "sg_event_id",
+    "sg_message_id": "sg_message_id"
+  }
+]`,
+		http.StatusOK,
+		`success`,
+		``,
+	},
 	{
 		"multipart",
 		"plex",