Skip to content

Commit

Permalink
Call webhook when a flag changed (#65)
Browse files Browse the repository at this point in the history
  • Loading branch information
thomaspoignant authored Feb 3, 2021
1 parent 04865aa commit c9edddb
Show file tree
Hide file tree
Showing 28 changed files with 1,008 additions and 188 deletions.
121 changes: 118 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,18 @@ Example:
```go
ffclient.Init(ffclient.Config{
PollInterval: 3,
Logger: log.New(file, "/tmp/log", 0)
Context context.Background(),
Logger: log.New(file, "/tmp/log", 0),
Context: context.Background(),
Retriever: &ffclient.FileRetriever{Path: "testdata/test.yaml"},
Webhooks: []ffclient.WebhookConfig{
{
PayloadURL: " https://example.com/hook",
Secret: "Secret",
Meta: map[string]string{
"app.name": "my app",
},
},
},
})
```

Expand All @@ -80,7 +90,7 @@ ffclient.Init(ffclient.Config{
|`Logger` | Logger used to log what `go-feature-flag` is doing.<br />If no logger is provided the module will not log anything.|
|`Context` | The context used by the retriever.<br />The default value is `context.Background()`.|
|`Retriever` | The configuration retriever you want to use to get your flag file *(see [Where do I store my flags file](#where-do-i-store-my-flags-file) for the configuration details)*.|

|`Webhooks` | List of webhooks to call when your flag file has changed *(see [webhook section](#webhook) for more details)*.|
## Where do I store my flags file
`go-feature-flags` support different ways of retrieving the flag file.
We can have only one source for the file, if you set multiple sources in your configuration, only one will be take in
Expand Down Expand Up @@ -256,6 +266,111 @@ The default value is return when an error is encountered _(`ffclient` not initia
In the example, if the flag `your.feature.key` does not exists, result will be `false`.
Not that you will always have a usable value in the result.

## Webhook
If you want to be informed when a flag has changed outside of your app, you can configure a webhook.

```go
ffclient.Config{
// ...
Webhooks: []ffclient.WebhookConfig{
{
PayloadURL: " https://example.com/hook",
Secret: "Secret",
Meta: map[string]string{
"app.name": "my app",
},
},
},
}
```

| | | |
|---|---|---|
|`PayloadURL` |![mandatory](https://img.shields.io/badge/-mandatory-red) | The complete URL of your API *(we will send a POST request to this URL, [see format](#format))* |
|`Secret` |![optional](https://img.shields.io/badge/-optional-green) | A secret key you can share with your webhook. We will use this key to sign the request *(see [signature section](#signature) for more details)*. |
|`Meta` |![optional](https://img.shields.io/badge/-optional-green) | A list of key value that will be add in your request, this is super usefull if you to add information on the current running instance of your app.<br/>*By default the hostname is always added in the meta informations.*|



### Format
If you have configured a webhook, a POST request will be sent to the `PayloadURL` with a body in this format:

```json
{
"meta": {
"hostname": "server01",
// ...
},
"flags": {
"deleted": {}, // map of your deleted flags
"added": {}, // map of your added flags
"updated": {
"flag-name": { // an object that contains old and new value
"old_value": {},
"new_value": {}
}
}
}
}
```

<details>
<summary><b>Example</b></summary>

```json
{
"meta":{
"hostname": "server01"
},
"flags":{
"deleted": {
"test-flag": {
"rule": "key eq \"random-key\"",
"percentage": 100,
"true": true,
"false": false,
"default": false
}
},
"added": {
"test-flag3": {
"percentage": 5,
"true": "test",
"false": "false",
"default": "default"
}
},
"updated": {
"test-flag2": {
"old_value": {
"rule": "key eq \"not-a-key\"",
"percentage": 100,
"true": true,
"false": false,
"default": false
},
"new_value": {
"disable": true,
"rule": "key eq \"not-a-key\"",
"percentage": 100,
"true": true,
"false": false,
"default": false
}
}
}
}
}
```
</details>

### Signature
This header **`X-Hub-Signature-256`** is sent if the webhook is configured with a secret. This is the HMAC hex digest of the request body, and is generated using the SHA-256 hash function and the secret as the HMAC key.

:warning: **The recommendation is to always use the `Secret` and on your API/webook always verify the signature key to be sure that you don't have a man in the middle attack.**

---

## Multiple flag configurations
`go-feature-flag` comes ready to use out of the box by calling the `Init` function and after that it will be available everywhere.
Since most applications will want to use a single central flag configuration, the `go-feature-flag` package provides this. It is similar to a singleton.
Expand Down
61 changes: 57 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import (
// PollInterval is the interval in seconds where we gonna read the file to update the cache.
// You should also have a retriever to specify where to read the flags file.
type Config struct {
PollInterval int // Poll every X seconds
Logger *log.Logger
Context context.Context // default is context.Background()
Retriever Retriever
PollInterval int // Poll every X seconds
Logger *log.Logger
Context context.Context // default is context.Background()
Retriever Retriever
Webhooks []WebhookConfig // webhooks we should call when a flag create/update/delete
}

// GetRetriever returns a retriever.FlagRetriever configure with the retriever available in the config.
Expand Down Expand Up @@ -131,3 +132,55 @@ func (r *GithubRetriever) getFlagRetriever() (retriever.FlagRetriever, error) {

return httpRetriever.getFlagRetriever()
}

// WebhookConfig is the configuration of your webhook.
// we will call this URL with a POST request with the following format
//
// {
// "meta":{
// "hostname": "server01"
// },
// "flags":{
// "deleted": {
// "test-flag": {
// "rule": "key eq \"random-key\"",
// "percentage": 100,
// "true": true,
// "false": false,
// "default": false
// }
// },
// "added": {
// "test-flag3": {
// "percentage": 5,
// "true": "test",
// "false": "false",
// "default": "default"
// }
// },
// "updated": {
// "test-flag2": {
// "old_value": {
// "rule": "key eq \"not-a-key\"",
// "percentage": 100,
// "true": true,
// "false": false,
// "default": false
// },
// "new_value": {
// "disable": true,
// "rule": "key eq \"not-a-key\"",
// "percentage": 100,
// "true": true,
// "false": false,
// "default": false
// }
// }
// }
// }
// }
type WebhookConfig struct {
PayloadURL string // PayloadURL of your webhook
Secret string // Secret used to sign your request body.
Meta map[string]string // Meta information that you want to send to your webhook (not mandatory)
}
20 changes: 9 additions & 11 deletions feature_flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,20 @@ func New(config Config) (*GoFeatureFlag, error) {
return nil, fmt.Errorf("%d is not a valid PollInterval value, it need to be > 0", config.PollInterval)
}

notifiers, err := getNotifiers(config)
if err != nil {
return nil, fmt.Errorf("wrong configuration in your webhook: %v", err)
}
notificationService := cache.NewNotificationService(notifiers)

goFF := &GoFeatureFlag{
config: config,
bgUpdater: newBackgroundUpdater(config.PollInterval),
cache: cache.New(notificationService),
}
goFF.cache = cache.New(cache.NewService(goFF.getNotifiers()))

// fail if we cannot retrieve the flags the 1st time
err := retrieveFlagsAndUpdateCache(goFF.config, goFF.cache)
err = retrieveFlagsAndUpdateCache(goFF.config, goFF.cache)
if err != nil {
return nil, fmt.Errorf("impossible to retrieve the flags, please check your configuration: %v", err)
}
Expand All @@ -74,6 +80,7 @@ func New(config Config) (*GoFeatureFlag, error) {
return goFF, nil
}

// Close wait until thread are done
func (g *GoFeatureFlag) Close() {
// clear the cache
g.cache.Close()
Expand All @@ -97,15 +104,6 @@ func (g *GoFeatureFlag) startFlagUpdaterDaemon() {
}
}

// getNotifiers is creating Notifier from the config
func (g *GoFeatureFlag) getNotifiers() []cache.Notifier {
var notifiers []cache.Notifier
if g.config.Logger != nil {
notifiers = append(notifiers, &cache.LogNotifier{Logger: g.config.Logger})
}
return notifiers
}

// retrieveFlagsAndUpdateCache is called every X seconds to refresh the cache flag.
func retrieveFlagsAndUpdateCache(config Config, cache cache.Cache) error {
retriever, err := config.GetRetriever()
Expand Down
22 changes: 21 additions & 1 deletion feature_flag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func TestImpossibleToLoadfile(t *testing.T) {
false: false
default: false`

flagFile, _ := ioutil.TempFile("", "")
flagFile, _ := ioutil.TempFile("", "impossible")
_ = ioutil.WriteFile(flagFile.Name(), []byte(initialFileContent), 0600)

gffClient1, _ := New(Config{
Expand All @@ -161,3 +161,23 @@ func TestImpossibleToLoadfile(t *testing.T) {
flagValue, _ = gffClient1.BoolVariation("test-flag", ffuser.NewUser("random-key"), false)
assert.True(t, flagValue)
}

func TestWrongWebhookConfig(t *testing.T) {
_, err := New(Config{
PollInterval: 5,
Retriever: &FileRetriever{Path: "testdata/test.yaml"},
Webhooks: []WebhookConfig{
{
PayloadURL: " https://example.com/hook",
Secret: "Secret",
Meta: map[string]string{
"my-app": "go-ff-test",
},
},
},
})

assert.Errorf(t, err, "wrong url should return an error")
assert.Equal(t, err.Error(), "wrong configuration in your webhook: parse \" https://example.com/hook\": "+
"first path segment in URL cannot contain colon")
}
8 changes: 8 additions & 0 deletions internal/HTTPClient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package internal

import "net/http"

// HTTPClient is an interface over http.Client to make mock easier.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
12 changes: 6 additions & 6 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import (
"gopkg.in/yaml.v3"
"sync"

"github.com/thomaspoignant/go-feature-flag/internal/flags"
"github.com/thomaspoignant/go-feature-flag/internal/model"
)

type Cache interface {
UpdateCache(loadedFlags []byte) error
Close()
GetFlag(key string) (flags.Flag, error)
GetFlag(key string) (model.Flag, error)
}

type cacheImpl struct {
Expand All @@ -23,7 +23,7 @@ type cacheImpl struct {

func New(notificationService Service) Cache {
return &cacheImpl{
flagsCache: make(map[string]flags.Flag),
flagsCache: make(map[string]model.Flag),
mutex: sync.RWMutex{},
notificationService: notificationService,
}
Expand Down Expand Up @@ -56,17 +56,17 @@ func (c *cacheImpl) Close() {
c.notificationService.Close()
}

func (c *cacheImpl) GetFlag(key string) (flags.Flag, error) {
func (c *cacheImpl) GetFlag(key string) (model.Flag, error) {
c.mutex.RLock()
defer c.mutex.RUnlock()

if c.flagsCache == nil {
return flags.Flag{}, errors.New("impossible to read the toggle before the initialisation")
return model.Flag{}, errors.New("impossible to read the toggle before the initialisation")
}

flag, ok := c.flagsCache[key]
if !ok {
return flags.Flag{}, fmt.Errorf("flag [%v] does not exists", key)
return model.Flag{}, fmt.Errorf("flag [%v] does not exists", key)
}
return flag, nil
}
9 changes: 5 additions & 4 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import (
"github.com/stretchr/testify/assert"
"testing"

"github.com/thomaspoignant/go-feature-flag/internal/flags"
"github.com/thomaspoignant/go-feature-flag/internal/model"
"github.com/thomaspoignant/go-feature-flag/internal/notifier"
)

func Test_FlagCacheNotInit(t *testing.T) {
Expand Down Expand Up @@ -34,15 +35,15 @@ func Test_FlagCache(t *testing.T) {
tests := []struct {
name string
args args
expected map[string]flags.Flag
expected map[string]model.Flag
wantErr bool
}{
{
name: "Add valid",
args: args{
loadedFlags: exampleFile,
},
expected: map[string]flags.Flag{
expected: map[string]model.Flag{
"test-flag": {
Disable: false,
Rule: "key eq \"random-key\"",
Expand Down Expand Up @@ -70,7 +71,7 @@ func Test_FlagCache(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fCache := New(NewService([]Notifier{}))
fCache := New(NewNotificationService([]notifier.Notifier{}))
err := fCache.UpdateCache(tt.args.loadedFlags)
if (err != nil) != tt.wantErr {
t.Errorf("UpdateCache() error = %v, wantErr %v", err, tt.wantErr)
Expand Down
Loading

0 comments on commit c9edddb

Please sign in to comment.