Skip to content
Open
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,8 @@ $ hookdeck gateway connection create \
--rule-retry-count 3
```

Rules built from `--rule-*` flags follow the order in which each rule type's first flag appears. Filter, transform and deduplicate rules run in that order, so put `--rule-filter-*` before `--rule-transform-*` to filter on the original payload. To set the whole array explicitly, use `--rules` or `--rules-file`.

#### Configure rate limiting

Control the rate of event delivery to your destination:
Expand Down
86 changes: 77 additions & 9 deletions pkg/cmd/connection_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
)
Expand Down Expand Up @@ -64,6 +65,58 @@ type connectionRuleFlags struct {
RuleDeduplicateWindow int
RuleDeduplicateIncludeFields string
RuleDeduplicateExcludeFields string

// ruleOrder records rule types in the order their first --rule-<type>-* flag
// appeared on the command line. Filter, transform and deduplicate run in
// rules array order, so the flags must not impose a fixed order.
ruleOrder []string
}

// defaultRuleOrder is used for rule types with no recorded flag position, such as
// when connectionRuleFlags is populated directly rather than from the command line.
var defaultRuleOrder = []string{"deduplicate", "transform", "filter", "delay", "retry"}

// ruleFlagTypes maps each individual rule flag to the rule type it configures.
var ruleFlagTypes = map[string]string{
"rule-retry-strategy": "retry",
"rule-retry-count": "retry",
"rule-retry-interval": "retry",
"rule-retry-response-status-codes": "retry",
"rule-filter-body": "filter",
"rule-filter-headers": "filter",
"rule-filter-query": "filter",
"rule-filter-path": "filter",
"rule-transform-name": "transform",
"rule-transform-code": "transform",
"rule-transform-env": "transform",
"rule-delay": "delay",
"rule-deduplicate-window": "deduplicate",
"rule-deduplicate-include-fields": "deduplicate",
"rule-deduplicate-exclude-fields": "deduplicate",
}

// recordRuleType appends ruleType to the recorded order unless it is already present.
func (f *connectionRuleFlags) recordRuleType(ruleType string) {
for _, t := range f.ruleOrder {
if t == ruleType {
return
}
}
f.ruleOrder = append(f.ruleOrder, ruleType)
}

// orderTrackingValue wraps a flag value so that setting it records the flag's rule type.
type orderTrackingValue struct {
pflag.Value
onSet func()
}

func (v *orderTrackingValue) Set(s string) error {
if err := v.Value.Set(s); err != nil {
return err
}
v.onSet()
return nil
}

// addConnectionRuleFlags binds rule flags to cmd. Pass a pointer to the flags struct
Expand Down Expand Up @@ -91,11 +144,18 @@ func addConnectionRuleFlags(cmd *cobra.Command, f *connectionRuleFlags) {
cmd.Flags().IntVar(&f.RuleDeduplicateWindow, "rule-deduplicate-window", 0, "Time window in seconds for deduplication")
cmd.Flags().StringVar(&f.RuleDeduplicateIncludeFields, "rule-deduplicate-include-fields", "", "Comma-separated list of fields to include for deduplication")
cmd.Flags().StringVar(&f.RuleDeduplicateExcludeFields, "rule-deduplicate-exclude-fields", "", "Comma-separated list of fields to exclude for deduplication")

for name, ruleType := range ruleFlagTypes {
flag := cmd.Flags().Lookup(name)
ruleType := ruleType
flag.Value = &orderTrackingValue{Value: flag.Value, onSet: func() { f.recordRuleType(ruleType) }}
}
}

// buildConnectionRules builds a slice of rules from connectionRuleFlags.
// If rulesStr or rulesFile is non-empty, those are parsed as JSON and returned;
// otherwise individual rule flags are assembled into rules.
// otherwise individual rule flags are assembled into rules, ordered by the
// position of the first flag for each rule type.
// Shared by connection update and (for consistency) can be used by create/upsert.
func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
if f.Rules != "" {
Expand All @@ -118,8 +178,8 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
return normalizeRulesForAPI(rules), nil
}

// Build each rule type (order matches create: deduplicate -> transform -> filter -> delay -> retry)
var rules []hookdeck.Rule
// Build each rule type, then order them by flag position
built := make(map[string]hookdeck.Rule)

if f.RuleDeduplicateWindow > 0 {
rule := hookdeck.Rule{
Expand All @@ -132,7 +192,7 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
if f.RuleDeduplicateExcludeFields != "" {
rule["exclude_fields"] = strings.Split(f.RuleDeduplicateExcludeFields, ",")
}
rules = append(rules, rule)
built["deduplicate"] = rule
}

hasTransform := f.RuleTransformName != "" || f.RuleTransformCode != "" || f.RuleTransformEnv != ""
Expand All @@ -153,7 +213,7 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
transformConfig["env"] = env
}
rule["transformation"] = transformConfig
rules = append(rules, rule)
built["transform"] = rule
}

if f.RuleFilterBody != "" || f.RuleFilterHeaders != "" || f.RuleFilterQuery != "" || f.RuleFilterPath != "" {
Expand All @@ -170,14 +230,14 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
if f.RuleFilterPath != "" {
rule["path"] = parseJSONOrString(f.RuleFilterPath)
}
rules = append(rules, rule)
built["filter"] = rule
}

if f.RuleDelay > 0 {
rules = append(rules, hookdeck.Rule{
built["delay"] = hookdeck.Rule{
"type": "delay",
"delay": f.RuleDelay,
})
}
}

if f.RuleRetryStrategy != "" {
Expand Down Expand Up @@ -211,7 +271,15 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
}
rule["response_status_codes"] = strCodes
}
rules = append(rules, rule)
built["retry"] = rule
}

var rules []hookdeck.Rule
for _, ruleType := range append(append([]string{}, f.ruleOrder...), defaultRuleOrder...) {
if rule, ok := built[ruleType]; ok {
rules = append(rules, rule)
delete(built, ruleType)
}
}

return rules, nil
Expand Down
134 changes: 134 additions & 0 deletions pkg/cmd/connection_rule_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package cmd

import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
)

// parseRuleFlags binds the rule flags to a throwaway command and parses args,
// so flag values are set the same way cobra sets them at runtime.
func parseRuleFlags(t *testing.T, args []string) *connectionRuleFlags {
t.Helper()
f := &connectionRuleFlags{}
cmd := &cobra.Command{Use: "test"}
addConnectionRuleFlags(cmd, f)
require.NoError(t, cmd.ParseFlags(args))
return f
}

func ruleTypes(rules []hookdeck.Rule) []string {
types := make([]string, 0, len(rules))
for _, r := range rules {
types = append(types, r["type"].(string))
}
return types
}

// TestBuildConnectionRulesFollowsFlagOrder verifies that rules built from
// --rule-<type>-* flags follow the position of the first flag for each type.
// Filter, transform and deduplicate run in rules array order, so a fixed
// order made filter-before-transform impossible to express with flags.
func TestBuildConnectionRulesFollowsFlagOrder(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{
name: "filter before transform",
args: []string{"--rule-filter-body", `{"type":"order"}`, "--rule-transform-name", "tx1"},
want: []string{"filter", "transform"},
},
{
name: "transform before filter",
args: []string{"--rule-transform-name", "tx1", "--rule-filter-body", `{"type":"order"}`},
want: []string{"transform", "filter"},
},
{
name: "first flag of a type sets its position",
args: []string{
"--rule-filter-body", `{"type":"order"}`,
"--rule-transform-name", "tx1",
"--rule-filter-headers", `{"x-topic":"orders/create"}`,
},
want: []string{"filter", "transform"},
},
{
name: "all five types in flag order",
args: []string{
"--rule-retry-strategy", "exponential",
"--rule-filter-body", `{"type":"order"}`,
"--rule-delay", "1000",
"--rule-transform-name", "tx1",
"--rule-deduplicate-window", "60",
},
want: []string{"retry", "filter", "delay", "transform", "deduplicate"},
},
{
name: "retry position set by a non-strategy retry flag",
args: []string{"--rule-retry-count", "3", "--rule-filter-body", `{"type":"order"}`, "--rule-retry-strategy", "linear"},
want: []string{"retry", "filter"},
},
{
name: "zero-value flag records a position but builds no rule",
args: []string{"--rule-delay", "0", "--rule-filter-body", `{"type":"order"}`},
want: []string{"filter"},
},
{
name: "zero-value flag position kept when a later flag sets the rule",
args: []string{"--rule-delay", "0", "--rule-filter-body", `{"type":"order"}`, "--rule-delay", "500"},
want: []string{"delay", "filter"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rules, err := buildConnectionRules(parseRuleFlags(t, tt.args))
require.NoError(t, err)
assert.Equal(t, tt.want, ruleTypes(rules))
})
}
}

// TestBuildConnectionRulesDefaultOrderWithoutFlags verifies the fallback order
// when connectionRuleFlags is populated directly rather than parsed from flags.
func TestBuildConnectionRulesDefaultOrderWithoutFlags(t *testing.T) {
flags := connectionRuleFlags{
RuleRetryStrategy: "linear",
RuleFilterBody: `{"type":"order"}`,
RuleTransformName: "tx1",
RuleDelay: 1000,
RuleDeduplicateWindow: 60,
}
rules, err := buildConnectionRules(&flags)
require.NoError(t, err)
assert.Equal(t, []string{"deduplicate", "transform", "filter", "delay", "retry"}, ruleTypes(rules))
}

// TestConnectionCommandsRuleFlagOrder verifies that create, update and upsert
// all record rule flag order through their own flag sets.
func TestConnectionCommandsRuleFlagOrder(t *testing.T) {
args := []string{"--rule-filter-body", `{"type":"order"}`, "--rule-transform-name", "tx1"}

create := newConnectionCreateCmd()
require.NoError(t, create.cmd.ParseFlags(args))
rules, err := buildConnectionRules(&create.connectionRuleFlags)
require.NoError(t, err)
assert.Equal(t, []string{"filter", "transform"}, ruleTypes(rules), "create")

update := newConnectionUpdateCmd()
require.NoError(t, update.cmd.ParseFlags(args))
rules, err = buildConnectionRules(&update.connectionRuleFlags)
require.NoError(t, err)
assert.Equal(t, []string{"filter", "transform"}, ruleTypes(rules), "update")

upsert := newConnectionUpsertCmd()
require.NoError(t, upsert.cmd.ParseFlags(args))
rules, err = buildConnectionRules(&upsert.connectionCreateCmd.connectionRuleFlags)
require.NoError(t, err)
assert.Equal(t, []string{"filter", "transform"}, ruleTypes(rules), "upsert")
}
2 changes: 1 addition & 1 deletion pkg/gateway/mcp/tool_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ var connectionsSpec = mcpcore.ToolSpec{
{On: []string{"list"}, Text: "Filters on %s."},
{On: []string{"create", "upsert", "update"}, Text: "Links the destination on %s."},
}},
"rules": {Type: "array", Desc: "Ruleset applied to the connection (create/upsert/update). Array of rule objects; replaces the stored ruleset.", Items: &mcpcore.Prop{Type: "object"}, Write: true, Actions: []string{"create", "upsert", "update"}},
"rules": {Type: "array", Desc: "Ruleset applied to the connection (create/upsert/update). Array of rule objects; replaces the stored ruleset. Order matters: filter, transform and deduplicate rules run in array order, so send the full array in the intended order.", Items: &mcpcore.Prop{Type: "object"}, Write: true, Actions: []string{"create", "upsert", "update"}},
"disabled": {Type: "boolean", Desc: "Filter disabled connections (list)", Only: []string{mcpcore.GroupRead}, Actions: []string{"list"}},
"limit": {Type: "integer", Desc: "Max results (list)", Only: []string{mcpcore.GroupRead}, Actions: []string{"list"}},
"next": {Type: "string", Desc: "Next page cursor", Only: []string{mcpcore.GroupRead}, Actions: []string{"list"}},
Expand Down
Loading
Loading