Skip to content

Commit

Permalink
feat: send slack notifications to authorized workspaces (#37)
Browse files Browse the repository at this point in the history
* feat: setup api to send slack notification

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>

* feat: send notifications to users and joined public/private channels

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>

* test: fix failing tests

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>

* test: refactor and test slack notifier client

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>

* test: refactor and test slack notifier service

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>

* test: slack notifier handler

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>

* test: fetching access token for a workspace

* docs: add swagger schema

* feat: add request payload validation in notifications api handler

* refactor: move validation into domain

Co-authored-by: Abhishek <abhi.sah.97@gmail.com>

* refactor: custom validation using oneof tag
Co-authored-by: Abhishek <abhi.sah.97@gmail.com>

* refactor: use custom error

Co-authored-by: Abhishek <abhi.sah.97@gmail.com>

Co-authored-by: Praveen Yadav <pyadav9678@gmail.com>
  • Loading branch information
Abhishek Sah and pyadav authored Aug 5, 2021
1 parent e5ce5cc commit 1bc391b
Show file tree
Hide file tree
Showing 26 changed files with 1,407 additions and 17 deletions.
64 changes: 64 additions & 0 deletions api/handlers/notifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package handlers

import (
"encoding/json"
"github.com/odpf/siren/domain"
"github.com/odpf/siren/pkg/slacknotifier"
"github.com/pkg/errors"
"go.uber.org/zap"
"gopkg.in/go-playground/validator.v9"
"net/http"
)

// Notify handler
func Notify(notifierServices domain.NotifierServices, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
switch provider {
case "slack":
var payload domain.SlackMessage
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
badRequest(w, err, logger)
return
}

err = payload.Validate()
if err != nil {
var e *validator.InvalidValidationError
if errors.As(err, &e) {
logger.Error("invalid validation error")
internalServerError(w, err, logger)
return
}
badRequest(w, err, logger)
return
}
result, err := notifierServices.Slack.Notify(&payload)
if err != nil {
if isBadRequest(err) {
badRequest(w, err, logger)
return
}
internalServerError(w, err, logger)
return
}
returnJSON(w, result)
return
case "":
badRequest(w, errors.New("provider not given in query params"), logger)
return
default:
badRequest(w, errors.New("unrecognized provider"), logger)
return
}
}
}

func isBadRequest(err error) bool {

var noChannelFoundError *slacknotifier.NoChannelFoundErr
var userLookupByEmailErr *slacknotifier.UserLookupByEmailErr

return errors.As(err, &noChannelFoundError) || errors.As(err, &userLookupByEmailErr)
}
185 changes: 185 additions & 0 deletions api/handlers/notifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package handlers_test

import (
"bytes"
"encoding/json"
"errors"
"github.com/odpf/siren/api/handlers"
"github.com/odpf/siren/domain"
"github.com/odpf/siren/mocks"
"github.com/odpf/siren/pkg/slacknotifier"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)

func TestNotifier_Notify(t *testing.T) {
t.Run("should return 200 OK on success", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
dummyMessage := domain.SlackMessage{ReceiverName: "foo",
ReceiverType: "user",
Message: "some text",
Entity: "odpf"}

payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`)
expectedResponse := domain.SlackMessageSendResponse{
OK: true,
}
mockedSlackNotifierService.On("Notify", &dummyMessage).Return(&expectedResponse, nil).Once()
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusOK
response, _ := json.Marshal(expectedResponse)
expectedStringBody := string(response) + "\n"

handler.ServeHTTP(w, r)

assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 500 Internal server error on failure", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
dummyMessage := domain.SlackMessage{ReceiverName: "foo",
ReceiverType: "user",
Message: "some text",
Entity: "odpf"}

payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`)
mockedSlackNotifierService.On("Notify", &dummyMessage).Return(nil, errors.New("random error")).Once()
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusInternalServerError
expectedStringBody := `{"code":500,"message":"Internal server error","data":null}`
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 400 Bad request if app not part of channel", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
dummyMessage := domain.SlackMessage{ReceiverName: "test",
ReceiverType: "channel",
Message: "some text",
Entity: "odpf"}
expectedError := &slacknotifier.NoChannelFoundErr{
Err: errors.New("app is not part of test"),
}
payload := []byte(`{"receiver_name": "test","receiver_type": "channel","entity": "odpf","message": "some text"}`)
mockedSlackNotifierService.On("Notify", &dummyMessage).Return(nil, expectedError).Once()
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusBadRequest
expectedStringBody := `{"code":400,"message":"app is not part of test","data":null}`
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 400 Bad request if user not found", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
dummyMessage := domain.SlackMessage{ReceiverName: "foo",
ReceiverType: "user",
Message: "some text",
Entity: "odpf"}
expectedError := &slacknotifier.UserLookupByEmailErr{
Err: errors.New("failed to get id for foo"),
}
payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`)
mockedSlackNotifierService.On("Notify", &dummyMessage).Return(nil, expectedError).Once()
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusBadRequest
expectedStringBody := `{"code":400,"message":"failed to get id for foo","data":null}`
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 400 Bad request if no provider specified", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`)
r, err := http.NewRequest(http.MethodPost, "/notifications", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusBadRequest
expectedStringBody := `{"code":400,"message":"provider not given in query params","data":null}`
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 400 Bad request if unknown provider specified", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
payload := []byte(`{"receiver_name": "foo","receiver_type": "user","entity": "odpf","message": "some text"}`)
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=email", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusBadRequest
expectedStringBody := `{"code":400,"message":"unrecognized provider","data":null}`
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 400 Bad request for invalid payload", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
payload := []byte(`abcd`)
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusBadRequest
expectedStringBody := `{"code":400,"message":"invalid character 'a' looking for beginning of value","data":null}`
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
assert.Equal(t, expectedStringBody, w.Body.String())
})

t.Run("should return 400 Bad request if payload validation", func(t *testing.T) {
mockedSlackNotifierService := &mocks.SlackNotifierService{}
notifierServices := domain.NotifierServices{Slack: mockedSlackNotifierService}
payload := []byte(`{"receiver_name": "","receiver_type": "","entity": "","message": ""}`)
r, err := http.NewRequest(http.MethodPost, "/notifications?provider=slack", bytes.NewBuffer(payload))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := handlers.Notify(notifierServices, getPanicLogger())
expectedStatusCode := http.StatusBadRequest
handler.ServeHTTP(w, r)
assert.Equal(t, expectedStatusCode, w.Code)
})
}
47 changes: 47 additions & 0 deletions api/handlers/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,29 @@ definitions:
x-go-name: Webhook
type: object
x-go-package: github.com/odpf/siren/domain
SlackMessage:
properties:
entity:
type: string
x-go-name: Entity
message:
type: string
x-go-name: Message
receiver_name:
type: string
x-go-name: ReceiverName
receiver_type:
type: string
x-go-name: ReceiverType
type: object
x-go-package: github.com/odpf/siren/domain
SlackMessageSendResponse:
properties:
ok:
type: boolean
x-go-name: OK
type: object
x-go-package: github.com/odpf/siren/domain
Template:
properties:
body:
Expand Down Expand Up @@ -272,6 +295,26 @@ paths:
type: array
tags:
- alertHistory
/notifications:
post:
description: |-
POST Notifications API
This API sends notifications to configured channel
operationId: postNotificationsRequest
parameters:
- in: query
name: provider
type: string
x-go-name: Provider
- in: body
name: Body
schema:
$ref: '#/definitions/SlackMessage'
responses:
"200":
$ref: '#/responses/postResponse'
tags:
- notifications
/oauth/slack/token:
post:
description: |-
Expand Down Expand Up @@ -489,6 +532,10 @@ responses:
type: array
pingResponse:
description: Response body for Ping.
postNotificationsResponse:
description: POST notifications response
schema:
$ref: '#/definitions/SlackMessageSendResponse'
postResponse:
description: POST codeExchange response
schema:
Expand Down
1 change: 1 addition & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func New(container *service.Container, nr *newrelic.Application, logger *zap.Log
r.Methods("GET").Path("/history").Handler(handlers.GetAlertHistory(container.AlertHistoryService, logger))

r.Methods("POST").Path("/oauth/slack/token").Handler(handlers.ExchangeCode(container.CodeExchangeService, logger))
r.Methods("POST").Path("/notifications").Handler(handlers.Notify(container.NotifierServices, logger))

// Handle middlewares for NotFoundHandler and MethodNotAllowedHandler since Mux doesn't apply middlewares to them. Ref: https://github.com/gorilla/mux/issues/416
_, r.NotFoundHandler = newrelic.WrapHandle(nr, "NotFoundHandler", applyMiddlewaresToHandler(zapMiddlewares, http.NotFoundHandler()))
Expand Down
26 changes: 26 additions & 0 deletions docs/notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package docs

import "github.com/odpf/siren/domain"

//-------------------------
//-------------------------
// swagger:route POST /notifications notifications postNotificationsRequest
// POST Notifications API
// This API sends notifications to configured channel
// responses:
// 200: postResponse

// swagger:parameters postNotificationsRequest
type postNotificationsRequest struct {
// in:query
Provider string `json:"provider"`
// in:body
Body domain.SlackMessage
}

// POST notifications response
// swagger:response postNotificationsResponse
type postNotificationsResponse struct {
// in:body
Body domain.SlackMessageSendResponse
}
1 change: 1 addition & 0 deletions domain/codeexchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ type OAuthExchangeResponse struct {

type CodeExchangeService interface {
Exchange(payload OAuthPayload) (*OAuthExchangeResponse, error)
GetToken(string) (string, error)
Migrate() error
}
29 changes: 29 additions & 0 deletions domain/notifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package domain

import (
"gopkg.in/go-playground/validator.v9"
)

type NotifierServices struct {
Slack SlackNotifierService
}

type SlackMessageSendResponse struct {
OK bool `json:"ok"`
}

type SlackNotifierService interface {
Notify(*SlackMessage) (*SlackMessageSendResponse, error)
}

type SlackMessage struct {
ReceiverName string `json:"receiver_name" validate:"required"`
ReceiverType string `json:"receiver_type" validate:"required,oneof=user channel"`
Entity string `json:"entity" validate:"required"`
Message string `json:"message" validate:"required"`
}

func (sm *SlackMessage) Validate() error {
v := validator.New()
return v.Struct(sm)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
github.com/prometheus/alertmanager v0.21.1-0.20200911160112-1fdff6b3f939
github.com/prometheus/prometheus v1.8.2-0.20201014093524-73e2ce1bd643
github.com/purini-to/zapmw v1.1.0
github.com/slack-go/slack v0.9.3
github.com/spf13/afero v1.4.1 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/cobra v1.1.3
Expand Down
Loading

0 comments on commit 1bc391b

Please sign in to comment.