Skip to content

Commit

Permalink
feat(containrrr#404): implement basic webex functionality to shoutrrr
Browse files Browse the repository at this point in the history
  • Loading branch information
surdaft committed Nov 2, 2023
1 parent 52149dc commit ac1fc94
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 0 deletions.
2 changes: 2 additions & 0 deletions pkg/router/servicemap.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/containrrr/shoutrrr/pkg/services/smtp"
"github.com/containrrr/shoutrrr/pkg/services/teams"
"github.com/containrrr/shoutrrr/pkg/services/telegram"
"github.com/containrrr/shoutrrr/pkg/services/webex"
"github.com/containrrr/shoutrrr/pkg/services/zulip"
t "github.com/containrrr/shoutrrr/pkg/types"
)
Expand All @@ -45,5 +46,6 @@ var serviceMap = map[string]func() t.Service{
"smtp": func() t.Service { return &smtp.Service{} },
"teams": func() t.Service { return &teams.Service{} },
"telegram": func() t.Service { return &telegram.Service{} },
"webex": func() t.Service { return &webex.Service{} },
"zulip": func() t.Service { return &zulip.Service{} },
}
99 changes: 99 additions & 0 deletions pkg/services/webex/webex.go
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
}
75 changes: 75 additions & 0 deletions pkg/services/webex/webex_config.go
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"
154 changes: 154 additions & 0 deletions pkg/services/webex/webex_test.go
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))
}

0 comments on commit ac1fc94

Please sign in to comment.