feat(webhook): implement management API for incoming webhook endpoints
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Errors reported by the incoming webhook endpoint helpers. The HTTP layer maps
|
||||
// them onto statuses: exists -> 409, not found -> 404, invalid -> 400.
|
||||
var (
|
||||
// ErrWebhookEndpointExists is returned by AddWebhookEndpoint when the name
|
||||
// is already configured.
|
||||
ErrWebhookEndpointExists = errors.New("webhook endpoint already exists")
|
||||
|
||||
// ErrWebhookEndpointNotFound is returned when the named endpoint is not
|
||||
// configured.
|
||||
ErrWebhookEndpointNotFound = errors.New("webhook endpoint not found")
|
||||
|
||||
// ErrWebhookEndpointInvalid is returned when a name or field supplied for an
|
||||
// endpoint cannot be stored.
|
||||
ErrWebhookEndpointInvalid = errors.New("invalid webhook endpoint")
|
||||
)
|
||||
|
||||
// webhookEndpointFields are the endpoint keys a client may set. A field that is
|
||||
// absent from an update is left untouched; a field that is present but not
|
||||
// listed here is rejected rather than written, so a typo cannot leave an
|
||||
// endpoint silently ignoring a setting.
|
||||
var webhookEndpointFields = map[string]func(interface{}) bool{
|
||||
"enabled": isJSONBool,
|
||||
"token": isJSONString,
|
||||
"notifyPipes": isJSONStringArray,
|
||||
}
|
||||
|
||||
// WebhookEndpoints returns the configured incoming webhook endpoints keyed by
|
||||
// name, as a copy: changing the result does not change the loaded
|
||||
// configuration.
|
||||
func WebhookEndpoints() map[string]WebhookEndpointConfig {
|
||||
endpoints := map[string]WebhookEndpointConfig{}
|
||||
if C_globalConfig == nil {
|
||||
return endpoints
|
||||
}
|
||||
for name, endpoint := range C_globalConfig.Webhook.Endpoints {
|
||||
endpoints[name] = endpoint
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
// AddWebhookEndpoint registers a new incoming webhook endpoint under name. Only
|
||||
// the fields present in fields are set, so an endpoint can be created with
|
||||
// default values and completed later by ModifyWebhookEndpoint. Unlike
|
||||
// ModifyWebhookEndpoint it refuses to touch an endpoint that already exists.
|
||||
func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||
if err := validateWebhookEndpointName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
patch, err := webhookEndpointPatch(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if _, exists := webhookEndpoint(name); exists {
|
||||
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
}
|
||||
|
||||
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
||||
// fields present in fields are changed; every other field keeps its configured
|
||||
// value.
|
||||
func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||
if err := validateWebhookEndpointName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
patch, err := webhookEndpointPatch(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
}
|
||||
|
||||
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
|
||||
// name. The endpoint stops accepting requests as soon as the configuration is
|
||||
// reloaded.
|
||||
func DeleteWebhookEndpoint(name string) error {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
||||
}
|
||||
|
||||
// webhookEndpoint returns the named endpoint held by the loaded configuration.
|
||||
// No lock is needed to read it: a reload replaces the whole configuration
|
||||
// rather than mutating it in place, and the value is read from whichever
|
||||
// version is current.
|
||||
func webhookEndpoint(name string) (WebhookEndpointConfig, bool) {
|
||||
if C_globalConfig == nil {
|
||||
return WebhookEndpointConfig{}, false
|
||||
}
|
||||
endpoint, exists := C_globalConfig.Webhook.Endpoints[name]
|
||||
return endpoint, exists
|
||||
}
|
||||
|
||||
// webhookEndpointsPatch wraps the fields of one endpoint into the nested patch
|
||||
// the settings merge expects for webhook.endpoints.<name>.
|
||||
func webhookEndpointsPatch(name string, fields map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"webhook": map[string]interface{}{
|
||||
"endpoints": map[string]interface{}{name: fields},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// webhookEndpointDeletePatch is the patch that removes an endpoint. The value is
|
||||
// a null, which the settings merge reads as "delete this key". It must be an
|
||||
// untyped nil: a nil map of type map[string]interface{} would be merged as an
|
||||
// empty object instead, leaving the endpoint in the configuration.
|
||||
func webhookEndpointDeletePatch(name string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"webhook": map[string]interface{}{
|
||||
"endpoints": map[string]interface{}{name: nil},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// webhookEndpointPatch validates the fields of one endpoint and returns them as
|
||||
// the value to merge. Fields not accepted for an endpoint are rejected instead
|
||||
// of being written to the configuration file.
|
||||
func webhookEndpointPatch(fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
patch := make(map[string]interface{}, len(fields))
|
||||
for key, value := range fields {
|
||||
accepts, known := webhookEndpointFields[key]
|
||||
if !known {
|
||||
return nil, fmt.Errorf("%w: unknown field %q", ErrWebhookEndpointInvalid, key)
|
||||
}
|
||||
if !accepts(value) {
|
||||
return nil, fmt.Errorf("%w: field %q has the wrong type", ErrWebhookEndpointInvalid, key)
|
||||
}
|
||||
patch[key] = value
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
// validateWebhookEndpointName checks that a name can address an endpoint. The
|
||||
// name is the last segment of the endpoint URL, so a name containing a slash
|
||||
// could never be reached.
|
||||
func validateWebhookEndpointName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: name must not be empty", ErrWebhookEndpointInvalid)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return fmt.Errorf("%w: name must not contain %q", ErrWebhookEndpointInvalid, "/")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The predicates below accept the decoded JSON types a field may carry. Numbers
|
||||
// decoded with UseNumber stay json.Number, so a JSON true/false is the only
|
||||
// value accepted for a boolean field.
|
||||
func isJSONBool(value interface{}) bool {
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isJSONString(value interface{}) bool {
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isJSONStringArray(value interface{}) bool {
|
||||
items, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, ok := item.(string); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user