Skip to content

Commit

Permalink
feat: allow customization of the response (#177)
Browse files Browse the repository at this point in the history
**Describe the pull request**

This pull request introduce the possibility to set the response of the
webhook per spec using the formatting feature already present in the
app.

**Checklist**

- [x] I have linked the relative issue to this pull request
- [x] I have made the modifications or added tests related to my PR
- [x] I have added/updated the documentation for my RP
- [x] I put my PR in Ready for Review only when all the checklist is
checked

**Breaking changes ?**
no
  • Loading branch information
42atomys authored Mar 8, 2024
1 parent c95fb41 commit eac6a23
Show file tree
Hide file tree
Showing 16 changed files with 286 additions and 47 deletions.
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 @@ var (
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 @@ func Load(cfgFile string) error {
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())
}
}

log.Info().Msgf("Load %d configurations", len(currentConfig.Specs))
Expand Down Expand Up @@ -143,7 +150,7 @@ func loadStorage(spec *WebhookSpec) (err error) {
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 @@ func loadStorage(spec *WebhookSpec) (err error) {
// 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 @@ func loadTemplate(spec, parentSpec *FormattingSpec) (*FormattingSpec, error) {
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 @@ type Server struct {
// 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 @@ func (s *Server) WebhookHandler() http.HandlerFunc {
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 @@ func (s *Server) WebhookHandler() http.HandlerFunc {
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")
}
}

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
}

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

Expand All @@ -117,26 +134,30 @@ func webhookService(s *Server, spec *config.WebhookSpec, r *http.Request) (err e
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
}
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

0 comments on commit eac6a23

Please sign in to comment.