Skip to content

Commit

Permalink
Add bindings dingtalk webhook (#817)
Browse files Browse the repository at this point in the history
* Add bindings dingtalk-webhook

* Fix lint

* Fixing logger dependency

* Updated tests

* Tweaks to use the "settings pattern"

* Make webhooks global again

* Update sync.map to built-in map and RWMutex

* Fixing linter errors

Co-authored-by: Phil Kedy <[email protected]>
Co-authored-by: Yaron Schneider <[email protected]>
  • Loading branch information
3 people authored May 14, 2021
1 parent 2892d6a commit efd1980
Show file tree
Hide file tree
Showing 4 changed files with 383 additions and 0 deletions.
37 changes: 37 additions & 0 deletions bindings/alicloud/dingtalk/webhook/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------

// DingTalk webhook are a simple way to post messages from apps into DingTalk
//
// See https://developers.dingtalk.com/document/app/custom-robot-access for details

package webhook

import (
"errors"

"github.com/dapr/components-contrib/internal/config"
)

type Settings struct {
ID string `mapstructure:"id"`
URL string `mapstructure:"url"`
Secret string `mapstructure:"secret"`
}

func (s *Settings) Decode(in interface{}) error {
return config.Decode(in, s)
}

func (s *Settings) Validate() error {
if s.ID == "" {
return errors.New("webhook error: missing webhook id")
}
if s.URL == "" {
return errors.New("webhook error: missing webhook url")
}

return nil
}
28 changes: 28 additions & 0 deletions bindings/alicloud/dingtalk/webhook/settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------

package webhook

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSettingsDecode(t *testing.T) { //nolint:paralleltest
props := map[string]string{
"url": "a",
"secret": "b",
"id": "c",
}

var settings Settings
err := settings.Decode(props)
require.NoError(t, err)
assert.Equal(t, "a", settings.URL)
assert.Equal(t, "b", settings.Secret)
assert.Equal(t, "c", settings.ID)
}
222 changes: 222 additions & 0 deletions bindings/alicloud/dingtalk/webhook/webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------

package webhook

import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"strconv"
"sync"
"time"

"github.com/dapr/components-contrib/bindings"
"github.com/dapr/kit/logger"
)

const (
webhookContentType = "application/json"
defaultHTTPClientTimeout = time.Second * 30
)

type DingTalkWebhook struct {
logger logger.Logger
settings Settings
httpClient *http.Client
}

type webhookResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}

type outgoingWebhook struct {
handler func(*bindings.ReadResponse) ([]byte, error)
}

var webhooks = struct { // nolint: gochecknoglobals
sync.RWMutex
m map[string]*outgoingWebhook
}{m: make(map[string]*outgoingWebhook)}

func NewDingTalkWebhook(l logger.Logger) *DingTalkWebhook {
// See guidance on proper HTTP client settings here:
// https://medium.com/@nate510/don-t-use-go-s-default-http-client-4804cb19f779
dialer := &net.Dialer{ //nolint:exhaustivestruct
Timeout: 5 * time.Second,
}
var netTransport = &http.Transport{ //nolint:exhaustivestruct
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
}
httpClient := &http.Client{ //nolint:exhaustivestruct
Timeout: defaultHTTPClientTimeout,
Transport: netTransport,
}

return &DingTalkWebhook{ //nolint:exhaustivestruct
logger: l,
httpClient: httpClient,
}
}

// Init performs metadata parsing
func (t *DingTalkWebhook) Init(metadata bindings.Metadata) error {
var err error
if err = t.settings.Decode(metadata.Properties); err != nil {
return fmt.Errorf("dingtalk configuration error: %w", err)
}
if err = t.settings.Validate(); err != nil {
return fmt.Errorf("dingtalk configuration error: %w", err)
}

return nil
}

// Read triggers the outgoing webhook, not yet production ready
func (t *DingTalkWebhook) Read(handler func(*bindings.ReadResponse) ([]byte, error)) error {
t.logger.Debugf("dingtalk webhook: start read input binding")

webhooks.Lock()
defer webhooks.Unlock()
_, loaded := webhooks.m[t.settings.ID]
if loaded {
return fmt.Errorf("dingtalk webhook error: duplicate id %s", t.settings.ID)
}
webhooks.m[t.settings.ID] = &outgoingWebhook{handler: handler}

return nil
}

// Operations returns list of operations supported by dingtalk webhook binding
func (t *DingTalkWebhook) Operations() []bindings.OperationKind {
return []bindings.OperationKind{bindings.CreateOperation, bindings.GetOperation}
}

func (t *DingTalkWebhook) Invoke(req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
rst := &bindings.InvokeResponse{Metadata: map[string]string{}, Data: nil}
switch req.Operation {
case bindings.CreateOperation:
return rst, t.sendMessage(req)
case bindings.GetOperation:
return rst, t.receivedMessage(req)
case bindings.DeleteOperation, bindings.ListOperation:
return rst, fmt.Errorf("dingtalk webhook error: unsupported operation %s", req.Operation)
default:
return rst, fmt.Errorf("dingtalk webhook error: unsupported operation %s", req.Operation)
}
}

func (t *DingTalkWebhook) getOutgoingWebhook() (*outgoingWebhook, error) {
webhooks.RLock()
defer webhooks.RUnlock()
item, loaded := webhooks.m[t.settings.ID]
if !loaded {
return nil, fmt.Errorf("dingtalk webhook error: invalid component metadata.id %s", t.settings.ID)
}

return item, nil
}

func (t *DingTalkWebhook) receivedMessage(req *bindings.InvokeRequest) error {
item, err := t.getOutgoingWebhook()
if err != nil {
return err
}

in := &bindings.ReadResponse{Data: req.Data, Metadata: req.Metadata}
if _, err = item.handler(in); err != nil {
return err
}

return nil
}

func (t *DingTalkWebhook) sendMessage(req *bindings.InvokeRequest) error {
msg := req.Data

postURL, err := getPostURL(t.settings.URL, t.settings.Secret)
if err != nil {
return fmt.Errorf("dingtalk webhook error: get url failed. %w", err)
}

ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPClientTimeout)
defer cancel()

httpReq, err := http.NewRequestWithContext(ctx, "POST", postURL, bytes.NewReader(msg))
if err != nil {
return fmt.Errorf("dingtalk webhook error: new request failed. %w", err)
}

httpReq.Header.Add("Accept", webhookContentType)
httpReq.Header.Add("Content-Type", webhookContentType)

resp, err := t.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("dingtalk webhook error: post failed. %w", err)
}
defer func() {
_ = resp.Body.Close()
}()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("dingtalk webhook error: post failed. status:%d", resp.StatusCode)
}

data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("dingtalk webhook error: read body failed. %w", err)
}

var rst webhookResult
err = json.Unmarshal(data, &rst)
if err != nil {
return fmt.Errorf("dingtalk webhook error: unmarshal body failed. %w", err)
}

if rst.ErrCode != 0 {
return fmt.Errorf("dingtalk webhook error: send msg failed. %v", rst.ErrMsg)
}

return nil
}

func getPostURL(urlPath, secret string) (string, error) {
if secret == "" {
return urlPath, nil
}

timestamp := strconv.FormatInt(time.Now().Unix()*1000, 10)
sign, err := sign(secret, timestamp)
if err != nil {
return urlPath, err
}

query := url.Values{}
query.Set("timestamp", timestamp)
query.Set("sign", sign)

return urlPath + "&" + query.Encode(), nil
}

func sign(secret, timestamp string) (string, error) {
stringToSign := fmt.Sprintf("%s\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(secret))
if _, err := io.WriteString(h, stringToSign); err != nil {
return "", fmt.Errorf("sign failed. %w", err)
}

return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
96 changes: 96 additions & 0 deletions bindings/alicloud/dingtalk/webhook/webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------

package webhook

import (
"io/ioutil"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/dapr/components-contrib/bindings"
"github.com/dapr/kit/logger"
)

func TestPublishMsg(t *testing.T) { //nolint:paralleltest
msg := "{\"type\": \"text\",\"text\": {\"content\": \"hello\"}}"

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("{\"errcode\":0}"))
require.NoError(t, err)
if r.Method != "POST" {
t.Errorf("Expected 'POST' request, got '%s'", r.Method)
}
if r.URL.EscapedPath() != "/test" {
t.Errorf("Expected request to '/test', got '%s'", r.URL.EscapedPath())
}

body, err := ioutil.ReadAll(r.Body)
require.Nil(t, err)
assert.Equal(t, msg, string(body))
}))
defer ts.Close()

m := bindings.Metadata{Name: "test", Properties: map[string]string{
"url": ts.URL + "/test",
"secret": "",
"id": "x",
}}

d := NewDingTalkWebhook(logger.NewLogger("test"))
err := d.Init(m)
require.NoError(t, err)

req := &bindings.InvokeRequest{Data: []byte(msg), Operation: bindings.CreateOperation, Metadata: map[string]string{}}
_, err = d.Invoke(req)
require.NoError(t, err)
}

func TestBindingReadAndInvoke(t *testing.T) { //nolint:paralleltest
msg := "{\"type\": \"text\",\"text\": {\"content\": \"hello\"}}"

m := bindings.Metadata{Name: "test",
Properties: map[string]string{
"url": "/test",
"secret": "",
"id": "x",
}}

d := NewDingTalkWebhook(logger.NewLogger("test"))
err := d.Init(m)
assert.NoError(t, err)

var count int32
ch := make(chan bool, 1)

handler := func(in *bindings.ReadResponse) ([]byte, error) {
assert.Equal(t, msg, string(in.Data))
atomic.AddInt32(&count, 1)
ch <- true

return nil, nil
}

err = d.Read(handler)
require.NoError(t, err)

req := &bindings.InvokeRequest{Data: []byte(msg), Operation: bindings.GetOperation, Metadata: map[string]string{}}
_, err = d.Invoke(req)
require.NoError(t, err)

select {
case <-ch:
require.True(t, atomic.LoadInt32(&count) > 0)
case <-time.After(time.Second):
require.FailNow(t, "read timeout")
}
}

0 comments on commit efd1980

Please sign in to comment.