diff --git a/docs/services/opsgenie.md b/docs/services/opsgenie.md new file mode 100644 index 00000000..7d9c74fd --- /dev/null +++ b/docs/services/opsgenie.md @@ -0,0 +1,59 @@ +# OpsGenie + +## URL Format + +## Creating a REST API endpoint in OpsGenie + +1. Open up the Integration List page by clicking on *Settings => Integration List* within the menu +![Screenshot 1](opsgenie/1.png) + +2. Click *API => Add* + +3. Make sure *Create and Update Access* and *Enabled* are checked and click *Save Integration* +![Screenshot 2](opsgenie/2.png) + +4. Copy the *API Key* + +5. Format the service URL + +The host can be either api.opsgenie.com or api.eu.opsgenie.com depending on the location of your instance. See +the [OpsGenie documentation](https://docs.opsgenie.com/docs/alert-api) for details. + +``` +opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889 + └───────────────────────────────────┘ + token +``` + +## Passing parameters via code + +If you want to, you can pass additional parameters to the `send` function. +
+The following example contains all parameters that are currently supported. + +```gotemplate +service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`, + "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`, + "actions": "An action", + "tags": "tag1 tag2", + "details": `{"key1": "value1", "key2": "value2"}`, + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", +}) +``` + +# Optional parameters + +You can optionally specify the parameters in the URL: +opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=["tag1","tag2"]&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note + +Example using the command line: + + shoutrrr send -u 'opsgenie://api.eu.opsgenie.com/token?tags=["tag1","tag2"]&description=testing&responders=[{"username":"superuser", "type": "user"}]&entity=Example Entity&source=Example Source&actions=["asdf", "bcde"]' -m "Hello World6" + diff --git a/docs/services/opsgenie/1.png b/docs/services/opsgenie/1.png new file mode 100644 index 00000000..dc4cadf2 Binary files /dev/null and b/docs/services/opsgenie/1.png differ diff --git a/docs/services/opsgenie/2.png b/docs/services/opsgenie/2.png new file mode 100644 index 00000000..0bf1cc29 Binary files /dev/null and b/docs/services/opsgenie/2.png differ diff --git a/pkg/format/format.go b/pkg/format/format.go index 7acbf7ce..557495a2 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -5,28 +5,12 @@ import ( "strings" ) -// NotifyFormat describes the format used in the notification body -type NotifyFormat int - -const ( - // Markdown is the default notification format - Markdown NotifyFormat = 0 -) - // ParseBool returns true for "1","true","yes" or false for "0","false","no" or defaultValue for any other value func ParseBool(value string, defaultValue bool) (parsedValue bool, ok bool) { switch strings.ToLower(value) { - case "true": - fallthrough - case "1": - fallthrough - case "yes": + case "true", "1", "yes": return true, true - case "false": - fallthrough - case "0": - fallthrough - case "no": + case "false", "0", "no": return false, true default: return defaultValue, false diff --git a/pkg/format/format_query.go b/pkg/format/format_query.go index 4f49ede5..bbb0b665 100644 --- a/pkg/format/format_query.go +++ b/pkg/format/format_query.go @@ -19,7 +19,7 @@ func BuildQuery(cqr types.ConfigQueryResolver) string { } value, err := cqr.Get(key) - if err == nil { + if err == nil && value != "" { query.Set(key, value) } } diff --git a/pkg/format/format_test.go b/pkg/format/format_test.go new file mode 100644 index 00000000..b0e53b26 --- /dev/null +++ b/pkg/format/format_test.go @@ -0,0 +1,273 @@ +package format + +import ( + "errors" + "github.com/containrrr/shoutrrr/pkg/types" + "reflect" + + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var ( + // logger *log.Logger + f = formatter{ + EnumFormatters: map[string]types.EnumFormatter{ + "TestEnum": testEnum, + }, + MaxDepth: 2, + } + ts *testStruct +) + +func TestFormat(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shoutrrr Discord Suite") +} + +var _ = Describe("the format package", func() { + BeforeSuite(func() { + // logger = log.New(GinkgoWriter, "Test", log.LstdFlags) + }) + + Describe("SetConfigField", func() { + var ( + tv reflect.Value + ) + tt := reflect.TypeOf(testStruct{}) + fields := f.getStructFieldInfo(tt) + + fieldMap := make(map[string]FieldInfo, len(fields)) + for _, fi := range fields { + fieldMap[fi.Name] = fi + } + When("updating a struct", func() { + + BeforeEach(func() { + tsPtr := reflect.New(tt) + tv = tsPtr.Elem() + ts = tsPtr.Interface().(*testStruct) + }) + + When("setting an integer value", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["Signed"], "3") + Expect(valid).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + + Expect(ts.Signed).To(Equal(3)) + }) + }) + When("the value is invalid", func() { + It("should return an error", func() { + ts.Signed = 2 + valid, err := SetConfigField(tv, fieldMap["Signed"], "z7") + Expect(valid).To(BeFalse()) + Expect(err).To(HaveOccurred()) + + Expect(ts.Signed).To(Equal(2)) + }) + }) + }) + + When("setting an unsigned integer value", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["Unsigned"], "6") + Expect(valid).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + + Expect(ts.Unsigned).To(Equal(uint(6))) + }) + }) + When("the value is invalid", func() { + It("should return an error", func() { + ts.Unsigned = 2 + valid, err := SetConfigField(tv, fieldMap["Unsigned"], "-3") + + Expect(ts.Unsigned).To(Equal(uint(2))) + Expect(valid).To(BeFalse()) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + When("setting a string slice value", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["StrSlice"], "meawannowalkalitabitalleh,meawannofeelalitabitstrongah") + Expect(valid).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + + Expect(ts.StrSlice).To(HaveLen(2)) + }) + }) + }) + + When("setting a string array value", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["StrArray"], "meawannowalkalitabitalleh,meawannofeelalitabitstrongah,meawannothinkalitabitsmartah") + Expect(valid).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + }) + }) + When("the value has too many elements", func() { + It("should return an error", func() { + valid, err := SetConfigField(tv, fieldMap["StrArray"], "one,two,three,four?") + Expect(valid).To(BeFalse()) + Expect(err).To(HaveOccurred()) + }) + }) + When("the value has too few elements", func() { + It("should return an error", func() { + valid, err := SetConfigField(tv, fieldMap["StrArray"], "one,two") + Expect(valid).To(BeFalse()) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + When("setting a struct value", func() { + When("it implements ConfigProp", func() { + It("should return an error", func() { + valid, err := SetConfigField(tv, fieldMap["Sub"], "@awol") + Expect(err).To(HaveOccurred()) + Expect(valid).NotTo(BeTrue()) + }) + }) + When("it implements ConfigProp", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["SubProp"], "@awol") + Expect(err).NotTo(HaveOccurred()) + Expect(valid).To(BeTrue()) + + Expect(ts.SubProp.Value).To(Equal("awol")) + }) + }) + When("the value is invalid", func() { + It("should return an error", func() { + valid, err := SetConfigField(tv, fieldMap["SubProp"], "missing initial at symbol") + Expect(err).To(HaveOccurred()) + Expect(valid).NotTo(BeTrue()) + }) + }) + }) + }) + + When("setting a struct slice value", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["SubPropSlice"], "@alice,@merton") + Expect(err).NotTo(HaveOccurred()) + Expect(valid).To(BeTrue()) + + Expect(ts.SubPropSlice).To(HaveLen(2)) + }) + }) + }) + + When("setting a struct pointer slice value", func() { + When("the value is valid", func() { + It("should set it", func() { + valid, err := SetConfigField(tv, fieldMap["SubPropPtrSlice"], "@the,@best") + Expect(err).NotTo(HaveOccurred()) + Expect(valid).To(BeTrue()) + + Expect(ts.SubPropPtrSlice).To(HaveLen(2)) + }) + }) + }) + }) + + When("formatting stuct values", func() { + BeforeEach(func() { + tsPtr := reflect.New(tt) + tv = tsPtr.Elem() + }) + When("setting and formatting", func() { + It("should format signed integers identical to input", func() { + testSetAndFormat(tv, fieldMap["Signed"], "-45", "-45") + }) + It("should format unsigned integers identical to input", func() { + testSetAndFormat(tv, fieldMap["Unsigned"], "5", "5") + }) + It("should format structs identical to input", func() { + testSetAndFormat(tv, fieldMap["SubProp"], "@whoa", "@whoa") + }) + It("should format enums identical to input", func() { + testSetAndFormat(tv, fieldMap["TestEnum"], "Foo", "Foo") + }) + It("should format string slices identical to input", func() { + testSetAndFormat(tv, fieldMap["StrSlice"], "one,two,three,four", "[ one, two, three, four ]") + }) + It("should format string arrays identical to input", func() { + testSetAndFormat(tv, fieldMap["StrArray"], "one,two,three", "[ one, two, three ]") + }) + It("should format prop struct slices identical to input", func() { + testSetAndFormat(tv, fieldMap["SubPropSlice"], "@be,@the,@best", "[ @be, @the, @best ]") + }) + It("should format prop struct slices identical to input", func() { + testSetAndFormat(tv, fieldMap["SubPropPtrSlice"], "@diet,@glue", "[ @diet, @glue ]") + }) + It("should format prop struct slices identical to input", func() { + testSetAndFormat(tv, fieldMap["StrMap"], "one:1,two:2", "{ one: 1, two: 2 }") + }) + }) + }) + }) +}) + +func testSetAndFormat(tv reflect.Value, field FieldInfo, value string, prettyFormat string) { + _, _ = SetConfigField(tv, field, value) + fieldValue := tv.FieldByName(field.Name) + + // Used for de-/serializing configuration + formatted, err := GetConfigFieldString(tv, field) + Expect(err).NotTo(HaveOccurred()) + Expect(formatted).To(Equal(value)) + + // Used for pretty printing output, coloring etc. + formatted, _ = f.getStructFieldValueString(fieldValue, field, 0) + Expect(formatted).To(Equal(prettyFormat)) +} + +type testStruct struct { + Signed int + Unsigned uint + Str string + StrSlice []string + StrArray [3]string + Sub subStruct + TestEnum int + SubProp subPropStruct + SubSlice []subStruct + SubPropSlice []subPropStruct + SubPropPtrSlice []*subPropStruct + StrMap map[string]string +} + +type subStruct struct { + Value string +} + +type subPropStruct struct { + Value string +} + +func (s *subPropStruct) SetFromProp(propValue string) error { + if len(propValue) < 1 || propValue[0] != '@' { + return errors.New("invalid value") + } + s.Value = propValue[1:] + return nil +} +func (s *subPropStruct) GetPropValue() (string, error) { + return "@" + s.Value, nil +} + +var testEnum = CreateEnumFormatter([]string{"None", "Foo", "Bar"}) diff --git a/pkg/format/formatter.go b/pkg/format/formatter.go index 7cd2d1d2..7e7170a8 100644 --- a/pkg/format/formatter.go +++ b/pkg/format/formatter.go @@ -81,25 +81,9 @@ func (fmtr *formatter) formatStructMap(structType reflect.Type, structItem inter valueLen := 0 preLen := 16 - isEnum := field.EnumFormatter != nil - if values.IsValid() { - // Add some space to print the value preLen = 40 - if isEnum { - fieldVal := values.Field(i) - kind := fieldVal.Kind() - if kind == reflect.Int { - valueStr := field.EnumFormatter.Print(int(fieldVal.Int())) - value = ColorizeEnum(valueStr) - valueLen = len(valueStr) - } else { - err := fmt.Errorf("incorrect enum type '%s' for field '%s'", kind, field.Name) - fmtr.Errors = append(fmtr.Errors, err) - } - } else if nextDepth < fmtr.MaxDepth { - value, valueLen = fmtr.getFieldValueString(values.FieldByName(field.Name), field.Base, nextDepth) - } + value, valueLen = fmtr.getStructFieldValueString(values.Field(i), field, nextDepth) } else { // Since no values was supplied, let's substitute the value with the type typeName := field.Type.String() @@ -120,10 +104,10 @@ func (fmtr *formatter) formatStructMap(structType reflect.Type, structItem inter } if len(field.DefaultValue) > 0 { - value += fmt.Sprintf(" ", ColorizeValue(field.DefaultValue, isEnum)) + value += fmt.Sprintf(" ", ColorizeValue(field.DefaultValue, field.EnumFormatter != nil)) } - if isEnum { + if field.EnumFormatter != nil { value += fmt.Sprintf(" [%s]", strings.Join(field.EnumFormatter.Names(), ", ")) } @@ -137,6 +121,23 @@ func (fmtr *formatter) formatStructMap(structType reflect.Type, structItem inter return valueMap, maxKeyLen } +func (fmtr *formatter) getStructFieldValueString(fieldVal reflect.Value, field FieldInfo, nextDepth uint8) (value string, valueLen int) { + if field.EnumFormatter != nil { + kind := fieldVal.Kind() + if kind == reflect.Int { + valueStr := field.EnumFormatter.Print(int(fieldVal.Int())) + return ColorizeEnum(valueStr), len(valueStr) + } else { + err := fmt.Errorf("incorrect enum type '%s' for field '%s'", kind, field.Name) + fmtr.Errors = append(fmtr.Errors, err) + return "", 0 + } + } else if nextDepth >= fmtr.MaxDepth { + return + } + return fmtr.getFieldValueString(fieldVal, field.Base, nextDepth) +} + // FieldInfo is the meta data about a config field type FieldInfo struct { Name string @@ -261,7 +262,7 @@ func (fmtr *formatter) getFieldValueString(field reflect.Value, base int, depth items := make([]string, fieldLen) totalLen := 4 var itemLen int - for i := 0; i < fieldLen; i++ { + for i := range items { items[i], itemLen = fmtr.getFieldValueString(field.Index(i), base, nextDepth) totalLen += itemLen } @@ -273,32 +274,29 @@ func (fmtr *formatter) getFieldValueString(field reflect.Value, base int, depth } if kind == reflect.Map { - items := make([]string, field.Len()) + sb := strings.Builder{} + sb.WriteString("{ ") iter := field.MapRange() - index := 0 // initial value for totalLen is surrounding curlies and spaces, and separating commas totalLen := 4 + (field.Len() - 1) - for iter.Next() { + for i := 0; iter.Next(); i++ { key, keyLen := fmtr.getFieldValueString(iter.Key(), base, nextDepth) value, valueLen := fmtr.getFieldValueString(iter.Value(), base, nextDepth) - items[index] = fmt.Sprintf("%s: %s", key, value) + if sb.Len() > 2 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s: %s", key, value)) totalLen += keyLen + valueLen + 2 } + sb.WriteString(" }") - return fmt.Sprintf("{ %s }", strings.Join(items, ", ")), totalLen + return sb.String(), totalLen } - if kind == reflect.Struct { - structMap, _ := fmtr.formatStructMap(field.Type(), field, depth+1) - structFieldCount := len(structMap) - items := make([]string, structFieldCount) - index := 0 - totalLen := 4 + (structFieldCount - 1) - for key, value := range structMap { - items[index] = fmt.Sprintf("%s: %s", key, value) - index++ - totalLen += len(key) + 2 + len(value) - } - return fmt.Sprintf("< %s >", strings.Join(items, ", ")), totalLen + if kind == reflect.Struct || kind == reflect.Ptr { + formatted, err := GetConfigPropString(field) + if err == nil { + return formatted, len(formatted) + } } strVal := kind.String() return fmt.Sprintf("", strVal), len(strVal) + 5 @@ -346,15 +344,75 @@ func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (v configField.SetBool(value) return true, nil + } else if fieldKind == reflect.Map { + keyKind := field.Type.Key().Kind() + elemKind := field.Type.Elem().Kind() + if elemKind != reflect.String || keyKind != reflect.String { + return false, errors.New("field format is not supported") + } + + newMap := make(map[string]string) + + pairs := strings.Split(inputValue, ",") + for _, pair := range pairs { + elems := strings.Split(pair, ":") + if len(elems) != 2 { + return false, errors.New("field format is not supported") + } + key := elems[0] + value := elems[1] + + newMap[key] = value + } + + configField.Set(reflect.ValueOf(newMap)) + return true, nil + } else if fieldKind == reflect.Struct { + valuePtr, err := GetConfigPropFromString(field.Type, inputValue) + if err != nil { + return false, err + } + configField.Set(valuePtr.Elem()) + return true, nil } else if fieldKind >= reflect.Slice || fieldKind == reflect.Array { elemType := field.Type.Elem() + elemValType := elemType elemKind := elemType.Kind() - if elemKind != reflect.String { + + if elemKind == reflect.Ptr { + // When updating a pointer slice, use the value type kind + elemValType = elemType.Elem() + elemKind = elemValType.Kind() + } + + if elemKind != reflect.Struct && elemKind != reflect.String { return false, errors.New("field format is not supported") } values := strings.Split(inputValue, ",") - value := reflect.ValueOf(values) + + var value reflect.Value + if elemKind == reflect.Struct { + propValues := reflect.MakeSlice(reflect.SliceOf(elemType), 0, len(values)) + for _, v := range values { + propPtr, err := GetConfigPropFromString(elemValType, v) + if err != nil { + return false, err + } + propVal := propPtr + + // If not a pointer slice, dereference the value + if elemType.Kind() != reflect.Ptr { + propVal = propPtr.Elem() + } + propValues = reflect.Append(propValues, propVal) + } + value = propValues + } else { + // Use the split string parts as the target value + value = reflect.ValueOf(values) + } + if fieldKind == reflect.Array { arrayLen := field.Type.Len() if len(values) != arrayLen { @@ -376,10 +434,44 @@ func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (v } +func GetConfigPropFromString(structType reflect.Type, value string) (reflect.Value, error) { + valuePtr := reflect.New(structType) + configProp, ok := valuePtr.Interface().(types.ConfigProp) + if !ok { + return reflect.Value{}, errors.New("struct field cannot be used as a prop") + } + + if err := configProp.SetFromProp(value); err != nil { + return reflect.Value{}, err + } + + return valuePtr, nil +} + +func GetConfigPropString(propPtr reflect.Value) (string, error) { + + if propPtr.Kind() != reflect.Ptr { + propVal := propPtr + propPtr = reflect.New(propVal.Type()) + propPtr.Elem().Set(propVal) + } + + configProp, ok := propPtr.Interface().(types.ConfigProp) + if !ok { + return "", errors.New("struct field cannot be used as a prop") + } + + return configProp.GetPropValue() +} + // GetConfigFieldString serializes the config field value to a string representation func GetConfigFieldString(config reflect.Value, field FieldInfo) (value string, err error) { configField := config.FieldByName(field.Name) fieldKind := field.Type.Kind() + if fieldKind == reflect.Ptr { + configField = configField.Elem() + fieldKind = field.Type.Elem().Kind() + } if fieldKind == reflect.String { return configField.String(), nil @@ -395,14 +487,42 @@ func GetConfigFieldString(config reflect.Value, field FieldInfo) (value string, return strconv.FormatInt(configField.Int(), field.Base), nil } else if fieldKind == reflect.Bool { return PrintBool(configField.Bool()), nil - } else if fieldKind >= reflect.Slice { + } else if fieldKind == reflect.Map { + keyKind := field.Type.Key().Kind() + elemKind := field.Type.Elem().Kind() + if elemKind != reflect.String || keyKind != reflect.String { + return "", errors.New("field format is not supported") + } + + kvPairs := []string{} + for _, key := range configField.MapKeys() { + value := configField.MapIndex(key).Interface() + + kvPairs = append(kvPairs, fmt.Sprintf("%s:%s", key, value)) + } + return strings.Join(kvPairs, ","), nil + } else if fieldKind == reflect.Slice || fieldKind == reflect.Array { sliceLen := configField.Len() sliceValue := configField.Slice(0, sliceLen) - if field.Type.Elem().Kind() != reflect.String { + elemKind := field.Type.Elem().Kind() + var slice []string + if elemKind == reflect.Struct || elemKind == reflect.Ptr { + slice = make([]string, sliceLen) + for i := range slice { + strVal, err := GetConfigPropString(configField.Index(i)) + if err != nil { + return "", err + } + slice[i] = strVal + } + } else if elemKind == reflect.String { + slice = sliceValue.Interface().([]string) + } else { return "", errors.New("field format is not supported") } - slice := sliceValue.Interface().([]string) return strings.Join(slice, ","), nil + } else if fieldKind == reflect.Struct { + return GetConfigPropString(configField) } return "", fmt.Errorf("field kind %x is not supported", fieldKind) diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go index 0af8cb6e..89aa1efd 100644 --- a/pkg/router/servicemap.go +++ b/pkg/router/servicemap.go @@ -8,6 +8,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/services/join" "github.com/containrrr/shoutrrr/pkg/services/logger" "github.com/containrrr/shoutrrr/pkg/services/mattermost" + "github.com/containrrr/shoutrrr/pkg/services/opsgenie" "github.com/containrrr/shoutrrr/pkg/services/pushbullet" "github.com/containrrr/shoutrrr/pkg/services/pushover" "github.com/containrrr/shoutrrr/pkg/services/rocketchat" @@ -37,4 +38,5 @@ var serviceMap = map[string]func() t.Service{ "zulip": func() t.Service { return &zulip.Service{} }, "join": func() t.Service { return &join.Service{} }, "rocketchat": func() t.Service { return &rocketchat.Service{} }, + "opsgenie": func() t.Service { return &opsgenie.Service{} }, } diff --git a/pkg/services/ifttt/ifttt_test.go b/pkg/services/ifttt/ifttt_test.go index f91df427..03d6bc85 100644 --- a/pkg/services/ifttt/ifttt_test.go +++ b/pkg/services/ifttt/ifttt_test.go @@ -102,7 +102,7 @@ var _ = Describe("the ifttt package", func() { When("serializing a config to URL", func() { When("given multiple events", func() { It("should return an URL with all the events comma-separated", func() { - expectedURL := "ifttt://dummyID/?events=foo%2Cbar%2Cbaz&messagevalue=0&value1=&value2=&value3=" + expectedURL := "ifttt://dummyID/?events=foo%2Cbar%2Cbaz&messagevalue=0" config := Config{ Events: []string{"foo", "bar", "baz"}, WebHookID: "dummyID", @@ -112,6 +112,20 @@ var _ = Describe("the ifttt package", func() { Expect(resultURL).To(Equal(expectedURL)) }) }) + + When("given values", func() { + It("should return an URL with all the values", func() { + expectedURL := "ifttt://dummyID/?messagevalue=0&value1=v1&value2=v2&value3=v3" + config := Config{ + WebHookID: "dummyID", + Value1: "v1", + Value2: "v2", + Value3: "v3", + } + resultURL := config.GetURL().String() + Expect(resultURL).To(Equal(expectedURL)) + }) + }) }) When("sending a message", func() { It("should error if the response code is not 204 no content", func() { diff --git a/pkg/services/opsgenie/opsgenie.go b/pkg/services/opsgenie/opsgenie.go new file mode 100644 index 00000000..348fdac3 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie.go @@ -0,0 +1,107 @@ +package opsgenie + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/services/standard" + "github.com/containrrr/shoutrrr/pkg/types" +) + +const ( + alertEndpointTemplate = "https://%s:%d/v2/alerts" +) + +// Service providing OpsGenie as a notification service +type Service struct { + standard.Standard + config *Config + pkr format.PropKeyResolver +} + +func (service *Service) sendAlert(url string, apiKey string, payload AlertPayload) error { + jsonBody, err := json.Marshal(payload) + if err != nil { + return err + } + + jsonBuffer := bytes.NewBuffer(jsonBody) + + req, err := http.NewRequest("POST", url, jsonBuffer) + if err != nil { + return err + } + req.Header.Add("Authorization", "GenieKey "+apiKey) + req.Header.Add("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification to OpsGenie: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("OpsGenie notification returned %d HTTP status code. Cannot read body: %s", resp.StatusCode, err) + } + return fmt.Errorf("OpsGenie notification returned %d HTTP status code: %s", resp.StatusCode, body) + } + + return nil +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service +func (service *Service) Initialize(configURL *url.URL, logger *log.Logger) error { + service.Logger.SetLogger(logger) + service.config = &Config{} + service.pkr = format.NewPropKeyResolver(service.config) + return service.config.setURL(&service.pkr, configURL) +} + +// Send a notification message to OpsGenie +// See: https://docs.opsgenie.com/docs/alert-api#create-alert +func (service *Service) Send(message string, params *types.Params) error { + config := service.config + url := fmt.Sprintf(alertEndpointTemplate, config.Host, config.Port) + payload, err := service.newAlertPayload(message, params) + if err != nil { + return err + } + return service.sendAlert(url, config.APIKey, payload) +} + +func (service *Service) newAlertPayload(message string, params *types.Params) (AlertPayload, error) { + if params == nil { + params = &types.Params{} + } + + // Defensive copy + payloadFields := *service.config + + if err := service.pkr.UpdateConfigFromParams(&payloadFields, params); err != nil { + return AlertPayload{}, err + } + + result := AlertPayload{ + Message: message, + Alias: payloadFields.Alias, + Description: payloadFields.Description, + Responders: payloadFields.Responders, + VisibleTo: payloadFields.VisibleTo, + Actions: payloadFields.Actions, + Tags: payloadFields.Tags, + Details: payloadFields.Details, + Entity: payloadFields.Entity, + Source: payloadFields.Source, + Priority: payloadFields.Priority, + User: payloadFields.User, + Note: payloadFields.Note, + } + return result, nil +} diff --git a/pkg/services/opsgenie/opsgenie_config.go b/pkg/services/opsgenie/opsgenie_config.go new file mode 100644 index 00000000..39fdb56f --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_config.go @@ -0,0 +1,96 @@ +package opsgenie + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/types" +) + +const defaultPort = 443 + +// Config for use within the opsgenie service +type Config struct { + APIKey string `desc:"The OpsGenie API key"` + Host string `desc:"The OpsGenie API host. Use 'api.eu.opsgenie.com' for EU instances" default:"api.opsgenie.com"` + Port uint16 `desc:"The OpsGenie API port." default:"443"` + Alias string `key:"alias" desc:"Client-defined identifier of the alert" optional:"true"` + Description string `key:"description" desc:"Description field of the alert" optional:"true"` + Responders []Entity `key:"responders" desc:"Teams, users, escalations and schedules that the alert will be routed to send notifications" optional:"true"` + VisibleTo []Entity `key:"visibleTo" desc:"Teams and users that the alert will become visible to without sending any notification" optional:"true"` + Actions []string `key:"actions" desc:"Custom actions that will be available for the alert" optional:"true"` + Tags []string `key:"tags" desc:"Tags of the alert" optional:"true"` + Details map[string]string `key:"details" desc:"Map of key-value pairs to use as custom properties of the alert" optional:"true"` + Entity string `key:"entity" desc:"Entity field of the alert that is generally used to specify which domain the Source field of the alert" optional:"true"` + Source string `key:"source" desc:"Source field of the alert" optional:"true"` + Priority string `key:"priority" desc:"Priority level of the alert. Possible values are P1, P2, P3, P4 and P5" optional:"true"` + Note string `key:"note" desc:"Additional note that will be added while creating the alert" optional:"true"` + User string `key:"user" desc:"Display name of the request owner" optional:"true"` +} + +// Enums returns an empty map because the OpsGenie service doesn't use Enums +func (config Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} + +// GetURL is the public version of getURL that creates a new PropKeyResolver when accessed from outside the package +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + return config.getURL(&resolver) +} + +// Private version of GetURL that can use an instance of PropKeyResolver instead of rebuilding it's model from reflection +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + host := "" + if config.Port > 0 { + host = fmt.Sprintf("%s:%d", config.Host, config.Port) + } else { + host = config.Host + } + + result := &url.URL{ + Host: host, + Path: fmt.Sprintf("/%s", config.APIKey), + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + } + + return result +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + return config.setURL(&resolver, url) +} + +// Private version of SetURL that can use an instance of PropKeyResolver instead of rebuilding it's model from reflection +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + config.Host = url.Hostname() + config.APIKey = url.Path[1:] + + if url.Port() != "" { + port, err := strconv.ParseUint(url.Port(), 10, 16) + if err != nil { + return err + } + config.Port = uint16(port) + } else { + config.Port = 443 + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return err + } + } + + return nil +} + +const ( + // Scheme is the identifying part of this service's configuration URL + Scheme = "opsgenie" +) diff --git a/pkg/services/opsgenie/opsgenie_entity.go b/pkg/services/opsgenie/opsgenie_entity.go new file mode 100644 index 00000000..9ff8f3e8 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_entity.go @@ -0,0 +1,71 @@ +package opsgenie + +import ( + "fmt" + "regexp" + "strings" +) + +// Entity represents either a user or a team +// +// The different variations are: +// +// { "id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c", "type":"team" } +// { "name":"rocket_team", "type":"team" } +// { "id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8", "type":"user" } +// { "username":"trinity@opsgenie.com", "type":"user" } +type Entity struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Username string `json:"username,omitempty"` +} + +// SetFromProp deserializes an entity +func (e *Entity) SetFromProp(propValue string) error { + elements := strings.Split(propValue, ":") + + if len(elements) != 2 { + return fmt.Errorf("invalid entity, should have two elments separated by colon: %q", propValue) + } + e.Type = elements[0] + identifier := elements[1] + isID, err := isOpsGenieID(identifier) + if err != nil { + return fmt.Errorf("invalid entity, cannot parse id/name: %q", identifier) + } + + if isID { + e.ID = identifier + } else if e.Type == "team" { + e.Name = identifier + } else if e.Type == "user" { + e.Username = identifier + } else { + return fmt.Errorf("invalid entity, unexpected entity type: %q", e.Type) + } + + return nil +} + +// GetPropValue serializes an entity +func (e *Entity) GetPropValue() (string, error) { + identifier := "" + + if e.ID != "" { + identifier = e.ID + } else if e.Name != "" { + identifier = e.Name + } else if e.Username != "" { + identifier = e.Username + } else { + return "", fmt.Errorf("invalid entity, should have either ID, name or username") + } + + return fmt.Sprintf("%s:%s", e.Type, identifier), nil +} + +// Detects OpsGenie IDs in the form 4513b7ea-3b91-438f-b7e4-e3e54af9147c +func isOpsGenieID(str string) (bool, error) { + return regexp.MatchString(`^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$`, str) +} diff --git a/pkg/services/opsgenie/opsgenie_json.go b/pkg/services/opsgenie/opsgenie_json.go new file mode 100644 index 00000000..3d5e6c6d --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_json.go @@ -0,0 +1,33 @@ +package opsgenie + +// AlertPayload represents the payload being sent to the OpsGenie API +// +// See: https://docs.opsgenie.com/docs/alert-api#create-alert +// +// Some fields contain complex values like arrays and objects. +// Because `params` are strings only we cannot pass in slices +// or maps. Instead we "preserve" the JSON in those fields. That +// way we can pass in complex types as JSON like so: +// +// service.Send("An example alert message", &types.Params{ +// "alias": "Life is too short for no alias", +// "description": "Every alert needs a description", +// "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`, +// "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`, +// "details": `{"key1": "value1", "key2": "value2"}`, +// }) +type AlertPayload struct { + Message string `json:"message"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Responders []Entity `json:"responders,omitempty"` + VisibleTo []Entity `json:"visibleTo,omitempty"` + Actions []string `json:"actions,omitempty"` + Tags []string `json:"tags,omitempty"` + Details map[string]string `json:"details,omitempty"` + Entity string `json:"entity,omitempty"` + Source string `json:"source,omitempty"` + Priority string `json:"priority,omitempty"` + User string `json:"user,omitempty"` + Note string `json:"note,omitempty"` +} diff --git a/pkg/services/opsgenie/opsgenie_test.go b/pkg/services/opsgenie/opsgenie_test.go new file mode 100644 index 00000000..ae3b26b7 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_test.go @@ -0,0 +1,417 @@ +package opsgenie + +import ( + "bytes" + "crypto/tls" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/containrrr/shoutrrr/pkg/types" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +const ( + mockAPIKey = "eb243592-faa2-4ba2-a551q-1afdf565c889" + mockHost = "api.opsgenie.com" +) + +func TestOpsGenie(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shoutrrr OpsGenie Suite") +} + +var _ = Describe("the OpsGenie service", func() { + var ( + // a simulated http server to mock out OpsGenie itself + mockServer *httptest.Server + // the host of our mock server + mockHost string + // function to check if the http request received by the mock server is as expected + checkRequest func(body string, header http.Header) + // the shoutrrr OpsGenie service + service *Service + // just a mock logger + mockLogger *log.Logger + ) + + BeforeEach(func() { + // Initialize a mock http server + httpHandler := func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + Expect(err).To(BeNil()) + defer r.Body.Close() + + checkRequest(string(body), r.Header) + } + mockServer = httptest.NewTLSServer(http.HandlerFunc(httpHandler)) + + // Our mock server doesn't have a valid cert + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + // Determine the host of our mock http server + mockServerURL, err := url.Parse(mockServer.URL) + Expect(err).To(BeNil()) + mockHost = mockServerURL.Host + + // Initialize a mock logger + var buf bytes.Buffer + mockLogger = log.New(&buf, "", 0) + }) + + AfterEach(func() { + mockServer.Close() + }) + + Context("without query parameters", func() { + BeforeEach(func() { + // Initialize service + serviceURL, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + service = &Service{} + err = service.Initialize(serviceURL, mockLogger) + Expect(err).To(BeNil()) + }) + + When("sending a simple alert", func() { + It("should send a request to our mock OpsGenie server", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"message":"hello world"}`)) + } + + err := service.Send("hello world", &types.Params{}) + Expect(err).To(BeNil()) + }) + }) + + When("sending an alert with runtime parameters", func() { + It("should send a request to our mock OpsGenie server with all fields populated from runtime parameters", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"` + + `message":"An example alert message",` + + `"alias":"Life is too short for no alias",` + + `"description":"Every alert needs a description",` + + `"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` + + `"visibleTo":[{"type":"team","name":"rocket"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"An example entity",` + + `"source":"The source",` + + `"priority":"P1",` + + `"user":"Dracula",` + + `"note":"Here is a note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2", + "visibleTo": "team:rocket", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", + }) + Expect(err).To(BeNil()) + }) + }) + }) + + Context("with query parameters", func() { + BeforeEach(func() { + // Initialize service + serviceURL, err := url.Parse(fmt.Sprintf(`opsgenie://%s/%s?alias=query-alias&description=query-description&responders=team:query_team&visibleTo=user:query_user&actions=queryAction1,queryAction2&tags=queryTag1,queryTag2&details=queryKey1:queryValue1,queryKey2:queryValue2&entity=query-entity&source=query-source&priority=P2&user=query-user¬e=query-note`, mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + service = &Service{} + err = service.Initialize(serviceURL, mockLogger) + Expect(err).To(BeNil()) + }) + + When("sending a simple alert", func() { + It("should send a request to our mock OpsGenie server with all fields populated from query parameters", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{` + + `"message":"An example alert message",` + + `"alias":"query-alias",` + + `"description":"query-description",` + + `"responders":[{"type":"team","name":"query_team"}],` + + `"visibleTo":[{"type":"user","username":"query_user"}],` + + `"actions":["queryAction1","queryAction2"],` + + `"tags":["queryTag1","queryTag2"],` + + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + + `"entity":"query-entity",` + + `"source":"query-source",` + + `"priority":"P2",` + + `"user":"query-user",` + + `"note":"query-note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{}) + Expect(err).To(BeNil()) + }) + }) + + When("sending an alert with runtime parameters", func() { + It("should send a request to our mock OpsGenie server with all fields populated from runtime parameters, overwriting the query parameters", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"` + + `message":"An example alert message",` + + `"alias":"Life is too short for no alias",` + + `"description":"Every alert needs a description",` + + `"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` + + `"visibleTo":[{"type":"team","name":"rocket"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"An example entity",` + + `"source":"The source",` + + `"priority":"P1",` + + `"user":"Dracula",` + + `"note":"Here is a note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2", + "visibleTo": "team:rocket", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", + }) + Expect(err).To(BeNil()) + }) + }) + + When("sending two alerts", func() { + It("should not mix-up the runtime parameters and the query parameters", func() { + // Internally the opsgenie service copies runtime parameters into the config struct + // before generating the alert payload. This test ensures that none of the parameters + // from alert 1 remain in the config struct when sending alert 2 + // In short: This tests if we clone the config struct + + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"` + + `message":"1",` + + `"alias":"1",` + + `"description":"1",` + + `"responders":[{"type":"team","name":"1"}],` + + `"visibleTo":[{"type":"team","name":"1"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"1",` + + `"source":"1",` + + `"priority":"P1",` + + `"user":"1",` + + `"note":"1"` + + `}`)) + } + + err := service.Send("1", &types.Params{ + "alias": "1", + "description": "1", + "responders": "team:1", + "visibleTo": "team:1", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "1", + "source": "1", + "priority": "P1", + "user": "1", + "note": "1", + }) + Expect(err).To(BeNil()) + + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{` + + `"message":"2",` + + `"alias":"query-alias",` + + `"description":"query-description",` + + `"responders":[{"type":"team","name":"query_team"}],` + + `"visibleTo":[{"type":"user","username":"query_user"}],` + + `"actions":["queryAction1","queryAction2"],` + + `"tags":["queryTag1","queryTag2"],` + + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + + `"entity":"query-entity",` + + `"source":"query-source",` + + `"priority":"P2",` + + `"user":"query-user",` + + `"note":"query-note"` + + `}`)) + } + + err = service.Send("2", nil) + Expect(err).To(BeNil()) + }) + }) + }) +}) + +var _ = Describe("the OpsGenie Config struct", func() { + When("generating a config from a simple URL", func() { + It("should populate the config with host and apikey", func() { + url, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.APIKey).To(Equal(mockAPIKey)) + Expect(config.Host).To(Equal(mockHost)) + Expect(config.Port).To(Equal(uint16(443))) + }) + }) + + When("generating a config from a url with port", func() { + It("should populate the port field", func() { + url, err := url.Parse(fmt.Sprintf("opsgenie://%s:12345/%s", mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.Port).To(Equal(uint16(12345))) + }) + }) + + When("generating a config from a url with query parameters", func() { + It("should populate the config fields with the query parameter values", func() { + queryParams := `alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=tag1,tag2&details=key:value,key2:value2&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note&responders=user:Test,team:NOC&visibleTo=user:A+User` + url, err := url.Parse(fmt.Sprintf("opsgenie://%s:12345/%s?%s", mockHost, mockAPIKey, queryParams)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.Alias).To(Equal("Life is too short for no alias")) + Expect(config.Description).To(Equal("Every alert needs a description")) + Expect(config.Responders).To(Equal([]Entity{ + {Type: "user", Username: "Test"}, + {Type: "team", Name: "NOC"}, + })) + Expect(config.VisibleTo).To(Equal([]Entity{ + {Type: "user", Username: "A User"}, + })) + Expect(config.Actions).To(Equal([]string{"An action"})) + Expect(config.Tags).To(Equal([]string{"tag1", "tag2"})) + Expect(config.Details).To(Equal(map[string]string{"key": "value", "key2": "value2"})) + Expect(config.Entity).To(Equal("An example entity")) + Expect(config.Source).To(Equal("The source")) + Expect(config.Priority).To(Equal("P1")) + Expect(config.User).To(Equal("Dracula")) + Expect(config.Note).To(Equal("Here is a note")) + + }) + }) + + When("generating a config from a url with differently escaped spaces", func() { + It("should parse the escaped spaces correctly", func() { + // Use: '%20', '+' and a normal space + queryParams := `alias=Life is+too%20short+for+no+alias` + url, err := url.Parse(fmt.Sprintf("opsgenie://%s:12345/%s?%s", mockHost, mockAPIKey, queryParams)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.Alias).To(Equal("Life is too short for no alias")) + + }) + }) + + When("generating a url from a simple config", func() { + It("should generate a url", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + } + + url := config.GetURL() + + Expect(url.String()).To(Equal("opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889")) + }) + }) + + When("generating a url from a config with a port", func() { + It("should generate a url with port", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + Port: 12345, + } + + url := config.GetURL() + + Expect(url.String()).To(Equal("opsgenie://api.opsgenie.com:12345/eb243592-faa2-4ba2-a551q-1afdf565c889")) + }) + }) + + When("generating a url from a config with all optional config fields", func() { + It("should generate a url with query parameters", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + Alias: "Life is too short for no alias", + Description: "Every alert needs a description", + Responders: []Entity{ + {Type: "user", Username: "Test"}, + {Type: "team", Name: "NOC"}, + {Type: "team", ID: "4513b7ea-3b91-438f-b7e4-e3e54af9147c"}, + }, + VisibleTo: []Entity{ + {Type: "user", Username: "A User"}, + }, + Actions: []string{"action1", "action2"}, + Tags: []string{"tag1", "tag2"}, + Details: map[string]string{"key": "value"}, + Entity: "An example entity", + Source: "The source", + Priority: "P1", + User: "Dracula", + Note: "Here is a note", + } + + url := config.GetURL() + Expect(url.String()).To(Equal(`opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?actions=action1%2Caction2&alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&details=key%3Avalue&entity=An+example+entity¬e=Here+is+a+note&priority=P1&responders=user%3ATest%2Cteam%3ANOC%2Cteam%3A4513b7ea-3b91-438f-b7e4-e3e54af9147c&source=The+source&tags=tag1%2Ctag2&user=Dracula&visibleto=user%3AA+User`)) + }) + }) +}) diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index d1ab0858..fa32141b 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -87,7 +87,7 @@ var _ = Describe("the teams plugin", func() { serviceURL, err := service.GetConfigURLFromCustom(customURL) Expect(err).NotTo(HaveOccurred(), "converting") - Expect(serviceURL.String()).To(Equal(testURLBase + "?color=&host=publicservice.info&title=")) + Expect(serviceURL.String()).To(Equal(testURLBase + "?host=publicservice.info")) }) It("should preserve the query params in the generated service URL", func() { service := Service{} diff --git a/pkg/types/config_prop.go b/pkg/types/config_prop.go new file mode 100644 index 00000000..c2720ba5 --- /dev/null +++ b/pkg/types/config_prop.go @@ -0,0 +1,6 @@ +package types + +type ConfigProp interface { + SetFromProp(propValue string) error + GetPropValue() (string, error) +}