Repository: dapr/components-contrib
Pull request #386 (2020-07-08 00:00:59)

Add scheduler binding #226
Addresses schedule binding #226 issue 

* [x] Code compiles correctly
* [x] Created/updated tests
* [x] Extended the [docs](https://github.com/dapr/docs/pull/672)



Patch:
diff --git a/bindings/cron/cron.go b/bindings/cron/cron.go
new file mode 100644
index 00000000..bea1303d
--- /dev/null
+++ b/bindings/cron/cron.go
@@ -0,0 +1,100 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+// ------------------------------------------------------------
+
+package twitter
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/dapr/components-contrib/bindings"
+	"github.com/dapr/dapr/pkg/logger"
+	"github.com/pkg/errors"
+	"github.com/robfig/cron/v3"
+)
+
+// Binding represents Cron input binding
+type Binding struct {
+	logger   logger.Logger
+	schedule string
+	stopCh   chan bool
+	parser   cron.Parser
+}
+
+var _ = bindings.InputBinding(&Binding{})
+
+// NewCron returns a new Cron event input binding
+func NewCron(logger logger.Logger) *Binding {
+	return &Binding{
+		logger: logger,
+		stopCh: make(chan bool),
+		parser: cron.NewParser(
+			cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
+		),
+	}
+}
+
+// Init initializes the Cron binding
+// Examples from https://godoc.org/github.com/robfig/cron:
+//   "15 * * * * *" - Every 15 sec
+//   "0 30 * * * *" - Every 30 min
+func (b *Binding) Init(metadata bindings.Metadata) error {
+	s, f := metadata.Properties["schedule"]
+	if !f || s == "" {
+		return fmt.Errorf("schedule not set")
+	}
+	_, err := b.parser.Parse(s)
+	if err != nil {
+		return errors.Wrapf(err, "invalid schedule format: %s", s)
+	}
+	b.schedule = s
+	return nil
+}
+
+// Read triggers the Cron scheduler
+func (b *Binding) Read(handler func(*bindings.ReadResponse) error) error {
+	c := cron.New(cron.WithParser(b.parser))
+	id, err := c.AddFunc(b.schedule, func() {
+		b.logger.Debugf("schedule fired: %v", time.Now())
+		handler(&bindings.ReadResponse{
+			Metadata: map[string]string{
+				"timeZone":    c.Location().String(),
+				"readTimeUTC": time.Now().UTC().String(),
+			},
+		})
+	})
+	if err != nil {
+		return errors.Wrapf(err, "error scheduling %s", b.schedule)
+	}
+	c.Start()
+	b.logger.Debugf("next run: %v", time.Until(c.Entry(id).Next))
+	<-b.stopCh
+	b.logger.Debugf("stopping schedule: %s", b.schedule)
+	c.Stop()
+	return nil
+}
+
+// Invoke exposes way to stop previously started cron
+func (b *Binding) Invoke(req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
+	b.logger.Debugf("operation: %v", req.Operation)
+	if req.Operation != bindings.DeleteOperation {
+		return nil, fmt.Errorf("invalid operation: '%v', only '%v' supported",
+			req.Operation, bindings.DeleteOperation)
+	}
+	b.stopCh <- true
+	return &bindings.InvokeResponse{
+		Metadata: map[string]string{
+			"schedule":    b.schedule,
+			"stopTimeUTC": time.Now().UTC().String(),
+		},
+	}, nil
+}
+
+// Operations method returns the supported operations by this binding
+func (b *Binding) Operations() []bindings.OperationKind {
+	return []bindings.OperationKind{
+		bindings.DeleteOperation,
+	}
+}
diff --git a/go.mod b/go.mod
index 4a3d050c..f9266820 100644
--- a/go.mod
+++ b/go.mod
@@ -54,6 +54,7 @@ require (
 	github.com/nats-io/stan.go v0.6.0
 	github.com/openzipkin/zipkin-go v0.1.6
 	github.com/pkg/errors v0.9.1
+	github.com/robfig/cron/v3 v3.0.1
 	github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da
 	github.com/satori/go.uuid v1.2.0
 	github.com/sendgrid/rest v2.4.1+incompatible // indirect
diff --git a/go.sum b/go.sum
index 55afa038..b7a72bb5 100644
--- a/go.sum
+++ b/go.sum
@@ -64,8 +64,6 @@ github.com/Azure/go-autorest/autorest v0.9.2 h1:6AWuh3uWrsZJcNoCHrCF/+g4aKPCU39k
 github.com/Azure/go-autorest/autorest v0.9.2/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
 github.com/Azure/go-autorest/autorest v0.9.3 h1:OZEIaBbMdUE/Js+BQKlpO81XlISgipr6yDJ+PSwsgi4=
 github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0=
-github.com/Azure/go-autorest/autorest v0.10.0 h1:mvdtztBqcL8se7MdrUweNieTNi4kfNG6GOJuurQJpuY=
-github.com/Azure/go-autorest/autorest v0.10.0/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
 github.com/Azure/go-autorest/autorest v0.10.2 h1:NuSF3gXetiHyUbVdneJMEVyPUYAe5wh+aN08JYAf1tI=
 github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
 github.com/Azure/go-autorest/autorest/adal v0.5.0 h1:q2gDruN08/guU9vAjuPWff0+QIrpH6ediguzdAzXAUU=
@@ -212,7 +210,6 @@ github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4
 github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU=
 github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY=
 github.com/dgrijalva/jwt-go v0.0.0-20160705203006-01aeca54ebda/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
@@ -675,6 +672,8 @@ github.com/qiangxue/fasthttp-routing v0.0.0-20160225050629-6ccdc2a18d87/go.mod h
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ=
 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@@ -930,6 +929,7 @@ golang.org/x/sys v0.0.0-20200113162924-86b910548bc1 h1:gZpLHxUX5BdYLA08Lj4YCJNN/
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=


Test patch:
diff --git a/bindings/cron/cron_test.go b/bindings/cron/cron_test.go
new file mode 100644
index 00000000..eb2ff589
--- /dev/null
+++ b/bindings/cron/cron_test.go
@@ -0,0 +1,86 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+// ------------------------------------------------------------
+
+package twitter
+
+import (
+	"os"
+	"testing"
+
+	"github.com/dapr/components-contrib/bindings"
+	"github.com/dapr/dapr/pkg/logger"
+	"github.com/stretchr/testify/assert"
+)
+
+func getTestMetadata(schedule string) bindings.Metadata {
+	m := bindings.Metadata{}
+	m.Properties = map[string]string{
+		"schedule": schedule,
+	}
+	return m
+}
+
+func getNewCron() *Binding {
+	l := logger.NewLogger("cron")
+	if os.Getenv("DEBUG") != "" {
+		l.SetOutputLevel(logger.DebugLevel)
+	}
+	return NewCron(l)
+}
+
+// go test -v -timeout 15s -count=1 ./bindings/cron/
+func TestCronInitSuccess(t *testing.T) {
+	c := getNewCron()
+	err := c.Init(getTestMetadata("@every 1h"))
+	assert.NoErrorf(t, err, "error initializing valid schedule")
+}
+
+func TestCronInitWithSeconds(t *testing.T) {
+	c := getNewCron()
+	err := c.Init(getTestMetadata("15 * * * * *"))
+	assert.NoErrorf(t, err, "error initializing schedule with seconds")
+}
+
+func TestCronInitFailure(t *testing.T) {
+	c := getNewCron()
+	err := c.Init(getTestMetadata("invalid schedule"))
+	assert.Errorf(t, err, "no error while initializing invalid schedule")
+}
+
+// TestLongRead
+// go test -v -count=1 -timeout 15s -run TestLongRead ./bindings/cron/
+func TestCronReadWithDeleteInvoke(t *testing.T) {
+	c := getNewCron()
+	schedule := "@every 1s"
+	assert.NoErrorf(t, c.Init(getTestMetadata(schedule)), "error initializing valid schedule")
+	testsNum := 3
+	i := 0
+	err := c.Read(func(res *bindings.ReadResponse) error {
+		assert.NotNil(t, res)
+		assert.LessOrEqualf(t, i, testsNum, "Invoke didn't stop the schedule")
+		i++
+		if i == testsNum {
+			resp, err := c.Invoke(&bindings.InvokeRequest{
+				Operation: bindings.DeleteOperation,
+			})
+			assert.NoError(t, err)
+			scheduleVal, exists := resp.Metadata["schedule"]
+			assert.Truef(t, exists, "Response metadata doesn't include the expected 'schedule' key")
+			assert.Equal(t, schedule, scheduleVal)
+		}
+		return nil
+	})
+	assert.NoErrorf(t, err, "error on read")
+}
+
+func TestCronInvokeInvalidOperation(t *testing.T) {
+	c := getNewCron()
+	initErr := c.Init(getTestMetadata("@every 1s"))
+	assert.NoErrorf(t, initErr, "Error on Init")
+	_, err := c.Invoke(&bindings.InvokeRequest{
+		Operation: bindings.CreateOperation,
+	})
+	assert.Error(t, err)
+}