Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow customization of the response #177

Merged
merged 7 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/k6.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
check-latest: true
- name: Install k6
run: |
curl https://github.com/grafana/k6/releases/download/v0.45.0/k6-v0.45.0-linux-amd64.tar.gz -L | tar xvz --strip-components 1
curl https://github.com/grafana/k6/releases/download/v0.49.0/k6-v0.49.0-linux-amd64.tar.gz -L | tar xvz --strip-components 1
- name: Start application and run K6
continue-on-error: true
run: |
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ specs:
port: 6379
database: 0
key: example-webhook


# Response is the final step of the pipeline. It allows you to send a response
# to the webhook sender. You can use the built-in helper function to format it
# as you want. (Optional)
#
# In this example we send a JSON response with a 200 HTTP code and a custom
# content type header `application/json`. The response contains the deliveryID
# header value or `unknown` if not present in the request.
response:
formatting:
templateString: |
{
"deliveryID": "{{ .Request.Header | getHeader "X-Delivery" | default "unknown" }}"
}
httpCode: 200
contentType: application/json
```

More informations about security pipeline available on wiki : [Configuration/Security](https://github.com/42Atomys/webhooked/wiki/Security)
Expand Down
7 changes: 6 additions & 1 deletion config/webhooked.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ specs:
password:
valueFrom:
envRef: REDIS_PASSWORD
key: example-webhook
key: example-webhook
response:
formatting:
templateString: '{ "status": "ok" }'
httpCode: 200
contentType: application/json
19 changes: 13 additions & 6 deletions internal/config/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
currentConfig = &Configuration{}
// ErrSpecNotFound is returned when the spec is not found
ErrSpecNotFound = errors.New("spec not found")
// defaultTemplate is the default template for the payload
// defaultPayloadTemplate is the default template for the payload
// when no template is defined
defaultTemplate = `{{ .Payload }}`
defaultPayloadTemplate = `{{ .Payload }}`
// defaultResponseTemplate is the default template for the response
// when no template is defined
defaultResponseTemplate = ``
)

// Load loads the configuration from the configuration file
Expand Down Expand Up @@ -73,13 +76,17 @@
return err
}

if spec.Formatting, err = loadTemplate(spec.Formatting, nil); err != nil {
if spec.Formatting, err = loadTemplate(spec.Formatting, nil, defaultPayloadTemplate); err != nil {
return fmt.Errorf("configured storage for %s received an error: %s", spec.Name, err.Error())
}

if err = loadStorage(spec); err != nil {
return fmt.Errorf("configured storage for %s received an error: %s", spec.Name, err.Error())
}

if spec.Response.Formatting, err = loadTemplate(spec.Response.Formatting, nil, defaultResponseTemplate); err != nil {
return fmt.Errorf("configured response for %s received an error: %s", spec.Name, err.Error())
}

Check warning on line 89 in internal/config/configuration.go

View check run for this annotation

Codecov / codecov/patch

internal/config/configuration.go#L88-L89

Added lines #L88 - L89 were not covered by tests
}

log.Info().Msgf("Load %d configurations", len(currentConfig.Specs))
Expand Down Expand Up @@ -143,7 +150,7 @@
return fmt.Errorf("storage %s cannot be loaded properly: %s", s.Type, err.Error())
}

if s.Formatting, err = loadTemplate(s.Formatting, spec.Formatting); err != nil {
if s.Formatting, err = loadTemplate(s.Formatting, spec.Formatting, defaultPayloadTemplate); err != nil {
return fmt.Errorf("storage %s cannot be loaded properly: %s", s.Type, err.Error())
}
}
Expand All @@ -155,7 +162,7 @@
// loadTemplate loads the template for the given `spec`. When no spec is defined
// we try to load the template from the parentSpec and fallback to the default
// template if parentSpec is not given.
func loadTemplate(spec, parentSpec *FormattingSpec) (*FormattingSpec, error) {
func loadTemplate(spec, parentSpec *FormattingSpec, defaultTemplate string) (*FormattingSpec, error) {
if spec == nil {
spec = &FormattingSpec{}
}
Expand Down Expand Up @@ -185,7 +192,7 @@
if parentSpec != nil {
if parentSpec.Template == "" {
var err error
parentSpec, err = loadTemplate(parentSpec, nil)
parentSpec, err = loadTemplate(parentSpec, nil, defaultTemplate)
if err != nil {
return spec, err
}
Expand Down
4 changes: 2 additions & 2 deletions internal/config/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ func Test_loadTemplate(t *testing.T) {
nil,
nil,
false,
defaultTemplate,
defaultPayloadTemplate,
},
{
"template string",
Expand Down Expand Up @@ -317,7 +317,7 @@ func Test_loadTemplate(t *testing.T) {
}

for _, test := range tests {
tmpl, err := loadTemplate(test.input, test.parentSpec)
tmpl, err := loadTemplate(test.input, test.parentSpec, defaultPayloadTemplate)
if test.wantErr {
assert.Error(t, err, test.name)
} else {
Expand Down
16 changes: 16 additions & 0 deletions internal/config/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ type WebhookSpec struct {
// Storage is the configuration for the storage of the webhook spec
// It is defined by the user and can be empty.
Storage []*StorageSpec `mapstructure:"storage" json:"-"`
// Response is the configuration for the response of the webhook sent
// to the caller. It is defined by the user and can be empty.
Response ResponseSpec `mapstructure:"response" json:"-"`
}

type ResponseSpec struct {
// Formatting is used to define the response body sent by webhooked
// to the webhook caller. When this configuration is empty, no response
// body is sent. It is defined by the user and can be empty.
Formatting *FormattingSpec `mapstructure:"formatting" json:"-"`
// HTTPCode is the HTTP code of the response. It is defined by the user
// and can be empty. (default: 200)
HttpCode int `mapstructure:"httpCode" json:"httpCode"`
// ContentType is the content type of the response. It is defined by the user
// and can be empty. (default: plain/text)
ContentType string `mapstructure:"contentType" json:"contentType"`
}

// Security is the struct contains the configuration for a security
Expand Down
49 changes: 35 additions & 14 deletions internal/server/v1alpha1/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
// config is the current configuration of the server
config *config.Configuration
// webhookService is the function that will be called to process the webhook
webhookService func(s *Server, spec *config.WebhookSpec, r *http.Request) error
webhookService func(s *Server, spec *config.WebhookSpec, r *http.Request) (string, error)
// logger is the logger used by the server
logger zerolog.Logger
}
Expand Down Expand Up @@ -68,7 +68,8 @@
return
}

if err := s.webhookService(s, spec, r); err != nil {
responseBody, err := s.webhookService(s, spec, r)
if err != nil {
switch err {
case errSecurityFailed:
w.WriteHeader(http.StatusForbidden)
Expand All @@ -79,33 +80,49 @@
return
}
}

if responseBody != "" {
log.Debug().Str("response", responseBody).Msg("Webhook response")
if _, err := w.Write([]byte(responseBody)); err != nil {
s.logger.Error().Err(err).Msg("Error during response writing")
}

Check warning on line 88 in internal/server/v1alpha1/handlers.go

View check run for this annotation

Codecov / codecov/patch

internal/server/v1alpha1/handlers.go#L87-L88

Added lines #L87 - L88 were not covered by tests
}

if spec.Response.HttpCode != 0 {
w.WriteHeader(spec.Response.HttpCode)
}

if spec.Response.ContentType != "" {
w.Header().Set("Content-Type", spec.Response.ContentType)
}

s.logger.Debug().Str("entry", spec.Name).Msg("Webhook processed successfully")
}
}

// webhookService is the function that will be called to process the webhook call
// it will call the security pipeline if configured and store data on each configured
// storages
func webhookService(s *Server, spec *config.WebhookSpec, r *http.Request) (err error) {
func webhookService(s *Server, spec *config.WebhookSpec, r *http.Request) (responseTemplare string, err error) {
ctx := r.Context()

if spec == nil {
return config.ErrSpecNotFound
return "", config.ErrSpecNotFound
}

if r.Body == nil {
return errRequestBodyMissing
return "", errRequestBodyMissing
}
defer r.Body.Close()

data, err := io.ReadAll(r.Body)
if err != nil {
return err
return "", err

Check warning on line 120 in internal/server/v1alpha1/handlers.go

View check run for this annotation

Codecov / codecov/patch

internal/server/v1alpha1/handlers.go#L120

Added line #L120 was not covered by tests
}

if spec.HasSecurity() {
if err := s.runSecurity(spec, r, data); err != nil {
return err
return "", err
}
}

Expand All @@ -117,26 +134,30 @@
WithData("Config", config.Current())

for _, storage := range spec.Storage {
payloadFormatter = payloadFormatter.WithData("Storage", storage)
storageFormatter := *payloadFormatter.WithData("Storage", storage)

storagePayload, err := payloadFormatter.WithTemplate(storage.Formatting.Template).Render()
storagePayload, err := storageFormatter.WithTemplate(storage.Formatting.Template).Render()
if err != nil {
return err
return "", err
}

// update the formatter with the rendered payload of storage formatting
// this will allow to chain formatting
payloadFormatter.WithData("PreviousPayload", previousPayload)
ctx = formatting.ToContext(ctx, payloadFormatter)
storageFormatter.WithData("PreviousPayload", previousPayload)
ctx = formatting.ToContext(ctx, &storageFormatter)

log.Debug().Msgf("store following data: %s", storagePayload)
if err := storage.Client.Push(ctx, []byte(storagePayload)); err != nil {
return err
return "", err

Check warning on line 151 in internal/server/v1alpha1/handlers.go

View check run for this annotation

Codecov / codecov/patch

internal/server/v1alpha1/handlers.go#L151

Added line #L151 was not covered by tests
}
log.Debug().Str("storage", storage.Client.Name()).Msgf("stored successfully")
}

return err
if spec.Response.Formatting != nil && spec.Response.Formatting.Template != "" {
return payloadFormatter.WithTemplate(spec.Response.Formatting.Template).Render()
}

return "", err
}

// runSecurity will run the security pipeline for the current webhook call
Expand Down
46 changes: 39 additions & 7 deletions internal/server/v1alpha1/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestServer_WebhookHandler(t *testing.T) {
EntrypointURL: "/test",
}},
},
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) error { return expectedError },
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) (string, error) { return "", expectedError },
}).Code,
)

Expand All @@ -67,7 +67,27 @@ func TestServer_WebhookHandler(t *testing.T) {
EntrypointURL: "/test",
}},
},
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) error { return nil },
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) (string, error) { return "", nil },
}).Code,
)

assert.Equal(t,
http.StatusOK,
testServerWebhookHandlerHelper(t, &Server{
config: &config.Configuration{
APIVersion: "v1alpha1",
Specs: []*config.WebhookSpec{
{
Name: "test",
EntrypointURL: "/test",
Response: config.ResponseSpec{
Formatting: &config.FormattingSpec{Template: "test-payload"},
HttpCode: 200,
ContentType: "application/json",
},
}},
},
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) (string, error) { return "test-payload", nil },
}).Code,
)

Expand All @@ -82,7 +102,9 @@ func TestServer_WebhookHandler(t *testing.T) {
EntrypointURL: "/test",
}},
},
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) error { return errSecurityFailed },
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) (string, error) {
return "", errSecurityFailed
},
}).Code,
)

Expand All @@ -97,7 +119,7 @@ func TestServer_WebhookHandler(t *testing.T) {
EntrypointURL: "/test",
}},
},
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) error { return nil },
webhookService: func(s *Server, spec *config.WebhookSpec, r *http.Request) (string, error) { return "", nil },
}).Code,
)
}
Expand Down Expand Up @@ -162,21 +184,31 @@ func Test_webhookService(t *testing.T) {
{"empty security", &input{&config.WebhookSpec{
SecurityPipeline: factory.NewPipeline(),
}, req}, false, nil},

{"valid security", &input{&config.WebhookSpec{
SecurityPipeline: validPipeline,
}, req}, false, nil},
{"invalid security", &input{&config.WebhookSpec{
SecurityPipeline: invalidPipeline,
}, req}, true, errSecurityFailed},
{"valid payload with response", &input{
&config.WebhookSpec{
SecurityPipeline: validPipeline,
Response: config.ResponseSpec{
Formatting: &config.FormattingSpec{Template: "{{.Payload}}"},
HttpCode: 200,
ContentType: "application/json",
},
},
req,
}, false, nil},
{"invalid body payload", &input{&config.WebhookSpec{
SecurityPipeline: validPipeline,
}, invalidReq}, true, errRequestBodyMissing},
}

for _, test := range tests {
log.Warn().Msgf("body %+v", test.input.req.Body)
got := webhookService(&Server{}, test.input.spec, test.input.req)
_, got := webhookService(&Server{}, test.input.spec, test.input.req)
if test.wantErr {
assert.ErrorIs(got, test.matchErr, "input: %s", test.name)
} else {
Expand Down Expand Up @@ -233,7 +265,7 @@ func TestServer_webhokServiceStorage(t *testing.T) {
},
}

got := webhookService(&Server{}, spec, test.req)
_, got := webhookService(&Server{}, spec, test.req)
if test.wantErr {
assert.Error(t, got, "input: %s", test.name)
} else {
Expand Down
8 changes: 4 additions & 4 deletions pkg/formatting/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"net/http"
"sync"
"text/template"

"github.com/rs/zerolog/log"
)

type Formatter struct {
Expand Down Expand Up @@ -95,8 +93,6 @@ func (d *Formatter) Render() (string, error) {
return "", ErrNoTemplate
}

log.Debug().Msgf("rendering template: %s", d.tmplString)

t := template.New("formattingTmpl").Funcs(funcMap())
t, err := t.Parse(d.tmplString)
if err != nil {
Expand All @@ -108,6 +104,10 @@ func (d *Formatter) Render() (string, error) {
return "", fmt.Errorf("error while filling your template: %s", err.Error())
}

if buf.String() == "<no value>" {
return "", fmt.Errorf("template cannot be rendered, check your template")
}

return buf.String(), nil
}

Expand Down
Loading
Loading