From 6e1878c230a9cf45b01e9647e427192a941c2dd4 Mon Sep 17 00:00:00 2001 From: Shivalinga Reddy <55144604+shivuslr41@users.noreply.github.com> Date: Wed, 21 Jun 2023 21:08:56 +0530 Subject: [PATCH] feat(service): Adds Mattermost service (#516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(service): Adds Mattermost service * refactor: Removes log msgs fix: Adds PreSend and PostSend capabilities to user docs: Updates doc.go and README.md * refactor: Adds test cases Adds presend and postsend tests for mattermost service * refactor: Updates mattermost test cases * refactor: update mattermost test cases * refactor: update mattermost hook(s) test cases --------- Co-authored-by: EthanEFung Co-authored-by: Niko Köser --- service/http/README.md | 1 + service/http/doc.go | 1 + service/mattermost/README.md | 80 +++++++++++++ service/mattermost/doc.go | 67 +++++++++++ service/mattermost/mattermost.go | 155 +++++++++++++++++++++++++ service/mattermost/mattermost_test.go | 144 +++++++++++++++++++++++ service/mattermost/mock_http_client.go | 66 +++++++++++ 7 files changed, 514 insertions(+) create mode 100644 service/mattermost/README.md create mode 100644 service/mattermost/doc.go create mode 100644 service/mattermost/mattermost.go create mode 100644 service/mattermost/mattermost_test.go create mode 100644 service/mattermost/mock_http_client.go diff --git a/service/http/README.md b/service/http/README.md index 1f8d81d3..268acbc0 100644 --- a/service/http/README.md +++ b/service/http/README.md @@ -42,6 +42,7 @@ func main() { // from the given subject and message. httpService.AddReceivers(&http.Webhook{ URL: "http://localhost:8080", + Header: stdhttp.Header{}, ContentType: "text/plain", Method: stdhttp.MethodPost, BuildPayload: func(subject, message string) (payload any) { diff --git a/service/http/doc.go b/service/http/doc.go index 4aeae38a..cc485708 100644 --- a/service/http/doc.go +++ b/service/http/doc.go @@ -40,6 +40,7 @@ Usage: // from the given subject and message. httpService.AddReceivers(&http.Webhook{ URL: "http://localhost:8080", + Header: stdhttp.Header{}, ContentType: "text/plain", Method: stdhttp.MethodPost, BuildPayload: func(subject, message string) (payload any) { diff --git a/service/mattermost/README.md b/service/mattermost/README.md new file mode 100644 index 00000000..16fd0fda --- /dev/null +++ b/service/mattermost/README.md @@ -0,0 +1,80 @@ +# Mattermost Usage + +Ensure that you have already navigated to your GOPATH and installed the following packages: + +* `go get -u github.com/nikoksr/notify` + +## Steps for Mattermost Server + +These are general and very high level instructions + +1. Create a new Mattermost server / Join existing Mattermost server +2. Make sure your Username/loginID have the OAuth permission scope(s): `create_post` +3. Copy the *Channel ID* of the channel you want to post a message to. You can grab the *Channel ID* in channel info. example: *yfgstwuisnshydhd* +4. Now you should be good to use the code below + +## Sample Code + +```go +package main + +import ( + "os" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/mattermost" +) + +func main() { + + // Init notifier + notifier := notify.New() + ctx := context.Background() + + // Provide your Mattermost server url + mattermostService := mattermost.New("https://myserver.cloud.mattermost.com") + + // Provide username as loginID and password to login into above server. + // NOTE: This generates auth token which will get expired, invoking this method again + // after expiry will generate new token and uses for further requests. + err := mattermostService.LoginWithCredentials(ctx, "someone@gmail.com", "somepassword") + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Passing a Mattermost channel/chat id as receiver for our messages. + // Where to send our messages. + mattermostService.AddReceivers("CHANNEL_ID") + + // Tell our notifier to use the Mattermost service. You can repeat the above process + // for as many services as you like and just tell the notifier to use them. + notifier.UseServices(mattermostService) + + // Add presend and postsend hooks that you need to execute before every requests and after + // every response respectively. Multiple presend and postsend are executed in the order defined here. + // refer service/http for the more info. + // PreSend hook + mattermostService.PreSend(func(req *stdhttp.Request) error { + log.Printf("Sending message to %s server", req.URL) + return nil + }) + // PostSend hook + mattermostService.PostSend(func(req *stdhttp.Request, resp *stdhttp.Response) error { + log.Printf("Message sent to %s server with status %d", req.URL, resp.StatusCode) + return nil + }) + + // Send a message + err = notifier.Send( + ctx, + "Hello from notify :wave:\n", + "Message written in Go!", + ) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + +} +``` diff --git a/service/mattermost/doc.go b/service/mattermost/doc.go new file mode 100644 index 00000000..ce028ff4 --- /dev/null +++ b/service/mattermost/doc.go @@ -0,0 +1,67 @@ +/* +Package mattermost provides message notification integration for mattermost.com. + +Usage: + + package main + + import ( + "os" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/mattermost" + ) + + func main() { + + // Init notifier + notifier := notify.New() + ctx := context.Background() + + // Provide your Mattermost server url + mattermostService := mattermost.New("https://myserver.cloud.mattermost.com") + + // Provide username as loginID and password to login into above server. + // NOTE: This generates auth token which will get expired, invoking this method again + // after expiry will generate new token and uses for further requests. + err := mattermostService.LoginWithCredentials(ctx, "someone@gmail.com", "somepassword") + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Passing a Mattermost channel/chat id as receiver for our messages. + // Where to send our messages. + mattermostService.AddReceivers("CHANNEL_ID") + + // Tell our notifier to use the Mattermost service. You can repeat the above process + // for as many services as you like and just tell the notifier to use them. + notifier.UseServices(mattermostService) + + // Add presend and postsend hooks that you need to execute before every requests and after + // every response respectively. Multiple presend and postsend are executed in the order defined here. + // refer service/http for the more info. + // PreSend hook + mattermostService.PreSend(func(req *stdhttp.Request) error { + log.Printf("Sending message to %s server", req.URL) + return nil + }) + // PostSend hook + mattermostService.PostSend(func(req *stdhttp.Request, resp *stdhttp.Response) error { + log.Printf("Message sent to %s server with status %d", req.URL, resp.StatusCode) + return nil + }) + + // Send a message + err = notifier.Send( + ctx, + "Hello from notify :wave:", + "Message written in Go!", + ) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + } +*/ +package mattermost diff --git a/service/mattermost/mattermost.go b/service/mattermost/mattermost.go new file mode 100644 index 00000000..a0bf4182 --- /dev/null +++ b/service/mattermost/mattermost.go @@ -0,0 +1,155 @@ +// Package mattermost provides message notification integration for mattermost.com. +package mattermost + +import ( + "context" + "io" + stdhttp "net/http" + + "github.com/pkg/errors" + + "github.com/nikoksr/notify/service/http" +) + +//go:generate mockery --name=httpClient --output=. --case=underscore --inpackage +type httpClient interface { + AddReceivers(wh ...*http.Webhook) + PreSend(prefn http.PreSendHookFn) + Send(ctx context.Context, subject, message string) error + PostSend(postfn http.PostSendHookFn) +} + +// Service encapsulates the notify httpService client and contains mattermost channel ids. +type Service struct { + loginClient httpClient + messageClient httpClient + channelIDs map[string]bool +} + +// New returns a new instance of a Mattermost notification service. +func New(url string) *Service { + httpService := setupMsgService(url) + return &Service{ + setupLoginService(url, httpService), + httpService, + make(map[string]bool), + } +} + +// LoginWithCredentials provides helper for authentication using Mattermost user/admin credentials. +func (s *Service) LoginWithCredentials(ctx context.Context, loginID, password string) error { + // request login + if err := s.loginClient.Send(ctx, loginID, password); err != nil { + return errors.Wrapf(err, "failed login to Mattermost server") + } + return nil +} + +// AddReceivers takes Mattermost channel IDs or Chat IDs and adds them to the internal channel ID list. +// The Send method will send a given message to all these channels. +func (s *Service) AddReceivers(channelIDs ...string) { + for i := range channelIDs { + s.channelIDs[channelIDs[i]] = true + } +} + +// Send takes a message subject and a message body and send them to added channel ids. +// you will need a 'create_post' permission for your username. +// refer https://api.mattermost.com/ for more info +func (s *Service) Send(ctx context.Context, subject, message string) error { + for id := range s.channelIDs { + select { + case <-ctx.Done(): + return ctx.Err() + default: + // create post + if err := s.messageClient.Send(ctx, id, subject+"\n"+message); err != nil { + return errors.Wrapf(err, "failed to send message") + } + } + } + return nil +} + +// PreSend adds a pre-send hook to the service. The hook will be executed before sending a request to a receiver. +func (s *Service) PreSend(hook http.PreSendHookFn) { + s.messageClient.PreSend(hook) +} + +// PostSend adds a post-send hook to the service. The hook will be executed after sending a request to a receiver. +func (s *Service) PostSend(hook http.PostSendHookFn) { + s.messageClient.PostSend(hook) +} + +// setups main message service for creating posts +func setupMsgService(url string) *http.Service { + // create new http client for sending messages/notifications + httpService := http.New() + + // add custom payload builder + httpService.AddReceivers(&http.Webhook{ + URL: url + "/api/v4/posts", + Header: stdhttp.Header{}, + ContentType: "application/json", + Method: stdhttp.MethodPost, + BuildPayload: func(channelID, subjectAndMessage string) (payload any) { + return map[string]string{ + "channel_id": channelID, + "message": subjectAndMessage, + } + }, + }) + + // add post-send hook for error checks + httpService.PostSend(func(req *stdhttp.Request, resp *stdhttp.Response) error { + if resp.StatusCode != stdhttp.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return errors.New("failed to create post with status: " + resp.Status + " body: " + string(b)) + } + return nil + }) + return httpService +} + +// setups login service to get token +func setupLoginService(url string, msgService *http.Service) *http.Service { + // create another new http client for login request call. + httpService := http.New() + + // append login path for the given mattermost server with custom payload builder. + httpService.AddReceivers(&http.Webhook{ + URL: url + "/api/v4/users/login", + Header: stdhttp.Header{}, + ContentType: "application/json", + Method: stdhttp.MethodPost, + BuildPayload: func(loginID, password string) (payload any) { + return map[string]string{ + "login_id": loginID, + "password": password, + } + }, + }) + + // Add post-send hook to do error checks and log the response after it is received. + // Also extract token from response header and set it as part of pre-send hook of main http client for further requests. + httpService.PostSend(func(req *stdhttp.Request, resp *stdhttp.Response) error { + if resp.StatusCode != stdhttp.StatusOK { + b, _ := io.ReadAll(resp.Body) + return errors.New("login failed with status: " + resp.Status + " body: " + string(b)) + } + + // get token from header + token := resp.Header.Get("Token") + if token == "" { + return errors.New("received empty token") + } + + // set token as pre-send hook + msgService.PreSend(func(req *stdhttp.Request) error { + req.Header.Set("Authorization", "Bearer "+token) + return nil + }) + return nil + }) + return httpService +} diff --git a/service/mattermost/mattermost_test.go b/service/mattermost/mattermost_test.go new file mode 100644 index 00000000..2e26d3aa --- /dev/null +++ b/service/mattermost/mattermost_test.go @@ -0,0 +1,144 @@ +package mattermost + +import ( + "context" + "errors" + "log" + "net/http" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const url = "https://host.mattermost.com" + +func TestService_New(t *testing.T) { + t.Parallel() + assert := require.New(t) + service := New(url) + assert.NotNil(service) +} + +func TestService_LoginWithCredentials(t *testing.T) { + t.Parallel() + assert := require.New(t) + service := New(url) + // Test responses + mockClient := newMockHttpClient(t) + mockClient. + On("Send", context.TODO(), "fake-loginID", "fake-password").Return(nil) + service.loginClient = mockClient + // test call + err := service.LoginWithCredentials(context.TODO(), "fake-loginID", "fake-password") + assert.Nil(err) + mockClient.AssertExpectations(t) + + // Test errors + // Test responses + mockClient = newMockHttpClient(t) + mockClient. + On("Send", context.TODO(), "fake-loginID", "").Return(errors.New("empty password")) + service.loginClient = mockClient + // test call + err = service.LoginWithCredentials(context.TODO(), "fake-loginID", "") + assert.NotNil(err) + mockClient.AssertExpectations(t) +} + +func TestService_AddReceivers(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + service := New(url) + assert.NotNil(service) + + service.AddReceivers("yfgstwuisnshydhd") + assert.Equal(1, len(service.channelIDs)) + + service.AddReceivers("yfgstwuisnshydhd", "nwudneyfrwqjs") + assert.Equal(2, len(service.channelIDs)) + + hooks := []string{"yfgstwuisnshydhd", "nwudneyfrwqjs", "abcjudiekslkj"} + // prepare expected map + hooksMap := make(map[string]bool) + for i := range hooks { + hooksMap[hooks[i]] = true + } + service.AddReceivers(hooks...) + assert.Equal(3, len(service.channelIDs)) + assert.Equal(service.channelIDs, hooksMap) +} + +func TestService_Send(t *testing.T) { + t.Parallel() + assert := require.New(t) + + service := New(url) + channelID := "yfgstwuisnshydhd" + service.channelIDs[channelID] = true + + // Test responses + mockClient := newMockHttpClient(t) + mockClient. + On("Send", context.TODO(), channelID, "fake-sub\nfake-msg").Return(nil) + service.messageClient = mockClient + // test call + err := service.Send(context.TODO(), "fake-sub", "fake-msg") + assert.Nil(err) + mockClient.AssertExpectations(t) + + // Test error on Send + // Test responses + mockClient = newMockHttpClient(t) + mockClient. + On("Send", context.TODO(), channelID, "fake-sub\nfake-msg").Return(errors.New("internal error")) + service.messageClient = mockClient + // test call + err = service.Send(context.TODO(), "fake-sub", "fake-msg") + assert.NotNil(err) + mockClient.AssertExpectations(t) +} + +func TestService_PreSend(t *testing.T) { + t.Parallel() + assert := require.New(t) + + service := New(url) + assert.NotNil(service) + + // Test responses + mockClient := newMockHttpClient(t) + mockClient.On("PreSend", mock.AnythingOfType("http.PreSendHookFn")) + service.messageClient = mockClient + // test call + service.PreSend(func(req *http.Request) error { + log.Println("sending notification") + return nil + }) + // check if mockClient PreSend hook is called + assert.True(mockClient.AssertCalled(t, "PreSend", mock.AnythingOfType("http.PreSendHookFn"))) + mockClient.AssertExpectations(t) +} + +func TestService_PostSend(t *testing.T) { + t.Parallel() + assert := require.New(t) + + service := New(url) + assert.NotNil(service) + + // Test responses + mockClient := newMockHttpClient(t) + mockClient.On("PostSend", mock.AnythingOfType("http.PostSendHookFn")) + service.messageClient = mockClient + // test call + service.PostSend(func(req *http.Request, resp *http.Response) error { + log.Println("sent notification") + return nil + }) + // check if mockClient PostSend hook is called + assert.True(mockClient.AssertCalled(t, "PostSend", mock.AnythingOfType("http.PostSendHookFn"))) + mockClient.AssertExpectations(t) +} diff --git a/service/mattermost/mock_http_client.go b/service/mattermost/mock_http_client.go new file mode 100644 index 00000000..b8ef06fa --- /dev/null +++ b/service/mattermost/mock_http_client.go @@ -0,0 +1,66 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mattermost + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + http "github.com/nikoksr/notify/service/http" +) + +// mockHttpClient is an autogenerated mock type for the httpClient type +type mockHttpClient struct { + mock.Mock +} + +// AddReceivers provides a mock function with given fields: wh +func (_m *mockHttpClient) AddReceivers(wh ...*http.Webhook) { + _va := make([]interface{}, len(wh)) + for _i := range wh { + _va[_i] = wh[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + _m.Called(_ca...) +} + +// PostSend provides a mock function with given fields: postfn +func (_m *mockHttpClient) PostSend(postfn http.PostSendHookFn) { + _m.Called(postfn) +} + +// PreSend provides a mock function with given fields: prefn +func (_m *mockHttpClient) PreSend(prefn http.PreSendHookFn) { + _m.Called(prefn) +} + +// Send provides a mock function with given fields: ctx, subject, message +func (_m *mockHttpClient) Send(ctx context.Context, subject string, message string) error { + ret := _m.Called(ctx, subject, message) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, subject, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTnewMockHttpClient interface { + mock.TestingT + Cleanup(func()) +} + +// newMockHttpClient creates a new instance of mockHttpClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func newMockHttpClient(t mockConstructorTestingTnewMockHttpClient) *mockHttpClient { + mock := &mockHttpClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}