From 4509fb3f095bb1726d141df2dcf187eb3d467c52 Mon Sep 17 00:00:00 2001 From: Charlie Crawford Date: Tue, 18 Jan 2022 06:51:58 -0500 Subject: [PATCH 1/2] Initial Support for v3 Webhook Payloads --- examples/webhooks/webhook_server.go | 18 ++++++- go.mod | 1 + webhookv3/webhook_event.go | 74 +++++++++++++++++++++++++++++ webhookv3/webhook_event_test.go | 58 ++++++++++++++++++++++ webhookv3/webhookv3.go | 22 +++++++++ 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 webhookv3/webhook_event.go create mode 100644 webhookv3/webhook_event_test.go diff --git a/examples/webhooks/webhook_server.go b/examples/webhooks/webhook_server.go index 98f79453..84762c7b 100644 --- a/examples/webhooks/webhook_server.go +++ b/examples/webhooks/webhook_server.go @@ -2,9 +2,10 @@ package main import ( "fmt" - "log" "net/http" + log "github.com/sirupsen/logrus" + "github.com/PagerDuty/go-pagerduty/webhookv3" ) @@ -35,5 +36,18 @@ func handler(w http.ResponseWriter, r *http.Request) { return } - fmt.Fprintf(w, "received signed webhook") + log.Infof("Received signed webhook") + + payload, err := webhookv3.ReadWebhookPayload(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Errorf("%v", err) + return + } + + event := payload.Event + dataType, _ := event.GetEventDataValue("type") + log.Infof("Event: %v, Event Type: %v, EventData Type: %v", event.ID, event.EventType, dataType) + + fmt.Fprint(w, "OK\n") } diff --git a/go.mod b/go.mod index 00230bbe..40a7c94f 100644 --- a/go.mod +++ b/go.mod @@ -8,5 +8,6 @@ require ( github.com/mitchellh/cli v1.0.0 github.com/mitchellh/go-homedir v1.1.0 github.com/sirupsen/logrus v1.4.2 + github.com/stretchr/testify v1.2.2 gopkg.in/yaml.v2 v2.2.2 ) diff --git a/webhookv3/webhook_event.go b/webhookv3/webhook_event.go new file mode 100644 index 00000000..c6191b82 --- /dev/null +++ b/webhookv3/webhook_event.go @@ -0,0 +1,74 @@ +package webhookv3 + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/PagerDuty/go-pagerduty" +) + +type OutboundEventData struct { + Object map[string]interface{} + // The raw json data for use in structured unmarshalling. + RawData json.RawMessage +} + +type OutboundEvent struct { + ID string `json:"id"` + EventType string `json:"event_type"` + ResourceType string `json:"resource_type"` + OccurredAt string `json:"occurred_at"` + Agent *pagerduty.APIReference `json:"agent"` + Data *OutboundEventData `json:"data"` +} + +type WebhookPayload struct { + Event OutboundEvent `json:"event"` +} + +func (e *OutboundEventData) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &e.Object); err != nil { + return err + } + + if err := e.RawData.UnmarshalJSON(data); err != nil { + return err + } + + return nil +} + +func (oe *OutboundEvent) GetEventDataValue(keys ...string) (string, error) { + return getDataValue(oe.Data.Object, keys) +} + +func getDataValue(d map[string]interface{}, keys []string) (string, error) { + key := keys[0] + node := d[key] + + for k := 1; k < len(keys); k++ { + key = keys[k] + + switch n := node.(type) { + case []interface{}: + intKey, err := strconv.Atoi(key) + if err != nil { + return "", fmt.Errorf("cannot identify array element with key '%s'", key) + } + node = n[intKey] + continue + case map[string]interface{}: + node = n[key] + continue + default: + break + } + } + + if node == nil { + return "", fmt.Errorf("JSON does not have field '%s'", key) + } + + return fmt.Sprintf("%v", node), nil +} diff --git a/webhookv3/webhook_event_test.go b/webhookv3/webhook_event_test.go new file mode 100644 index 00000000..13581da4 --- /dev/null +++ b/webhookv3/webhook_event_test.go @@ -0,0 +1,58 @@ +package webhookv3 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWebhookPayload_UnmarshallJSON(t *testing.T) { + var wp WebhookPayload + + data := `{ "event": { "id": "5ac64822-4adc-4fda-ade0-410becf0de4f", "event_type": "incident.priority_updated", "resource_type": "incident", "occurred_at": "2020-10-02T18:45:22.169Z", "agent": { "html_url": "https://acme.pagerduty.com/users/PLH1HKV", "id": "PLH1HKV", "self": "https://api.pagerduty.com/users/PLH1HKV", "summary": "Tenex Engineer", "type": "user_reference" }, "client": { "name": "PagerDuty" }, "data": { "id": "PGR0VU2", "type": "incident", "self": "https://api.pagerduty.com/incidents/PGR0VU2", "html_url": "https://acme.pagerduty.com/incidents/PGR0VU2", "number": 2, "status": "triggered", "title": "A little bump in the road", "service": { "html_url": "https://acme.pagerduty.com/services/PF9KMXH", "id": "PF9KMXH", "self": "https://api.pagerduty.com/services/PF9KMXH", "summary": "API Service", "type": "service_reference" }, "assignees": [ { "html_url": "https://acme.pagerduty.com/users/PTUXL6G", "id": "PTUXL6G", "self": "https://api.pagerduty.com/users/PTUXL6G", "summary": "User 123", "type": "user_reference" } ], "escalation_policy": { "html_url": "https://acme.pagerduty.com/escalation_policies/PUS0KTE", "id": "PUS0KTE", "self": "https://api.pagerduty.com/escalation_policies/PUS0KTE", "summary": "Default", "type": "escalation_policy_reference" }, "teams": [ { "html_url": "https://acme.pagerduty.com/teams/PFCVPS0", "id": "PFCVPS0", "self": "https://api.pagerduty.com/teams/PFCVPS0", "summary": "Engineering", "type": "team_reference" } ], "priority": { "html_url": "https://acme.pagerduty.com/account/incident_priorities", "id": "PSO75BM", "self": "https://api.pagerduty.com/priorities/PSO75BM", "summary": "P1", "type": "priority_reference" }, "urgency": "high", "conference_bridge": { "conference_number": "+1 1234123412,,987654321#", "conference_url": "https://example.com" }, "resolve_reason": null } } }` + + err := json.Unmarshal([]byte(data), &wp) + assert.NoError(t, err) + + oe := wp.Event + + assert.Equal(t, "5ac64822-4adc-4fda-ade0-410becf0de4f", oe.ID) + assert.Equal(t, "incident.priority_updated", oe.EventType) + assert.Equal(t, "incident", oe.ResourceType) + assert.Equal(t, "2020-10-02T18:45:22.169Z", oe.OccurredAt) + + assert.Equal(t, "PLH1HKV", oe.Agent.ID) + assert.Equal(t, "user_reference", oe.Agent.Type) +} + +func TestWebhookEvent_GetEventDataValue(t *testing.T) { + var wp WebhookPayload + + data := `{ "event": { "id": "5ac64822-4adc-4fda-ade0-410becf0de4f", "event_type": "incident.priority_updated", "resource_type": "incident", "occurred_at": "2020-10-02T18:45:22.169Z", "agent": { "html_url": "https://acme.pagerduty.com/users/PLH1HKV", "id": "PLH1HKV", "self": "https://api.pagerduty.com/users/PLH1HKV", "summary": "Tenex Engineer", "type": "user_reference" }, "client": { "name": "PagerDuty" }, "data": { "id": "PGR0VU2", "type": "incident", "self": "https://api.pagerduty.com/incidents/PGR0VU2", "html_url": "https://acme.pagerduty.com/incidents/PGR0VU2", "number": 2, "status": "triggered", "title": "A little bump in the road", "service": { "html_url": "https://acme.pagerduty.com/services/PF9KMXH", "id": "PF9KMXH", "self": "https://api.pagerduty.com/services/PF9KMXH", "summary": "API Service", "type": "service_reference" }, "assignees": [ { "html_url": "https://acme.pagerduty.com/users/PTUXL6G", "id": "PTUXL6G", "self": "https://api.pagerduty.com/users/PTUXL6G", "summary": "User 123", "type": "user_reference" } ], "escalation_policy": { "html_url": "https://acme.pagerduty.com/escalation_policies/PUS0KTE", "id": "PUS0KTE", "self": "https://api.pagerduty.com/escalation_policies/PUS0KTE", "summary": "Default", "type": "escalation_policy_reference" }, "teams": [ { "html_url": "https://acme.pagerduty.com/teams/PFCVPS0", "id": "PFCVPS0", "self": "https://api.pagerduty.com/teams/PFCVPS0", "summary": "Engineering", "type": "team_reference" } ], "priority": { "html_url": "https://acme.pagerduty.com/account/incident_priorities", "id": "PSO75BM", "self": "https://api.pagerduty.com/priorities/PSO75BM", "summary": "P1", "type": "priority_reference" }, "urgency": "high", "conference_bridge": { "conference_number": "+1 1234123412,,987654321#", "conference_url": "https://example.com" }, "resolve_reason": null } } }` + + err := json.Unmarshal([]byte(data), &wp) + assert.NoError(t, err) + + oe := wp.Event + + value, _ := oe.GetEventDataValue("type") + assert.Equal(t, "incident", value) + + value, _ = oe.GetEventDataValue("title") + assert.Equal(t, "A little bump in the road", value) + + value, _ = oe.GetEventDataValue("service", "summary") + assert.Equal(t, "API Service", value) + + value, err = oe.GetEventDataValue("not_a_field") + assert.Equal(t, "", value) + assert.Error(t, err) + + value, _ = oe.GetEventDataValue("assignees", "0", "summary") + assert.Equal(t, "User 123", value) + + value, err = oe.GetEventDataValue("assignees", "not_an_integer") + assert.Equal(t, "", value) + assert.Error(t, err) +} diff --git a/webhookv3/webhookv3.go b/webhookv3/webhookv3.go index bb7ef123..ab47303a 100644 --- a/webhookv3/webhookv3.go +++ b/webhookv3/webhookv3.go @@ -7,6 +7,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -105,3 +106,24 @@ func calculateSignature(payload []byte, secret string) []byte { mac.Write(payload) return mac.Sum(nil) } + +func ReadWebhookPayload(r *http.Request) (*WebhookPayload, error) { + orb := r.Body + + b, err := ioutil.ReadAll(io.LimitReader(r.Body, webhookBodyReaderLimit)) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + defer func() { _ = orb.Close() }() + r.Body = ioutil.NopCloser(bytes.NewReader(b)) + + if len(b) == 0 { + return nil, ErrMalformedBody + } + + var wp WebhookPayload + err = json.Unmarshal(b, &wp) + + return &wp, err +} From 6ca4799f84d0177f11d2ae91f2223e9c9a6532b3 Mon Sep 17 00:00:00 2001 From: Charlie Crawford Date: Tue, 1 Mar 2022 09:45:57 -0500 Subject: [PATCH 2/2] Basic comments and docs. --- webhookv3/webhook_event.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/webhookv3/webhook_event.go b/webhookv3/webhook_event.go index c6191b82..1e710c93 100644 --- a/webhookv3/webhook_event.go +++ b/webhookv3/webhook_event.go @@ -8,12 +8,15 @@ import ( "github.com/PagerDuty/go-pagerduty" ) +// OutboundEventData is the unmarshalled data portion of the OutboundEvent. type OutboundEventData struct { Object map[string]interface{} // The raw json data for use in structured unmarshalling. RawData json.RawMessage } +// OutboundEvent represents the event that is delivered in a V3 Webhook Payload. +// See https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTkw-v3-overview#webhook-payload for more details. type OutboundEvent struct { ID string `json:"id"` EventType string `json:"event_type"` @@ -23,10 +26,14 @@ type OutboundEvent struct { Data *OutboundEventData `json:"data"` } +// WebhookPayload represents the full object delivered as a result of V3 Webhook Subscriptions. +// See https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTkw-v3-overview#webhook-payload for more details. type WebhookPayload struct { Event OutboundEvent `json:"event"` } +// UnmarshalJSON is a custom unmarshaller used to produce OutboundEventData +// for further processing. func (e *OutboundEventData) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &e.Object); err != nil { return err @@ -39,8 +46,14 @@ func (e *OutboundEventData) UnmarshalJSON(data []byte) error { return nil } -func (oe *OutboundEvent) GetEventDataValue(keys ...string) (string, error) { - return getDataValue(oe.Data.Object, keys) +// GetEventDataValue returns a value from the e.Data object using the keys as a path +// or returns an error if the path does not point to a field. +// +// For example, `e.GetEventDataValue("type")` would return the `event.data.type` from a Webhook Payload. +// If the event type is `"incident"`, e.GetEventDataValue("priority", "id") would return the priority id. +// See the tests for additional examples. +func (e OutboundEvent) GetEventDataValue(keys ...string) (string, error) { + return getDataValue(e.Data.Object, keys) } func getDataValue(d map[string]interface{}, keys []string) (string, error) {