forked from containrrr/shoutrrr
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(containrrr#404): implement basic webex functionality to shoutrrr
- Loading branch information
Showing
4 changed files
with
330 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
package webex | ||
|
||
import ( | ||
"bytes" | ||
"encoding/json" | ||
"fmt" | ||
"net/http" | ||
"net/url" | ||
|
||
"github.com/containrrr/shoutrrr/pkg/format" | ||
"github.com/containrrr/shoutrrr/pkg/services/standard" | ||
"github.com/containrrr/shoutrrr/pkg/types" | ||
) | ||
|
||
// Service providing Webex as a notification service | ||
type Service struct { | ||
standard.Standard | ||
config *Config | ||
pkr format.PropKeyResolver | ||
} | ||
|
||
const ( | ||
MessagesEndpoint = "https://webexapis.com/v1/messages" | ||
) | ||
|
||
// MessagePayload is the message endpoint payload | ||
type MessagePayload struct { | ||
RoomID string `json:"roomId"` | ||
Markdown string `json:"markdown,omitempty"` | ||
} | ||
|
||
// Send a notification message to webex | ||
func (service *Service) Send(message string, params *types.Params) error { | ||
err := doSend(message, service.config) | ||
if err != nil { | ||
return fmt.Errorf("failed to send webex notification: %v", err) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// Initialize loads ServiceConfig from configURL and sets logger for this Service | ||
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { | ||
service.Logger.SetLogger(logger) | ||
service.config = &Config{} | ||
service.pkr = format.NewPropKeyResolver(service.config) | ||
|
||
if err := service.pkr.SetDefaultProps(service.config); err != nil { | ||
return err | ||
} | ||
|
||
if err := service.config.SetURL(configURL); err != nil { | ||
return err | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func doSend(message string, config *Config) error { | ||
req, err := BuildRequestFromPayloadAndConfig(message, config) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
res, err := http.DefaultClient.Do(req) | ||
|
||
if res == nil && err == nil { | ||
err = fmt.Errorf("unknown error") | ||
} | ||
|
||
if err == nil && res.StatusCode != http.StatusOK { | ||
err = fmt.Errorf("response status code %s", res.Status) | ||
} | ||
|
||
return err | ||
} | ||
|
||
func BuildRequestFromPayloadAndConfig(message string, config *Config) (*http.Request, error) { | ||
var err error | ||
payload := MessagePayload{ | ||
RoomID: config.RoomID, | ||
Markdown: message, | ||
} | ||
|
||
payloadBytes, err := json.Marshal(payload) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
req, err := http.NewRequest("POST", MessagesEndpoint, bytes.NewBuffer(payloadBytes)) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
req.Header.Add("Authorization", "Bearer "+config.BotToken) | ||
req.Header.Add("Content-Type", "application/json") | ||
|
||
return req, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
package webex | ||
|
||
import ( | ||
"errors" | ||
"net/url" | ||
|
||
"github.com/containrrr/shoutrrr/pkg/format" | ||
"github.com/containrrr/shoutrrr/pkg/services/standard" | ||
"github.com/containrrr/shoutrrr/pkg/types" | ||
) | ||
|
||
// Config is the configuration needed to send webex notifications | ||
type Config struct { | ||
standard.EnumlessConfig | ||
RoomID string `url:"host"` | ||
BotToken string `url:"user"` | ||
} | ||
|
||
// GetURL returns a URL representation of it's current field values | ||
func (config *Config) GetURL() *url.URL { | ||
resolver := format.NewPropKeyResolver(config) | ||
return config.getURL(&resolver) | ||
} | ||
|
||
// 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) | ||
} | ||
|
||
func (config *Config) getURL(resolver types.ConfigQueryResolver) (u *url.URL) { | ||
u = &url.URL{ | ||
User: url.User(config.BotToken), | ||
Host: config.RoomID, | ||
Scheme: Scheme, | ||
RawQuery: format.BuildQuery(resolver), | ||
ForceQuery: false, | ||
} | ||
|
||
return u | ||
} | ||
|
||
// SetURL updates a ServiceConfig from a URL representation of it's field values | ||
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { | ||
|
||
config.RoomID = url.Host | ||
config.BotToken = url.User.Username() | ||
|
||
if len(url.Path) > 0 { | ||
switch url.Path { | ||
// todo: implement markdown and card functionality separately | ||
default: | ||
return errors.New("illegal argument in config URL") | ||
} | ||
} | ||
|
||
if config.RoomID == "" { | ||
return errors.New("room ID missing from config URL") | ||
} | ||
|
||
if len(config.BotToken) < 1 { | ||
return errors.New("bot token missing from config URL") | ||
} | ||
|
||
for key, vals := range url.Query() { | ||
if err := resolver.Set(key, vals[0]); err != nil { | ||
return err | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// Scheme is the identifying part of this service's configuration URL | ||
const Scheme = "webex" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
package webex_test | ||
|
||
import ( | ||
"fmt" | ||
"log" | ||
|
||
"github.com/containrrr/shoutrrr/internal/testutils" | ||
. "github.com/containrrr/shoutrrr/pkg/services/webex" | ||
"github.com/containrrr/shoutrrr/pkg/types" | ||
"github.com/jarcoal/httpmock" | ||
|
||
"net/url" | ||
"os" | ||
"testing" | ||
|
||
. "github.com/onsi/ginkgo/v2" | ||
. "github.com/onsi/gomega" | ||
) | ||
|
||
func TestWebex(t *testing.T) { | ||
RegisterFailHandler(Fail) | ||
RunSpecs(t, "Shoutrrr Webex Suite") | ||
} | ||
|
||
var ( | ||
service *Service | ||
envWebexURL *url.URL | ||
logger *log.Logger | ||
_ = BeforeSuite(func() { | ||
service = &Service{} | ||
envWebexURL, _ = url.Parse(os.Getenv("SHOUTRRR_WEBEX_URL")) | ||
logger = log.New(GinkgoWriter, "Test", log.LstdFlags) | ||
}) | ||
) | ||
|
||
var _ = Describe("the webex service", func() { | ||
|
||
When("running integration tests", func() { | ||
It("should work without errors", func() { | ||
if envWebexURL.String() == "" { | ||
return | ||
} | ||
|
||
serviceURL, _ := url.Parse(envWebexURL.String()) | ||
err := service.Initialize(serviceURL, testutils.TestLogger()) | ||
Expect(err).NotTo(HaveOccurred()) | ||
|
||
err = service.Send( | ||
"this is an integration test", | ||
nil, | ||
) | ||
Expect(err).NotTo(HaveOccurred()) | ||
}) | ||
}) | ||
Describe("the service", func() { | ||
It("should implement Service interface", func() { | ||
var impl types.Service = service | ||
Expect(impl).ToNot(BeNil()) | ||
}) | ||
}) | ||
Describe("creating a config", func() { | ||
When("given an url and a message", func() { | ||
It("should return an error if no arguments where supplied", func() { | ||
serviceURL, _ := url.Parse("webex://") | ||
err := service.Initialize(serviceURL, nil) | ||
Expect(err).To(HaveOccurred()) | ||
}) | ||
It("should not return an error if exactly two arguments are given", func() { | ||
serviceURL, _ := url.Parse("webex://dummyToken@dummyRoom") | ||
err := service.Initialize(serviceURL, nil) | ||
Expect(err).NotTo(HaveOccurred()) | ||
}) | ||
It("should return an error if more than two arguments are given", func() { | ||
serviceURL, _ := url.Parse("webex://dummyToken@dummyRoom/illegal-argument") | ||
err := service.Initialize(serviceURL, nil) | ||
Expect(err).To(HaveOccurred()) | ||
}) | ||
}) | ||
When("parsing the configuration URL", func() { | ||
It("should be identical after de-/serialization", func() { | ||
testURL := "webex://token@room" | ||
|
||
url, err := url.Parse(testURL) | ||
Expect(err).NotTo(HaveOccurred(), "parsing") | ||
|
||
config := &Config{} | ||
err = config.SetURL(url) | ||
Expect(err).NotTo(HaveOccurred(), "verifying") | ||
|
||
outputURL := config.GetURL() | ||
|
||
Expect(outputURL.String()).To(Equal(testURL)) | ||
|
||
}) | ||
}) | ||
}) | ||
|
||
Describe("sending the payload", func() { | ||
var dummyConfig = Config{ | ||
RoomID: "1", | ||
BotToken: "dummyToken", | ||
} | ||
var service Service | ||
BeforeEach(func() { | ||
httpmock.Activate() | ||
service = Service{} | ||
if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil { | ||
panic(fmt.Errorf("service initialization failed: %w", err)) | ||
} | ||
}) | ||
AfterEach(func() { | ||
httpmock.DeactivateAndReset() | ||
}) | ||
It("should not report an error if the server accepts the payload", func() { | ||
setupResponder(&dummyConfig, 200, "") | ||
|
||
Expect(service.Send("Message", nil)).To(Succeed()) | ||
}) | ||
It("should report an error if the server response is not OK", func() { | ||
setupResponder(&dummyConfig, 400, "") | ||
Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed()) | ||
Expect(service.Send("Message", nil)).NotTo(Succeed()) | ||
}) | ||
It("should report an error if the message is empty", func() { | ||
setupResponder(&dummyConfig, 400, "") | ||
Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed()) | ||
Expect(service.Send("", nil)).NotTo(Succeed()) | ||
}) | ||
}) | ||
Describe("doing request", func() { | ||
dummyConfig := &Config{ | ||
BotToken: "dummyToken", | ||
} | ||
|
||
It("should add authorization header", func() { | ||
request, err := BuildRequestFromPayloadAndConfig("", dummyConfig) | ||
|
||
Expect(err).To(BeNil()) | ||
Expect(request.Header.Get("Authorization")).To(Equal("Bearer dummyToken")) | ||
}) | ||
|
||
// webex API rejects messages which do not define Content-Type | ||
It("should add content type header", func() { | ||
request, err := BuildRequestFromPayloadAndConfig("", dummyConfig) | ||
|
||
Expect(err).To(BeNil()) | ||
Expect(request.Header.Get("Content-Type")).To(Equal("application/json")) | ||
}) | ||
}) | ||
}) | ||
|
||
func setupResponder(config *Config, code int, body string) { | ||
httpmock.RegisterResponder("POST", MessagesEndpoint, httpmock.NewStringResponder(code, body)) | ||
} |