From d7873daf5d572e1e039e04fab8097f9fee7676c7 Mon Sep 17 00:00:00 2001 From: Alvar Penning Date: Wed, 29 May 2024 11:51:19 +0200 Subject: [PATCH] Channel default value by changing default logic In a nutshell, this allows omitting key value pairs from the configuration JSON given to Plugin.SetConfig, falling back to default values specified in struct tags. To avoid storing each default value twice, it's location was moved up to a "default" struct tag. By utilizing the already included defaults library and adding a small wrapper for plugin.Info, those defaults are now being set to the plugin.ConfigAttributes at runtime. Tests for this new wrapper function, plugin.NewInfo, and for one channel plugin were written. Furthermore, lots of lines have changed their indentation level. Thus, the diff looks greater and scarier as it is. Closes #205. --- cmd/channel/email/main.go | 148 +++++++++++++++++---------------- cmd/channel/email/main_test.go | 65 +++++++++++++++ cmd/channel/rocketchat/main.go | 74 +++++++++-------- cmd/channel/webhook/main.go | 126 ++++++++++++++-------------- pkg/plugin/plugin.go | 53 ++++++++++++ pkg/plugin/plugin_test.go | 85 +++++++++++++++++++ 6 files changed, 379 insertions(+), 172 deletions(-) create mode 100644 cmd/channel/email/main_test.go create mode 100644 pkg/plugin/plugin_test.go diff --git a/cmd/channel/email/main.go b/cmd/channel/email/main.go index 4441180d0..ac9c16f28 100644 --- a/cmd/channel/email/main.go +++ b/cmd/channel/email/main.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/creasty/defaults" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/google/uuid" @@ -26,7 +27,7 @@ type Email struct { Host string `json:"host"` Port string `json:"port"` SenderName string `json:"sender_name"` - SenderMail string `json:"sender_mail"` + SenderMail string `json:"sender_mail" default:"icinga@example.com"` User string `json:"user"` Password string `json:"password"` Encryption string `json:"encryption"` @@ -95,7 +96,12 @@ func (ch *Email) Send(reversePath string, recipients []string, msg []byte) error } func (ch *Email) SetConfig(jsonStr json.RawMessage) error { - err := json.Unmarshal(jsonStr, ch) + err := defaults.Set(ch) + if err != nil { + return err + } + + err = json.Unmarshal(jsonStr, ch) if err != nil { return fmt.Errorf("failed to load config: %s %w", jsonStr, err) } @@ -108,87 +114,83 @@ func (ch *Email) SetConfig(jsonStr json.RawMessage) error { } func (ch *Email) GetInfo() *plugin.Info { - elements := []*plugin.ConfigOption{ - { - Name: "sender_name", - Type: "string", - Label: map[string]string{ - "en_US": "Sender Name", - "de_DE": "Absendername", - }, - }, - { - Name: "sender_mail", - Type: "string", - Label: map[string]string{ - "en_US": "Sender Address", - "de_DE": "Absenderadresse", + info, err := plugin.NewInfo( + &Email{}, + "Email", + internal.Version.Version, + "Icinga GmbH", + []*plugin.ConfigOption{ + { + Name: "host", + Type: "string", + Required: true, + Label: map[string]string{ + "en_US": "SMTP Host", + "de_DE": "SMTP Host", + }, }, - Default: "icinga@example.com", - }, - { - Name: "host", - Type: "string", - Required: true, - Label: map[string]string{ - "en_US": "SMTP Host", - "de_DE": "SMTP Host", + { + Name: "port", + Type: "number", + Required: true, + Label: map[string]string{ + "en_US": "SMTP Port", + "de_DE": "SMTP Port", + }, + Min: types.Int{NullInt64: sql.NullInt64{Int64: 1, Valid: true}}, + Max: types.Int{NullInt64: sql.NullInt64{Int64: 65535, Valid: true}}, }, - }, - { - Name: "port", - Type: "number", - Required: true, - Label: map[string]string{ - "en_US": "SMTP Port", - "de_DE": "SMTP Port", + { + Name: "sender_name", + Type: "string", + Label: map[string]string{ + "en_US": "Sender Name", + "de_DE": "Absendername", + }, }, - Min: types.Int{NullInt64: sql.NullInt64{Int64: 1, Valid: true}}, - Max: types.Int{NullInt64: sql.NullInt64{Int64: 65535, Valid: true}}, - }, - { - Name: "user", - Type: "string", - Label: map[string]string{ - "en_US": "SMTP User", - "de_DE": "SMTP Benutzer", + { + Name: "sender_mail", + Type: "string", + Label: map[string]string{ + "en_US": "Sender Address", + "de_DE": "Absenderadresse", + }, }, - }, - { - Name: "password", - Type: "secret", - Label: map[string]string{ - "en_US": "SMTP Password", - "de_DE": "SMTP Passwort", + { + Name: "user", + Type: "string", + Label: map[string]string{ + "en_US": "SMTP User", + "de_DE": "SMTP Benutzer", + }, }, - }, - { - Name: "encryption", - Type: "option", - Required: true, - Label: map[string]string{ - "en_US": "SMTP Transport Encryption", - "de_DE": "SMTP Transportverschlüsselung", + { + Name: "password", + Type: "secret", + Label: map[string]string{ + "en_US": "SMTP Password", + "de_DE": "SMTP Passwort", + }, }, - Options: map[string]string{ - EncryptionNone: "None", - EncryptionStartTLS: "STARTTLS", - EncryptionTLS: "TLS", + { + Name: "encryption", + Type: "option", + Required: true, + Label: map[string]string{ + "en_US": "SMTP Transport Encryption", + "de_DE": "SMTP Transportverschlüsselung", + }, + Options: map[string]string{ + EncryptionNone: "None", + EncryptionStartTLS: "STARTTLS", + EncryptionTLS: "TLS", + }, }, - }, - } - - configAttrs, err := json.Marshal(elements) + }) if err != nil { panic(err) } - - return &plugin.Info{ - Name: "Email", - Version: internal.Version.Version, - Author: "Icinga GmbH", - ConfigAttributes: configAttrs, - } + return info } func (ch *Email) GetServer() string { diff --git a/cmd/channel/email/main_test.go b/cmd/channel/email/main_test.go new file mode 100644 index 000000000..cb64b61e6 --- /dev/null +++ b/cmd/channel/email/main_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestEmail_SetConfig(t *testing.T) { + tests := []struct { + name string + jsonMsg string + want *Email + wantErr bool + }{ + { + name: "empty-string", + jsonMsg: ``, + wantErr: true, + }, + { + name: "empty-json-obj", + jsonMsg: `{}`, + want: &Email{SenderMail: "icinga@example.com"}, + }, + { + name: "sender-mail-empty-val", + jsonMsg: `{"sender_mail": ""}`, + want: &Email{SenderMail: ""}, + }, + { + name: "example", + jsonMsg: `{"sender_name":"icinga","sender_mail":"icinga@example.com","host":"smtp.example.com","port":"25","encryption":"none"}`, + want: &Email{ + Host: "smtp.example.com", + Port: "25", + SenderName: "icinga", + SenderMail: "icinga@example.com", + User: "", + Password: "", + Encryption: "none", + }, + }, + { + name: "user-but-missing-pass", + jsonMsg: `{"user": "foo"}`, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &Email{} + err := email.SetConfig(json.RawMessage(tt.jsonMsg)) + if (err != nil) != tt.wantErr { + t.Errorf("SetConfig() error = %v, wantErr %v", err, tt.wantErr) + } else if tt.wantErr { + return + } + + if !reflect.DeepEqual(email, tt.want) { + t.Errorf("Email is\n\t%#v\n, expected\n\t%#v", email, tt.want) + } + }) + } +} diff --git a/cmd/channel/rocketchat/main.go b/cmd/channel/rocketchat/main.go index 143c5b9e0..dc3dff274 100644 --- a/cmd/channel/rocketchat/main.go +++ b/cmd/channel/rocketchat/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/creasty/defaults" "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/pkg/plugin" "net/http" @@ -77,50 +78,51 @@ func (ch *RocketChat) SendNotification(req *plugin.NotificationRequest) error { } func (ch *RocketChat) SetConfig(jsonStr json.RawMessage) error { + err := defaults.Set(ch) + if err != nil { + return err + } + return json.Unmarshal(jsonStr, ch) } func (ch *RocketChat) GetInfo() *plugin.Info { - - elements := []*plugin.ConfigOption{ - { - Name: "url", - Type: "string", - Label: map[string]string{ - "en_US": "Rocket.Chat URL", - "de_DE": "Rocket.Chat URL", + info, err := plugin.NewInfo( + &RocketChat{}, + "Rocket.Chat", + internal.Version.Version, + "Icinga GmbH", + []*plugin.ConfigOption{ + { + Name: "url", + Type: "string", + Label: map[string]string{ + "en_US": "Rocket.Chat URL", + "de_DE": "Rocket.Chat URL", + }, + Required: true, }, - Required: true, - }, - { - Name: "user_id", - Type: "string", - Label: map[string]string{ - "en_US": "User ID", - "de_DE": "Benutzer ID", + { + Name: "user_id", + Type: "string", + Label: map[string]string{ + "en_US": "User ID", + "de_DE": "Benutzer ID", + }, + Required: true, }, - Required: true, - }, - { - Name: "token", - Type: "secret", - Label: map[string]string{ - "en_US": "Personal Access Token", - "de_DE": "Persönliches Zugangstoken", + { + Name: "token", + Type: "secret", + Label: map[string]string{ + "en_US": "Personal Access Token", + "de_DE": "Persönliches Zugangstoken", + }, + Required: true, }, - Required: true, - }, - } - - configAttrs, err := json.Marshal(elements) + }) if err != nil { panic(err) } - - return &plugin.Info{ - Name: "Rocket.Chat", - Version: internal.Version.Version, - Author: "Icinga GmbH", - ConfigAttributes: configAttrs, - } + return info } diff --git a/cmd/channel/webhook/main.go b/cmd/channel/webhook/main.go index a5a24635e..731dbf461 100644 --- a/cmd/channel/webhook/main.go +++ b/cmd/channel/webhook/main.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/creasty/defaults" "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/pkg/plugin" "io" @@ -15,10 +16,10 @@ import ( ) type Webhook struct { - Method string `json:"method"` + Method string `json:"method" default:"POST"` URLTemplate string `json:"url_template"` - RequestBodyTemplate string `json:"request_body_template"` - ResponseStatusCodes string `json:"response_status_codes"` + RequestBodyTemplate string `json:"request_body_template" default:"{{json .}}"` + ResponseStatusCodes string `json:"response_status_codes" default:"200"` tmplUrl *template.Template tmplRequestBody *template.Template @@ -27,76 +28,75 @@ type Webhook struct { } func (ch *Webhook) GetInfo() *plugin.Info { - elements := []*plugin.ConfigOption{ - { - Name: "method", - Type: "string", - Label: map[string]string{ - "en_US": "HTTP Method", - "de_DE": "HTTP-Methode", + info, err := plugin.NewInfo( + &Webhook{}, + "Webhook", + internal.Version.Version, + "Icinga GmbH", + []*plugin.ConfigOption{ + { + Name: "method", + Type: "string", + Label: map[string]string{ + "en_US": "HTTP Method", + "de_DE": "HTTP-Methode", + }, + Help: map[string]string{ + "en_US": "HTTP request method used for the web request.", + "de_DE": "HTTP-Methode für die Anfrage.", + }, }, - Help: map[string]string{ - "en_US": "HTTP request method used for the web request.", - "de_DE": "HTTP-Methode für die Anfrage.", + { + Name: "url_template", + Type: "string", + Label: map[string]string{ + "en_US": "URL Template", + "de_DE": "URL-Template", + }, + Help: map[string]string{ + "en_US": "URL, optionally as a Go template over the current plugin.NotificationRequest.", + "de_DE": "URL, optional als Go-Template über das zu verarbeitende plugin.NotificationRequest.", + }, + Required: true, }, - Default: "POST", - }, - { - Name: "url_template", - Type: "string", - Label: map[string]string{ - "en_US": "URL Template", - "de_DE": "URL-Template", - }, - Help: map[string]string{ - "en_US": "URL, optionally as a Go template over the current plugin.NotificationRequest.", - "de_DE": "URL, optional als Go-Template über das zu verarbeitende plugin.NotificationRequest.", - }, - Required: true, - }, - { - Name: "request_body_template", - Type: "string", - Label: map[string]string{ - "en_US": "Request Body Template", - "de_DE": "Anfragedaten-Template", + { + Name: "request_body_template", + Type: "string", + Label: map[string]string{ + "en_US": "Request Body Template", + "de_DE": "Anfragedaten-Template", + }, + Help: map[string]string{ + "en_US": "Go template applied to the current plugin.NotificationRequest to create an request body.", + "de_DE": "Go-Template über das zu verarbeitende plugin.NotificationRequest zum Erzeugen der mitgesendeten Anfragedaten.", + }, }, - Help: map[string]string{ - "en_US": "Go template applied to the current plugin.NotificationRequest to create an request body.", - "de_DE": "Go-Template über das zu verarbeitende plugin.NotificationRequest zum Erzeugen der mitgesendeten Anfragedaten.", + { + Name: "response_status_codes", + Type: "string", + Label: map[string]string{ + "en_US": "Response Status Codes", + "de_DE": "Antwort-Status-Codes", + }, + Help: map[string]string{ + "en_US": "Comma separated list of expected HTTP response status code, e.g., 200,201,202,208,418", + "de_DE": "Kommaseparierte Liste erwarteter Status-Code der HTTP-Antwort, z.B.: 200,201,202,208,418", + }, }, - Default: `{{json .}}`, - }, - { - Name: "response_status_codes", - Type: "string", - Label: map[string]string{ - "en_US": "Response Status Codes", - "de_DE": "Antwort-Status-Codes", - }, - Help: map[string]string{ - "en_US": "Comma separated list of expected HTTP response status code, e.g., 200,201,202,208,418", - "de_DE": "Kommaseparierte Liste erwarteter Status-Code der HTTP-Antwort, z.B.: 200,201,202,208,418", - }, - Default: "200", - }, - } - - configAttrs, err := json.Marshal(elements) + }) if err != nil { panic(err) } - - return &plugin.Info{ - Name: "Webhook", - Version: internal.Version.Version, - Author: "Icinga GmbH", - ConfigAttributes: configAttrs, - } + return info } func (ch *Webhook) SetConfig(jsonStr json.RawMessage) error { - err := json.Unmarshal(jsonStr, ch) + err := defaults.Set(ch) + if err != nil { + return err + } + + err = json.Unmarshal(jsonStr, ch) if err != nil { return err } diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 9d3d1923a..584b3349d 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/creasty/defaults" "github.com/icinga/icinga-notifications/internal/event" "github.com/icinga/icinga-notifications/internal/utils" "github.com/icinga/icinga-notifications/pkg/rpc" @@ -11,6 +12,7 @@ import ( "io" "log" "os" + "reflect" "sync" "time" ) @@ -48,6 +50,8 @@ type ConfigOption struct { Help map[string]string `json:"help,omitempty"` // Element default: bool for checkbox default value, string for other elements (used as placeholder) + // + // The Default value will be set based on a Plugin struct field "default" tag by the NewInfo function. Default any `json:"default,omitempty"` // Set true if this element is required, omit otherwise @@ -78,6 +82,55 @@ type Info struct { ConfigAttributes json.RawMessage `db:"config_attrs" json:"config_attrs"` // ConfigOption(s) as json-encoded list } +// NewInfo constructs a new Info to be used in Plugin.GetInfo. +// +// The configOptions will be enriched with defaults from the typePtr, being a pointer to the Plugin implementation. +func NewInfo(typePtr interface{}, name, version, author string, configOptions []*ConfigOption) (*Info, error) { + err := defaults.Set(typePtr) + if err != nil { + return nil, err + } + + typeDefaults := make(map[string]any) + typeRefT := reflect.TypeOf(typePtr).Elem() + typeRefV := reflect.ValueOf(typePtr) + for i := 0; i < typeRefT.NumField(); i++ { + if !typeRefT.Field(i).IsExported() { + continue + } + + jsonVal, jsonOk := typeRefT.Field(i).Tag.Lookup("json") + _, defaultOk := typeRefT.Field(i).Tag.Lookup("default") + + if !jsonOk { + return nil, fmt.Errorf("exported field %q misses json struct tag, set to \"-\" to ignore", typeRefT.Field(i).Name) + } + if jsonVal == "-" || !defaultOk { + continue + } + + typeDefaults[jsonVal] = typeRefV.Elem().Field(i).Interface() + } + + for _, configOption := range configOptions { + if defaultVal, ok := typeDefaults[configOption.Name]; ok { + configOption.Default = defaultVal + } + } + + configAttributes, err := json.Marshal(configOptions) + if err != nil { + return nil, err + } + + return &Info{ + Name: name, + Version: version, + Author: author, + ConfigAttributes: configAttributes, + }, nil +} + // TableName implements the contracts.TableNamer interface. func (i *Info) TableName() string { return "available_channel_type" diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 000000000..3db8e5a25 --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -0,0 +1,85 @@ +package plugin + +import ( + "testing" +) + +func TestNewInfoConfigOptions(t *testing.T) { + type TestT0JsonInvalid struct { + Foo string + } + type testT1Simple struct { + Foo string `json:"foo"` + Bar string `json:"bar" default:"test123"` + } + type testT2SimpleInt struct { + Foo string `json:"foo"` + Bar int `json:"bar" default:"23"` + } + type testT3Unexported struct { + Foo string `json:"foo"` + bar string + } + type testT4Renamed struct { + Foo string `json:"fo0" default:"A"` + Bar int `json:"bAaAr" default:"23"` + } + + tests := []struct { + name string + typePtr interface{} + cfg []*ConfigOption + want string + wantErr bool + }{ + { + name: "invalid-struct-tags", + typePtr: &TestT0JsonInvalid{}, + cfg: []*ConfigOption{}, + wantErr: true, + }, + { + name: "simple", + typePtr: &testT1Simple{}, + cfg: []*ConfigOption{{Name: "foo", Type: "string"}, {Name: "bar", Type: "string"}}, + want: `[{"name":"foo","type":"string","label":null,"min":null,"max":null},{"name":"bar","type":"string","label":null,"default":"test123","min":null,"max":null}]`, + }, + { + name: "simple-int", + typePtr: &testT2SimpleInt{}, + cfg: []*ConfigOption{{Name: "foo", Type: "string"}, {Name: "bar", Type: "int"}}, + want: `[{"name":"foo","type":"string","label":null,"min":null,"max":null},{"name":"bar","type":"int","label":null,"default":23,"min":null,"max":null}]`, + }, + { + name: "unexported", + typePtr: &testT3Unexported{}, + cfg: []*ConfigOption{{Name: "foo", Type: "string"}}, + want: `[{"name":"foo","type":"string","label":null,"min":null,"max":null}]`, + }, + { + name: "renamed-key", + typePtr: &testT4Renamed{}, + cfg: []*ConfigOption{{Name: "fo0", Type: "string"}, {Name: "bAaAr", Type: "int"}}, + want: `[{"name":"fo0","type":"string","label":null,"default":"A","min":null,"max":null},{"name":"bAaAr","type":"int","label":null,"default":23,"min":null,"max":null}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := NewInfo(tt.typePtr, "__name", "__version", "__author", tt.cfg) + if (err != nil) != tt.wantErr { + t.Errorf("NewInfo() error = %v, wantErr %v", err, tt.wantErr) + return + } else if tt.wantErr { + return + } + + jsonMsg, err := info.ConfigAttributes.MarshalJSON() + if err != nil { + t.Errorf("Canont marshal JSON, %v", err) + } + if j := string(jsonMsg); j != tt.want { + t.Errorf("ConfigOptions JSON is\n\t%s\n, expected\n\t%s", j, tt.want) + } + }) + } +}