Skip to content

Commit c9edddb

Browse files
Call webhook when a flag changed (#65)
1 parent 04865aa commit c9edddb

28 files changed

+1008
-188
lines changed

README.md

+118-3
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,18 @@ Example:
6969
```go
7070
ffclient.Init(ffclient.Config{
7171
PollInterval: 3,
72-
Logger: log.New(file, "/tmp/log", 0)
73-
Context context.Background(),
72+
Logger: log.New(file, "/tmp/log", 0),
73+
Context: context.Background(),
74+
Retriever: &ffclient.FileRetriever{Path: "testdata/test.yaml"},
75+
Webhooks: []ffclient.WebhookConfig{
76+
{
77+
PayloadURL: " https://example.com/hook",
78+
Secret: "Secret",
79+
Meta: map[string]string{
80+
"app.name": "my app",
81+
},
82+
},
83+
},
7484
})
7585
```
7686

@@ -80,7 +90,7 @@ ffclient.Init(ffclient.Config{
8090
|`Logger` | Logger used to log what `go-feature-flag` is doing.<br />If no logger is provided the module will not log anything.|
8191
|`Context` | The context used by the retriever.<br />The default value is `context.Background()`.|
8292
|`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)*.|
83-
93+
|`Webhooks` | List of webhooks to call when your flag file has changed *(see [webhook section](#webhook) for more details)*.|
8494
## Where do I store my flags file
8595
`go-feature-flags` support different ways of retrieving the flag file.
8696
We can have only one source for the file, if you set multiple sources in your configuration, only one will be take in
@@ -256,6 +266,111 @@ The default value is return when an error is encountered _(`ffclient` not initia
256266
In the example, if the flag `your.feature.key` does not exists, result will be `false`.
257267
Not that you will always have a usable value in the result.
258268

269+
## Webhook
270+
If you want to be informed when a flag has changed outside of your app, you can configure a webhook.
271+
272+
```go
273+
ffclient.Config{
274+
// ...
275+
Webhooks: []ffclient.WebhookConfig{
276+
{
277+
PayloadURL: " https://example.com/hook",
278+
Secret: "Secret",
279+
Meta: map[string]string{
280+
"app.name": "my app",
281+
},
282+
},
283+
},
284+
}
285+
```
286+
287+
| | | |
288+
|---|---|---|
289+
|`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))* |
290+
|`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)*. |
291+
|`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.*|
292+
293+
294+
295+
### Format
296+
If you have configured a webhook, a POST request will be sent to the `PayloadURL` with a body in this format:
297+
298+
```json
299+
{
300+
"meta": {
301+
"hostname": "server01",
302+
// ...
303+
},
304+
"flags": {
305+
"deleted": {}, // map of your deleted flags
306+
"added": {}, // map of your added flags
307+
"updated": {
308+
"flag-name": { // an object that contains old and new value
309+
"old_value": {},
310+
"new_value": {}
311+
}
312+
}
313+
}
314+
}
315+
```
316+
317+
<details>
318+
<summary><b>Example</b></summary>
319+
320+
```json
321+
{
322+
"meta":{
323+
"hostname": "server01"
324+
},
325+
"flags":{
326+
"deleted": {
327+
"test-flag": {
328+
"rule": "key eq \"random-key\"",
329+
"percentage": 100,
330+
"true": true,
331+
"false": false,
332+
"default": false
333+
}
334+
},
335+
"added": {
336+
"test-flag3": {
337+
"percentage": 5,
338+
"true": "test",
339+
"false": "false",
340+
"default": "default"
341+
}
342+
},
343+
"updated": {
344+
"test-flag2": {
345+
"old_value": {
346+
"rule": "key eq \"not-a-key\"",
347+
"percentage": 100,
348+
"true": true,
349+
"false": false,
350+
"default": false
351+
},
352+
"new_value": {
353+
"disable": true,
354+
"rule": "key eq \"not-a-key\"",
355+
"percentage": 100,
356+
"true": true,
357+
"false": false,
358+
"default": false
359+
}
360+
}
361+
}
362+
}
363+
}
364+
```
365+
</details>
366+
367+
### Signature
368+
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.
369+
370+
: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.**
371+
372+
---
373+
259374
## Multiple flag configurations
260375
`go-feature-flag` comes ready to use out of the box by calling the `Init` function and after that it will be available everywhere.
261376
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.

config.go

+57-4
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import (
1818
// PollInterval is the interval in seconds where we gonna read the file to update the cache.
1919
// You should also have a retriever to specify where to read the flags file.
2020
type Config struct {
21-
PollInterval int // Poll every X seconds
22-
Logger *log.Logger
23-
Context context.Context // default is context.Background()
24-
Retriever Retriever
21+
PollInterval int // Poll every X seconds
22+
Logger *log.Logger
23+
Context context.Context // default is context.Background()
24+
Retriever Retriever
25+
Webhooks []WebhookConfig // webhooks we should call when a flag create/update/delete
2526
}
2627

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

132133
return httpRetriever.getFlagRetriever()
133134
}
135+
136+
// WebhookConfig is the configuration of your webhook.
137+
// we will call this URL with a POST request with the following format
138+
//
139+
// {
140+
// "meta":{
141+
// "hostname": "server01"
142+
// },
143+
// "flags":{
144+
// "deleted": {
145+
// "test-flag": {
146+
// "rule": "key eq \"random-key\"",
147+
// "percentage": 100,
148+
// "true": true,
149+
// "false": false,
150+
// "default": false
151+
// }
152+
// },
153+
// "added": {
154+
// "test-flag3": {
155+
// "percentage": 5,
156+
// "true": "test",
157+
// "false": "false",
158+
// "default": "default"
159+
// }
160+
// },
161+
// "updated": {
162+
// "test-flag2": {
163+
// "old_value": {
164+
// "rule": "key eq \"not-a-key\"",
165+
// "percentage": 100,
166+
// "true": true,
167+
// "false": false,
168+
// "default": false
169+
// },
170+
// "new_value": {
171+
// "disable": true,
172+
// "rule": "key eq \"not-a-key\"",
173+
// "percentage": 100,
174+
// "true": true,
175+
// "false": false,
176+
// "default": false
177+
// }
178+
// }
179+
// }
180+
// }
181+
// }
182+
type WebhookConfig struct {
183+
PayloadURL string // PayloadURL of your webhook
184+
Secret string // Secret used to sign your request body.
185+
Meta map[string]string // Meta information that you want to send to your webhook (not mandatory)
186+
}

feature_flag.go

+9-11
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,20 @@ func New(config Config) (*GoFeatureFlag, error) {
5656
return nil, fmt.Errorf("%d is not a valid PollInterval value, it need to be > 0", config.PollInterval)
5757
}
5858

59+
notifiers, err := getNotifiers(config)
60+
if err != nil {
61+
return nil, fmt.Errorf("wrong configuration in your webhook: %v", err)
62+
}
63+
notificationService := cache.NewNotificationService(notifiers)
64+
5965
goFF := &GoFeatureFlag{
6066
config: config,
6167
bgUpdater: newBackgroundUpdater(config.PollInterval),
68+
cache: cache.New(notificationService),
6269
}
63-
goFF.cache = cache.New(cache.NewService(goFF.getNotifiers()))
6470

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

83+
// Close wait until thread are done
7784
func (g *GoFeatureFlag) Close() {
7885
// clear the cache
7986
g.cache.Close()
@@ -97,15 +104,6 @@ func (g *GoFeatureFlag) startFlagUpdaterDaemon() {
97104
}
98105
}
99106

100-
// getNotifiers is creating Notifier from the config
101-
func (g *GoFeatureFlag) getNotifiers() []cache.Notifier {
102-
var notifiers []cache.Notifier
103-
if g.config.Logger != nil {
104-
notifiers = append(notifiers, &cache.LogNotifier{Logger: g.config.Logger})
105-
}
106-
return notifiers
107-
}
108-
109107
// retrieveFlagsAndUpdateCache is called every X seconds to refresh the cache flag.
110108
func retrieveFlagsAndUpdateCache(config Config, cache cache.Cache) error {
111109
retriever, err := config.GetRetriever()

feature_flag_test.go

+21-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func TestImpossibleToLoadfile(t *testing.T) {
138138
false: false
139139
default: false`
140140

141-
flagFile, _ := ioutil.TempFile("", "")
141+
flagFile, _ := ioutil.TempFile("", "impossible")
142142
_ = ioutil.WriteFile(flagFile.Name(), []byte(initialFileContent), 0600)
143143

144144
gffClient1, _ := New(Config{
@@ -161,3 +161,23 @@ func TestImpossibleToLoadfile(t *testing.T) {
161161
flagValue, _ = gffClient1.BoolVariation("test-flag", ffuser.NewUser("random-key"), false)
162162
assert.True(t, flagValue)
163163
}
164+
165+
func TestWrongWebhookConfig(t *testing.T) {
166+
_, err := New(Config{
167+
PollInterval: 5,
168+
Retriever: &FileRetriever{Path: "testdata/test.yaml"},
169+
Webhooks: []WebhookConfig{
170+
{
171+
PayloadURL: " https://example.com/hook",
172+
Secret: "Secret",
173+
Meta: map[string]string{
174+
"my-app": "go-ff-test",
175+
},
176+
},
177+
},
178+
})
179+
180+
assert.Errorf(t, err, "wrong url should return an error")
181+
assert.Equal(t, err.Error(), "wrong configuration in your webhook: parse \" https://example.com/hook\": "+
182+
"first path segment in URL cannot contain colon")
183+
}

internal/HTTPClient.go

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package internal
2+
3+
import "net/http"
4+
5+
// HTTPClient is an interface over http.Client to make mock easier.
6+
type HTTPClient interface {
7+
Do(req *http.Request) (*http.Response, error)
8+
}

internal/cache/cache.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import (
66
"gopkg.in/yaml.v3"
77
"sync"
88

9-
"github.com/thomaspoignant/go-feature-flag/internal/flags"
9+
"github.com/thomaspoignant/go-feature-flag/internal/model"
1010
)
1111

1212
type Cache interface {
1313
UpdateCache(loadedFlags []byte) error
1414
Close()
15-
GetFlag(key string) (flags.Flag, error)
15+
GetFlag(key string) (model.Flag, error)
1616
}
1717

1818
type cacheImpl struct {
@@ -23,7 +23,7 @@ type cacheImpl struct {
2323

2424
func New(notificationService Service) Cache {
2525
return &cacheImpl{
26-
flagsCache: make(map[string]flags.Flag),
26+
flagsCache: make(map[string]model.Flag),
2727
mutex: sync.RWMutex{},
2828
notificationService: notificationService,
2929
}
@@ -56,17 +56,17 @@ func (c *cacheImpl) Close() {
5656
c.notificationService.Close()
5757
}
5858

59-
func (c *cacheImpl) GetFlag(key string) (flags.Flag, error) {
59+
func (c *cacheImpl) GetFlag(key string) (model.Flag, error) {
6060
c.mutex.RLock()
6161
defer c.mutex.RUnlock()
6262

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

6767
flag, ok := c.flagsCache[key]
6868
if !ok {
69-
return flags.Flag{}, fmt.Errorf("flag [%v] does not exists", key)
69+
return model.Flag{}, fmt.Errorf("flag [%v] does not exists", key)
7070
}
7171
return flag, nil
7272
}

internal/cache/cache_test.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import (
44
"github.com/stretchr/testify/assert"
55
"testing"
66

7-
"github.com/thomaspoignant/go-feature-flag/internal/flags"
7+
"github.com/thomaspoignant/go-feature-flag/internal/model"
8+
"github.com/thomaspoignant/go-feature-flag/internal/notifier"
89
)
910

1011
func Test_FlagCacheNotInit(t *testing.T) {
@@ -34,15 +35,15 @@ func Test_FlagCache(t *testing.T) {
3435
tests := []struct {
3536
name string
3637
args args
37-
expected map[string]flags.Flag
38+
expected map[string]model.Flag
3839
wantErr bool
3940
}{
4041
{
4142
name: "Add valid",
4243
args: args{
4344
loadedFlags: exampleFile,
4445
},
45-
expected: map[string]flags.Flag{
46+
expected: map[string]model.Flag{
4647
"test-flag": {
4748
Disable: false,
4849
Rule: "key eq \"random-key\"",
@@ -70,7 +71,7 @@ func Test_FlagCache(t *testing.T) {
7071
}
7172
for _, tt := range tests {
7273
t.Run(tt.name, func(t *testing.T) {
73-
fCache := New(NewService([]Notifier{}))
74+
fCache := New(NewNotificationService([]notifier.Notifier{}))
7475
err := fCache.UpdateCache(tt.args.loadedFlags)
7576
if (err != nil) != tt.wantErr {
7677
t.Errorf("UpdateCache() error = %v, wantErr %v", err, tt.wantErr)

0 commit comments

Comments
 (0)