Skip to content

Commit

Permalink
Webhook exporter/notifier, allow custom headers (#747)
Browse files Browse the repository at this point in the history
* allow custom headers in webhook components

Signed-off-by: Thomas Poignant <[email protected]>

* add webhook custom header support in relay-proxy

Signed-off-by: Thomas Poignant <[email protected]>

* fix blank space

Signed-off-by: Thomas Poignant <[email protected]>

* Fix linter

Signed-off-by: Thomas Poignant <[email protected]>

---------

Signed-off-by: Thomas Poignant <[email protected]>
  • Loading branch information
thomaspoignant authored May 1, 2023
1 parent 89548c6 commit 50c5d39
Show file tree
Hide file tree
Showing 11 changed files with 158 additions and 59 deletions.
29 changes: 15 additions & 14 deletions cmd/relayproxy/config/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ import (

// ExporterConf contains all the field to configure an exporter
type ExporterConf struct {
Kind ExporterKind `mapstructure:"kind" koanf:"kind"`
OutputDir string `mapstructure:"outputDir" koanf:"outputdir"`
Format string `mapstructure:"format" koanf:"format"`
Filename string `mapstructure:"filename" koanf:"filename"`
CsvTemplate string `mapstructure:"csvTemplate" koanf:"csvtemplate"`
Bucket string `mapstructure:"bucket" koanf:"bucket"`
Path string `mapstructure:"path" koanf:"path"`
EndpointURL string `mapstructure:"endpointUrl" koanf:"endpointurl"`
Secret string `mapstructure:"secret" koanf:"secret"`
Meta map[string]string `mapstructure:"meta" koanf:"meta"`
LogFormat string `mapstructure:"logFormat" koanf:"logformat"`
FlushInterval int64 `mapstructure:"flushInterval" koanf:"flushinterval"`
MaxEventInMemory int64 `mapstructure:"maxEventInMemory" koanf:"maxeventinmemory"`
ParquetCompressionCodec string `mapstructure:"parquetCompressionCodec" koanf:"parquetcompressioncodec"`
Kind ExporterKind `mapstructure:"kind" koanf:"kind"`
OutputDir string `mapstructure:"outputDir" koanf:"outputdir"`
Format string `mapstructure:"format" koanf:"format"`
Filename string `mapstructure:"filename" koanf:"filename"`
CsvTemplate string `mapstructure:"csvTemplate" koanf:"csvtemplate"`
Bucket string `mapstructure:"bucket" koanf:"bucket"`
Path string `mapstructure:"path" koanf:"path"`
EndpointURL string `mapstructure:"endpointUrl" koanf:"endpointurl"`
Secret string `mapstructure:"secret" koanf:"secret"`
Meta map[string]string `mapstructure:"meta" koanf:"meta"`
LogFormat string `mapstructure:"logFormat" koanf:"logformat"`
FlushInterval int64 `mapstructure:"flushInterval" koanf:"flushinterval"`
MaxEventInMemory int64 `mapstructure:"maxEventInMemory" koanf:"maxeventinmemory"`
ParquetCompressionCodec string `mapstructure:"parquetCompressionCodec" koanf:"parquetcompressioncodec"`
Headers map[string][]string `mapstructure:"headers" koanf:"headers"`
}

func (c *ExporterConf) IsValid() error {
Expand Down
11 changes: 6 additions & 5 deletions cmd/relayproxy/config/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package config
import "fmt"

type NotifierConf struct {
Kind NotifierKind `mapstructure:"notifier" koanf:"notifier"`
SlackWebhookURL string `mapstructure:"slackWebhookUrl" koanf:"slackWebhookUrl"`
EndpointURL string `mapstructure:"endpointUrl" koanf:"endpointUrl"`
Secret string `mapstructure:"secret" koanf:"secret"`
Meta map[string]string `mapstructure:"meta" koanf:"meta"`
Kind NotifierKind `mapstructure:"notifier" koanf:"notifier"`
SlackWebhookURL string `mapstructure:"slackWebhookUrl" koanf:"slackWebhookUrl"`
EndpointURL string `mapstructure:"endpointUrl" koanf:"endpointUrl"`
Secret string `mapstructure:"secret" koanf:"secret"`
Meta map[string]string `mapstructure:"meta" koanf:"meta"`
Headers map[string][]string `mapstructure:"headers" koanf:"headers"`
}

func (c *NotifierConf) IsValid() error {
Expand Down
21 changes: 9 additions & 12 deletions cmd/relayproxy/service/gofeatureflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,12 @@ func initExporter(c *config.ExporterConf) (ffclient.DataExporter, error) {
switch c.Kind {
case config.WebhookExporter:
dataExp.Exporter = &webhookexporter.Exporter{
EndpointURL: c.EndpointURL,
Secret: c.Secret,
Meta: c.Meta,
}
EndpointURL: c.EndpointURL, Secret: c.Secret, Meta: c.Meta, Headers: c.Headers}
return dataExp, nil

case config.FileExporter:
dataExp.Exporter = &fileexporter.Exporter{
Format: format,
OutputDir: c.OutputDir,
Filename: filename,
CsvTemplate: csvTemplate,
ParquetCompressionCodec: parquetCompressionCodec,
}
dataExp.Exporter = &fileexporter.Exporter{Format: format, OutputDir: c.OutputDir, Filename: filename,
CsvTemplate: csvTemplate, ParquetCompressionCodec: parquetCompressionCodec}
return dataExp, nil

case config.LogExporter:
Expand Down Expand Up @@ -268,7 +260,12 @@ func initNotifier(c []config.NotifierConf) ([]notifier.Notifier, error) {

case config.WebhookNotifier:
notifiers = append(notifiers,
&webhooknotifier.Notifier{Secret: cNotif.Secret, EndpointURL: cNotif.EndpointURL, Meta: cNotif.Meta},
&webhooknotifier.Notifier{
Secret: cNotif.Secret,
EndpointURL: cNotif.EndpointURL,
Meta: cNotif.Meta,
Headers: cNotif.Headers,
},
)

default:
Expand Down
11 changes: 7 additions & 4 deletions exporter/webhookexporter/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type Exporter struct {
Secret string
// Meta information that you want to send to your webhook (not mandatory)
Meta map[string]string
// Headers (optional) the list of Headers to send to the endpoint
Headers map[string][]string

httpClient internal.HTTPClient
init sync.Once
Expand Down Expand Up @@ -84,21 +86,22 @@ func (f *Exporter) Export(ctx context.Context, _ *log.Logger, featureEvents []ex
return err
}

headers := http.Header{
"Content-Type": []string{"application/json"},
if f.Headers == nil {
f.Headers = map[string][]string{}
}
f.Headers["Content-Type"] = []string{"application/json"}

// if a secret is provided we sign the body and add this signature as a header.
if f.Secret != "" {
headers["X-Hub-Signature-256"] = []string{signer.Sign(payload, []byte(f.Secret))}
f.Headers["X-Hub-Signature-256"] = []string{signer.Sign(payload, []byte(f.Secret))}
}

request, err := http.NewRequestWithContext(
ctx, http.MethodPost, f.EndpointURL, io.NopCloser(bytes.NewReader(payload)))
if err != nil {
return err
}
request.Header = headers
request.Header = f.Headers
response, err := f.httpClient.Do(request)
// Log if something went wrong while calling the webhook.
if err != nil {
Expand Down
37 changes: 37 additions & 0 deletions exporter/webhookexporter/exporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func TestWebhook_Export(t *testing.T) {
Secret string
Meta map[string]string
httpClient testutils.HTTPClientMock
Headers map[string][]string
}
type args struct {
logger *log.Logger
Expand All @@ -33,6 +34,7 @@ func TestWebhook_Export(t *testing.T) {
type expected struct {
bodyFilePath string
signHeader string
headers map[string][]string
}
tests := []struct {
name string
Expand Down Expand Up @@ -147,6 +149,36 @@ func TestWebhook_Export(t *testing.T) {
},
wantErr: true,
},
{
name: "expect exporter to send custom headers",
fields: fields{
EndpointURL: "http://valid.com/webhook",
httpClient: testutils.HTTPClientMock{StatusCode: 200, ForceError: false},
Meta: map[string]string{"hostname": "hostname"},
Headers: map[string][]string{"Authorization": {"Bearer auth_token"}},
},
args: args{
logger: logger,
featureEvents: []exporter.FeatureEvent{
{
Kind: "feature", ContextKind: "anonymousUser", UserKey: "ABCD", CreationDate: 1617970547, Key: "random-key",
Variation: "Default", Value: "YO", Default: false,
},
{
Kind: "feature", ContextKind: "anonymousUser", UserKey: "EFGH", CreationDate: 1617970701, Key: "random-key",
Variation: "Default", Value: "YO2", Default: false, Version: "127",
},
},
},
expected: expected{
bodyFilePath: "./testdata/valid_without_signature.json",
signHeader: "",
headers: map[string][]string{
"Authorization": {"Bearer auth_token"},
"Content-Type": {"application/json"}},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -155,6 +187,7 @@ func TestWebhook_Export(t *testing.T) {
Secret: tt.fields.Secret,
Meta: tt.fields.Meta,
httpClient: &tt.fields.httpClient,
Headers: tt.fields.Headers,
}
err := f.Export(context.Background(), tt.args.logger, tt.args.featureEvents)
if tt.wantErr {
Expand All @@ -172,6 +205,10 @@ func TestWebhook_Export(t *testing.T) {
if tt.expected.signHeader != "" {
assert.Equal(t, tt.expected.signHeader, tt.fields.httpClient.Signature)
}

if tt.expected.headers != nil {
assert.Equal(t, tt.expected.headers, tt.fields.httpClient.Headers)
}
})
}
}
Expand Down
14 changes: 9 additions & 5 deletions notifier/webhooknotifier/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ type Notifier struct {
// Optional: Secret used to sign your request body.
Secret string

// Optional: Meta information that you want to send to your webhook
// Meta (optional) information that you want to send to your webhook
Meta map[string]string

// Headers (optional) the list of Headers to send to the endpoint
Headers map[string][]string

httpClient internal.HTTPClient
init sync.Once
}
Expand Down Expand Up @@ -120,19 +123,20 @@ func (c *Notifier) Notify(diff notifier.DiffCache, wg *sync.WaitGroup) error {
return fmt.Errorf("error: (Webhook Notifier) impossible to read differences; %v", err)
}

headers := http.Header{
"Content-Type": []string{"application/json"},
if c.Headers == nil {
c.Headers = map[string][]string{}
}
c.Headers["Content-Type"] = []string{"application/json"}

// if a secret is provided we sign the body and add this signature as a header.
if c.Secret != "" {
headers["X-Hub-Signature-256"] = []string{signer.Sign(payload, []byte(c.Secret))}
c.Headers["X-Hub-Signature-256"] = []string{signer.Sign(payload, []byte(c.Secret))}
}

request := http.Request{
Method: "POST",
URL: endpointURL,
Header: headers,
Header: c.Headers,
Body: io.NopCloser(bytes.NewReader(payload)),
}
response, err := c.httpClient.Do(&request)
Expand Down
44 changes: 44 additions & 0 deletions notifier/webhooknotifier/notifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ func Test_webhookNotifier_Notify(t *testing.T) {
errorMsg string
bodyPath string
signature string
headers map[string][]string
}
type args struct {
diff notifier.DiffCache
statusCode int
forceError bool
url string
headers map[string][]string
}
tests := []struct {
name string
Expand Down Expand Up @@ -208,6 +210,44 @@ func Test_webhookNotifier_Notify(t *testing.T) {
forceError: true,
},
},
{
name: "should use custom Headers",
expected: expected{
bodyPath: "./testdata/should_not_be_signed_if_no_secret.json",
signature: "",
headers: map[string][]string{
"Authorization": {"Bearer auth_token"},
"Content-Type": {"application/json"},
},
},
args: args{
url: "http://webhook.example/hook",
statusCode: http.StatusOK,
headers: map[string][]string{
"Authorization": {"Bearer auth_token"},
},
diff: notifier.DiffCache{
Added: map[string]flag.Flag{
"test-flag3": &flag.InternalFlag{
Variations: &map[string]*interface{}{
"Default": testconvert.Interface("default"),
"False": testconvert.Interface("false"),
"True": testconvert.Interface("test"),
},
DefaultRule: &flag.Rule{
Name: testconvert.String("legacyDefaultRule"),
Percentages: &map[string]float64{
"False": 95,
"True": 5,
},
},
},
},
Deleted: map[string]flag.Flag{},
Updated: map[string]notifier.DiffUpdated{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -219,6 +259,7 @@ func Test_webhookNotifier_Notify(t *testing.T) {
Meta: map[string]string{"hostname": "toto"},
httpClient: mockHTTPClient,
init: sync.Once{},
Headers: tt.args.headers,
}

w := sync.WaitGroup{}
Expand All @@ -232,6 +273,9 @@ func Test_webhookNotifier_Notify(t *testing.T) {
content, _ := os.ReadFile(tt.expected.bodyPath)
assert.JSONEq(t, string(content), mockHTTPClient.Body)
assert.Equal(t, tt.expected.signature, mockHTTPClient.Signature)
if tt.expected.headers != nil {
assert.Equal(t, tt.expected.headers, mockHTTPClient.Headers)
}
}
})
}
Expand Down
2 changes: 2 additions & 0 deletions testutils/httpclient_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type HTTPClientMock struct {
StatusCode int
Body string
Signature string
Headers map[string][]string
}

func (h *HTTPClientMock) Do(req *http.Request) (*http.Response, error) {
Expand All @@ -25,6 +26,7 @@ func (h *HTTPClientMock) Do(req *http.Request) (*http.Response, error) {
resp := &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte(""))),
}
h.Headers = req.Header.Clone()
resp.StatusCode = h.StatusCode
return resp, nil
}
14 changes: 9 additions & 5 deletions website/docs/go_module/data_collection/webhook.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,22 @@ ffclient.Config{
Secret: "secret-for-signing",
Meta: map[string]string{
"extraInfo": "info",
},
Headers: map[string][]string{
"Authorization": {"Bearer auth_token"},
},
},
},
// ...
}
```
## Configuration fields
| Field | Description |
|---|---|
|`EndpointURL ` | EndpointURL of your webhook |
|`Secret ` | *(optional)*<br/>Secret used to sign your request body and fill the `X-Hub-Signature-256` header.<br/>See [signature section](#signature) for more details. |
|`Meta` | *(optional)*<br/>Add all the information you want to see in your request. |
| Field | Description |
|----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `EndpointURL ` | EndpointURL of your webhook |
| `Secret ` | *(optional)*<br/>Secret used to sign your request body and fill the `X-Hub-Signature-256` header.<br/>See [signature section](#signature) for more details. |
| `Meta` | *(optional)*<br/>Add all the information you want to see in your request. |
| `Headers` | *(optional)*<br/> the list of Headers to send to the endpoint |


## Webhook format
Expand Down
4 changes: 4 additions & 0 deletions website/docs/go_module/notifier/webhook.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ ffclient.Config{
Meta: map[string]string{
"app.name": "my app",
},
Headers: map[string][]string{
"Authorization": {"Bearer auth_token"},
},
},
// ...
},
Expand All @@ -31,6 +34,7 @@ ffclient.Config{
| `EndpointURL` | The complete URL of your API *(we will send a POST request to this URL, [see format](#format))* |
| `Secret` | *(optional)*<br/>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)*<br/>A list of key value that will be add in your request, this is super useful if you want to add information on the current running instance of your app.<br/><br/>**By default the hostname is always added in the meta information.** |
| `Headers` | *(optional)*<br/> the list of Headers to send to the endpoint |

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

0 comments on commit 50c5d39

Please sign in to comment.