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("%s>", 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)
+}