Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(senders): add ability to use template webhook body and custom headers #995

Merged
merged 8 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/dto/triggers.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ func (trigger *Trigger) PopulatedDescription(events moira.NotificationEvents) er
return nil
}

templatingEvents := moira.NotificationEventsToTemplatingEvents(events)
description, err := templating.Populate(trigger.Name, *trigger.Desc, templatingEvents)
triggerDescriptionPopulater := templating.NewTriggerDescriptionPopulater(trigger.Name, events.ToTemplateEvents())
description, err := triggerDescriptionPopulater.Populate(*trigger.Desc)
if err != nil {
return fmt.Errorf("you have an error in your Go template: %v", err)
}
Expand Down
20 changes: 15 additions & 5 deletions datatypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ func (event *NotificationEvent) CreateMessage(location *time.Location) string {
// NotificationEvents represents slice of NotificationEvent
type NotificationEvents []NotificationEvent

// PopulatedDescription populates a description template using provided trigger and events data
func (trigger *TriggerData) PopulatedDescription(events NotificationEvents) error {
description, err := templating.Populate(trigger.Name, trigger.Desc, NotificationEventsToTemplatingEvents(events))
triggerDescriptionPopulater := templating.NewTriggerDescriptionPopulater(trigger.Name, events.ToTemplateEvents())
description, err := triggerDescriptionPopulater.Populate(trigger.Desc)
if err != nil {
description = "Your description is using the wrong template. Since we were unable to populate your template with " +
"data, we return it so you can parse it.\n\n" + trigger.Desc
Expand All @@ -141,10 +143,11 @@ func (trigger *TriggerData) PopulatedDescription(events NotificationEvents) erro
return err
}

func NotificationEventsToTemplatingEvents(events NotificationEvents) []templating.Event {
templatingEvents := make([]templating.Event, 0, len(events))
// ToTemplateEvents converts a slice of NotificationEvent into a slice of templating.Event
func (events NotificationEvents) ToTemplateEvents() []templating.Event {
templateEvents := make([]templating.Event, 0, len(events))
for _, event := range events {
templatingEvents = append(templatingEvents, templating.Event{
templateEvents = append(templateEvents, templating.Event{
Metric: event.Metric,
MetricElements: strings.Split(event.Metric, "."),
Timestamp: event.Timestamp,
Expand All @@ -153,7 +156,7 @@ func NotificationEventsToTemplatingEvents(events NotificationEvents) []templatin
})
}

return templatingEvents
return templateEvents
}

// TriggerData represents trigger object
Expand Down Expand Up @@ -199,6 +202,13 @@ type ContactData struct {
Team string `json:"team"`
}

func (contact *ContactData) ToTemplateContact() *templating.Contact {
kissken marked this conversation as resolved.
Show resolved Hide resolved
return &templating.Contact{
Type: contact.Type,
Value: contact.Value,
}
}

// SubscriptionData represents user subscription
type SubscriptionData struct {
Contacts []string `json:"contacts" example:"acd2db98-1659-4a2f-b227-52d71f6e3ba1"`
Expand Down
41 changes: 39 additions & 2 deletions senders/webhook/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"bytes"
"context"
"encoding/json"
"html"
"net/http"
"net/url"
"strings"

"github.com/moira-alert/moira"
"github.com/moira-alert/moira/templating"
)

func (sender *Sender) buildRequest(events moira.NotificationEvents, contact moira.ContactData, trigger moira.TriggerData, plots [][]byte, throttled bool) (*http.Request, error) {
Expand All @@ -17,21 +19,26 @@ func (sender *Sender) buildRequest(events moira.NotificationEvents, contact moir
String("potentially_dangerous_url", sender.url).
Msg("Found potentially dangerous url template, api contact validation is advised")
}

requestURL := buildRequestURL(sender.url, trigger, contact)
requestBody, err := buildRequestBody(events, contact, trigger, plots, throttled)
requestBody, err := sender.buildRequestBody(events, contact, trigger, plots, throttled)
if err != nil {
return nil, err
}

request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, requestURL, bytes.NewBuffer(requestBody))
if err != nil {
return request, err
}

if sender.user != "" && sender.password != "" {
request.SetBasicAuth(sender.user, sender.password)
}

for k, v := range sender.headers {
request.Header.Set(k, v)
}

sender.log.Debug().
String("method", request.Method).
String("url", request.URL.String()).
Expand All @@ -41,7 +48,33 @@ func (sender *Sender) buildRequest(events moira.NotificationEvents, contact moir
return request, nil
}

func buildRequestBody(events moira.NotificationEvents, contact moira.ContactData, trigger moira.TriggerData, plots [][]byte, throttled bool) ([]byte, error) {
func (sender *Sender) buildRequestBody(
events moira.NotificationEvents,
contact moira.ContactData,
trigger moira.TriggerData,
plots [][]byte,
throttled bool,
) ([]byte, error) {
if sender.body == "" {
return buildDefaultRequestBody(events, contact, trigger, plots, throttled)
}

webhookBodyPopulater := templating.NewWebhookBodyPopulater(contact.ToTemplateContact())
populatedBody, err := webhookBodyPopulater.Populate(sender.body)
if err != nil {
return nil, err
}

return []byte(html.UnescapeString(populatedBody)), nil
}

func buildDefaultRequestBody(
events moira.NotificationEvents,
contact moira.ContactData,
trigger moira.TriggerData,
plots [][]byte,
throttled bool,
) ([]byte, error) {
encodedFirstPlot := ""
encodedPlots := make([]string, 0, len(plots))
for i, plot := range plots {
Expand All @@ -51,6 +84,7 @@ func buildRequestBody(events moira.NotificationEvents, contact moira.ContactData
encodedFirstPlot = encodedPlot
}
}

requestPayload := payload{
Trigger: toTriggerData(trigger),
Events: toEventsData(events),
Expand All @@ -65,6 +99,7 @@ func buildRequestBody(events moira.NotificationEvents, contact moira.ContactData
Plots: encodedPlots,
Throttled: throttled,
}

return json.Marshal(requestPayload)
}

Expand All @@ -75,6 +110,7 @@ func buildRequestURL(template string, trigger moira.TriggerData, contact moira.C
moira.VariableContactType: contact.Type,
moira.VariableTriggerID: trigger.ID,
}

for k, v := range templateVariables {
value := url.PathEscape(v)
if k == moira.VariableContactValue &&
Expand All @@ -83,5 +119,6 @@ func buildRequestURL(template string, trigger moira.TriggerData, contact moira.C
}
template = strings.ReplaceAll(template, k, value)
}

return template
}
6 changes: 4 additions & 2 deletions senders/webhook/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,17 +183,19 @@ var requestURLTestCases = []requestURLTestCase{
}

func TestBuildRequestBody(t *testing.T) {
sender := Sender{}
Convey("Payload should be valid", t, func() {
Convey("Trigger state change", func() {
events, contact, trigger, plot, throttled := testEvents, testContact, testTrigger, testPlot, testThrottled
requestBody, err := buildRequestBody(events, contact, trigger, plot, throttled)
requestBody, err := sender.buildRequestBody(events, contact, trigger, plot, throttled)
actual, expected := prepareStrings(string(requestBody), expectedStateChangePayload)
So(actual, ShouldEqual, expected)
So(err, ShouldBeNil)
})

Convey("Empty notification", func() {
events, contact, trigger, plots, throttled := moira.NotificationEvents{}, moira.ContactData{}, moira.TriggerData{}, make([][]byte, 0), false
requestBody, err := buildRequestBody(events, contact, trigger, plots, throttled)
requestBody, err := sender.buildRequestBody(events, contact, trigger, plots, throttled)
actual, expected := prepareStrings(string(requestBody), expectedEmptyPayload)
So(actual, ShouldEqual, expected)
So(actual, ShouldNotContainSubstring, "null")
Expand Down
18 changes: 14 additions & 4 deletions senders/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ import (

// Structure that represents the Webhook configuration in the YAML file
type config struct {
URL string `mapstructure:"url"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Timeout int `mapstructure:"timeout"`
URL string `mapstructure:"url"`
Body string `mapstructure:"body"`
Headers map[string]string `mapstructure:"headers"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Timeout int `mapstructure:"timeout"`
}

// Sender implements moira sender interface via webhook
type Sender struct {
url string
body string
user string
password string
headers map[string]string
Expand All @@ -41,13 +44,19 @@ func (sender *Sender) Init(senderSettings interface{}, logger moira.Logger, loca
return fmt.Errorf("can not read url from config")
}

sender.body = cfg.Body

sender.user, sender.password = cfg.User, cfg.Password

sender.headers = map[string]string{
"User-Agent": "Moira",
"Content-Type": "application/json",
}

for header, value := range cfg.Headers {
sender.headers[header] = value
}

var timeout int
if cfg.Timeout != 0 {
timeout = cfg.Timeout
Expand All @@ -60,6 +69,7 @@ func (sender *Sender) Init(senderSettings interface{}, logger moira.Logger, loca
Timeout: time.Duration(timeout) * time.Second,
Transport: &http.Transport{DisableKeepAlives: true},
}

return nil
}

Expand Down
55 changes: 15 additions & 40 deletions templating/templating.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,6 @@ import (

const eventTimeFormat = "2006-01-02 15:04:05"

type notification struct {
Trigger trigger
Events []Event
}

type Event struct {
Metric string
MetricElements []string
Timestamp int64
Value *float64
State string
}

func date(unixTime int64) string {
return time.Unix(unixTime, 0).Format(eventTimeFormat)
}
Expand All @@ -33,18 +20,6 @@ func formatDate(unixTime int64, format string) string {
return time.Unix(unixTime, 0).Format(format)
}

func (event Event) TimestampDecrease(second int64) int64 {
return event.Timestamp - second
}

func (event Event) TimestampIncrease(second int64) int64 {
return event.Timestamp + second
}

type trigger struct {
Name string `json:"name"`
}

func filterKeys(source template.FuncMap, keys []string) template.FuncMap {
result := template.FuncMap{}
for _, key := range keys {
Expand Down Expand Up @@ -250,33 +225,33 @@ var sprigFuncMap = filterKeys(sprig.FuncMap(), []string{
"regexSplit",
"mustRegexSplit",
"regexQuoteMeta",

// Advanced functions
"uuidv4",
})

func Populate(name, description string, events []Event) (desc string, err error) {
type Populater interface {
Populate(template string) (string, error)
}

func populate(tmpl string, data any) (populatedTemplate string, err error) {
defer func() {
if errRecover := recover(); errRecover != nil {
desc = description
err = fmt.Errorf("PANIC in populate: %w, Trigger name: %s, desc: %s, events:%#v",
err, name, description, events)
populatedTemplate = tmpl
err = fmt.Errorf("PANIC in populate: %v, tmpl: %s, provided data: %#v", errRecover, tmpl, data)
}
}()

buffer := bytes.Buffer{}

dataToExecute := notification{
Trigger: trigger{Name: name},
Events: events,
}
template := template.New("populate-template").Funcs(sprigFuncMap).Funcs(funcMap)

triggerTemplate := template.New("populate-description").Funcs(sprigFuncMap).Funcs(funcMap)
triggerTemplate, err = triggerTemplate.Parse(description)
if err != nil {
return description, err
if template, err = template.Parse(tmpl); err != nil {
return tmpl, err
}

err = triggerTemplate.Execute(&buffer, dataToExecute)
if err != nil {
return description, err
if err = template.Execute(&buffer, data); err != nil {
return tmpl, err
}

return strings.TrimSpace(buffer.String()), nil
Expand Down
Loading
Loading