Skip to content

Commit 2ca2cdb

Browse files
leggetterclaude
andcommitted
feat(gateway): add the missing documented query filters to events and requests
The API documents seven public filters that neither the CLI nor the MCP tools exposed. Two of them answer questions that had no answer at all before: `--events-count 0` finds requests that produced no events, which is the usual explanation for a webhook that appears to have gone missing, and `--search-term` matches a value across body, headers, parsed query and path at once, for when you know the value but not which field carries it. Events gain search_term, delivery_group and next_attempt_at bounds; requests gain search_term and the events/ignored/cli event counts. The filters go on the plural, list-only MCP tools; the singular by-id tools keep their id and nothing else, which is the point of the pair. The spec also carries parameters marked x-docs-hide. Those are omitted deliberately, and a test on each surface pins that so they are not later added by someone reading the spec without the context. The tests assert the query string that goes on the wire, not the exit status: an unrecognised query parameter is ignored rather than rejected, so a misspelled filter key still exits zero and still prints results. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
1 parent eda57f9 commit 2ca2cdb

12 files changed

Lines changed: 613 additions & 10 deletions

REFERENCE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,9 @@ hookdeck gateway transformation executions get <transformation-id-or-name> <exec
15601560
15611561
List events (processed webhook deliveries). Filter by connection ID, source, destination, or status.
15621562
1563+
Use `--search-term` to match a value partially against the body, headers, parsed query or path
1564+
at once, when you know the value but not which field carries it.
1565+
15631566
**Usage:**
15641567
15651568
```bash
@@ -1576,6 +1579,7 @@ hookdeck gateway event list [flags]
15761579
| `--connection-id` | `string` | Filter by connection ID |
15771580
| `--created-after` | `string` | Filter events created after (ISO date-time) |
15781581
| `--created-before` | `string` | Filter events created before (ISO date-time) |
1582+
| `--delivery-group` | `string` | Filter by delivery group (comma-separated) |
15791583
| `--destination-id` | `string` | Filter by destination ID |
15801584
| `--dir` | `string` | Sort direction (asc, desc) |
15811585
| `--error-code` | `string` | Filter by error code |
@@ -1586,12 +1590,15 @@ hookdeck gateway event list [flags]
15861590
| `--last-attempt-at-before` | `string` | Filter by last_attempt_at before (ISO date-time) |
15871591
| `--limit` | `int` | Limit number of results (default "100") |
15881592
| `--next` | `string` | Pagination cursor for next page |
1593+
| `--next-attempt-at-after` | `string` | Filter by next_attempt_at after (ISO date-time) |
1594+
| `--next-attempt-at-before` | `string` | Filter by next_attempt_at before (ISO date-time) |
15891595
| `--order-by` | `string` | Sort key (e.g. created_at) |
15901596
| `--output` | `string` | Output format (json) |
15911597
| `--parsed-query` | `string` | Filter by parsed query (JSON string) |
15921598
| `--path` | `string` | Filter by path |
15931599
| `--prev` | `string` | Pagination cursor for previous page |
15941600
| `--response-status` | `string` | Filter by HTTP response status (e.g. 200, 500) |
1601+
| `--search-term` | `string` | Partial match against body, headers, parsed query or path (min 3 characters) |
15951602
| `--source-id` | `string` | Filter by source ID |
15961603
| `--status` | `string` | Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED) |
15971604
| `--successful-at-after` | `string` | Filter by successful_at after (ISO date-time) |
@@ -1603,6 +1610,8 @@ hookdeck gateway event list [flags]
16031610
hookdeck gateway event list
16041611
hookdeck gateway event list --connection-id web_abc123
16051612
hookdeck gateway event list --status FAILED --limit 20
1613+
hookdeck gateway event list --search-term cus_1234
1614+
hookdeck gateway event list --status QUEUED --next-attempt-at-before 2026-01-01T00:00:00Z
16061615
```
16071616
### hookdeck gateway event get
16081617
@@ -1700,6 +1709,10 @@ hookdeck gateway event raw-body evt_abc123
17001709
17011710
List requests (raw inbound webhooks). Filter by source ID.
17021711
1712+
Use `--search-term` to match a value partially against the body, headers, parsed query or path
1713+
at once. `--events-count` 0 finds requests that produced no events, which is the usual reason a
1714+
webhook appears to have gone missing.
1715+
17031716
**Usage:**
17041717
17051718
```bash
@@ -1711,11 +1724,14 @@ hookdeck gateway request list [flags]
17111724
| Flag | Type | Description |
17121725
|------|------|-------------|
17131726
| `--body` | `string` | Filter by body (JSON string) |
1727+
| `--cli-events-count` | `string` | Filter by number of CLI events (integer or operators) |
17141728
| `--created-after` | `string` | Filter requests created after (ISO date-time) |
17151729
| `--created-before` | `string` | Filter requests created before (ISO date-time) |
17161730
| `--dir` | `string` | Sort direction (asc, desc) |
1731+
| `--events-count` | `string` | Filter by number of events produced (integer or operators) |
17171732
| `--headers` | `string` | Filter by headers (JSON string) |
17181733
| `--id` | `string` | Filter by request ID(s) (comma-separated) |
1734+
| `--ignored-count` | `string` | Filter by number of ignored events (integer or operators) |
17191735
| `--ingested-at-after` | `string` | Filter by ingested_at after (ISO date-time) |
17201736
| `--ingested-at-before` | `string` | Filter by ingested_at before (ISO date-time) |
17211737
| `--limit` | `int` | Limit number of results (default "100") |
@@ -1726,6 +1742,7 @@ hookdeck gateway request list [flags]
17261742
| `--path` | `string` | Filter by path |
17271743
| `--prev` | `string` | Pagination cursor for previous page |
17281744
| `--rejection-cause` | `string` | Filter by rejection cause |
1745+
| `--search-term` | `string` | Partial match against body, headers, parsed query or path (min 3 characters) |
17291746
| `--source-id` | `string` | Filter by source ID |
17301747
| `--status` | `string` | Filter by status |
17311748
| `--verified` | `string` | Filter by verified (true/false) |
@@ -1735,6 +1752,8 @@ hookdeck gateway request list [flags]
17351752
```bash
17361753
hookdeck gateway request list
17371754
hookdeck gateway request list --source-id src_abc123 --limit 20
1755+
hookdeck gateway request list --search-term cus_1234
1756+
hookdeck gateway request list --events-count 0
17381757
```
17391758
### hookdeck gateway request get
17401759

pkg/cmd/event_list.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,20 @@ type eventListCmd struct {
2525
errorCode string
2626
cliID string
2727
issueID string
28+
deliveryGroup string
2829
createdAfter string
2930
createdBefore string
3031
successfulAfter string
3132
successfulBefore string
3233
lastAttemptAfter string
3334
lastAttemptBefore string
35+
nextAttemptAfter string
36+
nextAttemptBefore string
3437
headers string
3538
body string
3639
path string
3740
parsedQuery string
41+
searchTerm string
3842
orderBy string
3943
dir string
4044
limit int
@@ -52,10 +56,15 @@ func newEventListCmd() *eventListCmd {
5256
Short: ShortList(ResourceEvent),
5357
Long: `List events (processed webhook deliveries). Filter by connection ID, source, destination, or status.
5458
59+
Use --search-term to match a value partially against the body, headers, parsed query or path
60+
at once, when you know the value but not which field carries it.
61+
5562
Examples:
5663
hookdeck gateway event list
5764
hookdeck gateway event list --connection-id web_abc123
58-
hookdeck gateway event list --status FAILED --limit 20`,
65+
hookdeck gateway event list --status FAILED --limit 20
66+
hookdeck gateway event list --search-term cus_1234
67+
hookdeck gateway event list --status QUEUED --next-attempt-at-before 2026-01-01T00:00:00Z`,
5968
RunE: ec.runEventListCmd,
6069
}
6170

@@ -69,16 +78,20 @@ Examples:
6978
ec.cmd.Flags().StringVar(&ec.errorCode, "error-code", "", "Filter by error code")
7079
ec.cmd.Flags().StringVar(&ec.cliID, "cli-id", "", "Filter by CLI ID")
7180
ec.cmd.Flags().StringVar(&ec.issueID, "issue-id", "", "Filter by issue ID")
81+
ec.cmd.Flags().StringVar(&ec.deliveryGroup, "delivery-group", "", "Filter by delivery group (comma-separated)")
7282
ec.cmd.Flags().StringVar(&ec.createdAfter, "created-after", "", "Filter events created after (ISO date-time)")
7383
ec.cmd.Flags().StringVar(&ec.createdBefore, "created-before", "", "Filter events created before (ISO date-time)")
7484
ec.cmd.Flags().StringVar(&ec.successfulAfter, "successful-at-after", "", "Filter by successful_at after (ISO date-time)")
7585
ec.cmd.Flags().StringVar(&ec.successfulBefore, "successful-at-before", "", "Filter by successful_at before (ISO date-time)")
7686
ec.cmd.Flags().StringVar(&ec.lastAttemptAfter, "last-attempt-at-after", "", "Filter by last_attempt_at after (ISO date-time)")
7787
ec.cmd.Flags().StringVar(&ec.lastAttemptBefore, "last-attempt-at-before", "", "Filter by last_attempt_at before (ISO date-time)")
88+
ec.cmd.Flags().StringVar(&ec.nextAttemptAfter, "next-attempt-at-after", "", "Filter by next_attempt_at after (ISO date-time)")
89+
ec.cmd.Flags().StringVar(&ec.nextAttemptBefore, "next-attempt-at-before", "", "Filter by next_attempt_at before (ISO date-time)")
7890
ec.cmd.Flags().StringVar(&ec.headers, "headers", "", "Filter by headers (JSON string)")
7991
ec.cmd.Flags().StringVar(&ec.body, "body", "", "Filter by body (JSON string)")
8092
ec.cmd.Flags().StringVar(&ec.path, "path", "", "Filter by path")
8193
ec.cmd.Flags().StringVar(&ec.parsedQuery, "parsed-query", "", "Filter by parsed query (JSON string)")
94+
ec.cmd.Flags().StringVar(&ec.searchTerm, "search-term", "", "Partial match against body, headers, parsed query or path (min 3 characters)")
8295
ec.cmd.Flags().StringVar(&ec.orderBy, "order-by", "", "Sort key (e.g. created_at)")
8396
ec.cmd.Flags().StringVar(&ec.dir, "dir", "", "Sort direction (asc, desc)")
8497
ec.cmd.Flags().IntVar(&ec.limit, "limit", 100, "Limit number of results")
@@ -126,6 +139,9 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error
126139
if ec.issueID != "" {
127140
params["issue_id"] = ec.issueID
128141
}
142+
if ec.deliveryGroup != "" {
143+
params["delivery_group"] = ec.deliveryGroup
144+
}
129145
if ec.createdAfter != "" {
130146
params["created_at[gte]"] = ec.createdAfter
131147
}
@@ -144,6 +160,12 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error
144160
if ec.lastAttemptBefore != "" {
145161
params["last_attempt_at[lte]"] = ec.lastAttemptBefore
146162
}
163+
if ec.nextAttemptAfter != "" {
164+
params["next_attempt_at[gte]"] = ec.nextAttemptAfter
165+
}
166+
if ec.nextAttemptBefore != "" {
167+
params["next_attempt_at[lte]"] = ec.nextAttemptBefore
168+
}
147169
if ec.headers != "" {
148170
params["headers"] = ec.headers
149171
}
@@ -156,6 +178,9 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error
156178
if ec.parsedQuery != "" {
157179
params["parsed_query"] = ec.parsedQuery
158180
}
181+
if ec.searchTerm != "" {
182+
params["search_term"] = ec.searchTerm
183+
}
159184
if ec.orderBy != "" {
160185
params["order_by"] = ec.orderBy
161186
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"testing"
10+
11+
"github.com/spf13/cobra"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/hookdeck/hookdeck-cli/pkg/config"
16+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
17+
)
18+
19+
// captureListQuery runs a gateway list command against a stub API and returns
20+
// the query string it sent.
21+
//
22+
// A filter flag is only worth having if it reaches the API under the key the
23+
// API expects. Asserting the command exits zero proves nothing here: an
24+
// unrecognised query parameter is ignored by the API, so a misspelled key
25+
// silently returns the unfiltered list — the worst possible failure for a
26+
// filter, because the answer looks right.
27+
func captureListQuery(t *testing.T, args ...string) url.Values {
28+
t.Helper()
29+
30+
var got url.Values
31+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
w.Header().Set("Content-Type", "application/json")
33+
34+
if r.URL.Path == hookdeck.APIPathPrefix+"/cli-auth/validate" {
35+
_ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{
36+
ProjectID: "proj_1",
37+
ProjectMode: "inbound",
38+
})
39+
return
40+
}
41+
42+
got = r.URL.Query()
43+
_, _ = w.Write([]byte(`{"models":[],"pagination":{"limit":100}}`))
44+
}))
45+
t.Cleanup(server.Close)
46+
47+
config.ResetAPIClientForTesting()
48+
t.Cleanup(config.ResetAPIClientForTesting)
49+
50+
old := Config
51+
t.Cleanup(func() { Config = old })
52+
Config = config.Config{APIBaseURL: server.URL, LogLevel: "info"}
53+
Config.Profile.APIKey = "sk_test_123456789012"
54+
Config.Profile.ProjectId = "proj_1"
55+
Config.Profile.ProjectType = config.ProjectTypeGateway
56+
57+
root := &cobra.Command{Use: "hookdeck", SilenceUsage: true, SilenceErrors: true}
58+
root.AddCommand(newGatewayCmd().cmd)
59+
root.SetArgs(args)
60+
root.SetOut(io.Discard)
61+
root.SetErr(io.Discard)
62+
63+
require.NoError(t, root.Execute())
64+
require.NotNil(t, got, "the command made no list request")
65+
return got
66+
}
67+
68+
func TestEventListSendsNewFilters(t *testing.T) {
69+
query := captureListQuery(t,
70+
"gateway", "event", "list",
71+
"--search-term", "cus_1234",
72+
"--delivery-group", "grp_1",
73+
"--next-attempt-at-after", "2026-06-01T00:00:00Z",
74+
"--next-attempt-at-before", "2026-06-30T00:00:00Z",
75+
)
76+
77+
assert.Equal(t, "cus_1234", query.Get("search_term"))
78+
assert.Equal(t, "grp_1", query.Get("delivery_group"))
79+
// The date filters are operator keys, not plain fields: a bare
80+
// next_attempt_at= would be a different query the API rejects.
81+
assert.Equal(t, "2026-06-01T00:00:00Z", query.Get("next_attempt_at[gte]"))
82+
assert.Equal(t, "2026-06-30T00:00:00Z", query.Get("next_attempt_at[lte]"))
83+
assert.Empty(t, query.Get("next_attempt_at"))
84+
}
85+
86+
func TestRequestListSendsNewFilters(t *testing.T) {
87+
query := captureListQuery(t,
88+
"gateway", "request", "list",
89+
"--search-term", "cus_1234",
90+
"--events-count", "0",
91+
"--ignored-count", "2",
92+
"--cli-events-count", "1",
93+
)
94+
95+
assert.Equal(t, "cus_1234", query.Get("search_term"))
96+
// events_count=0 — requests that produced no events — is the query that
97+
// explains a "missing" webhook, so the zero has to survive to the wire.
98+
assert.Equal(t, "0", query.Get("events_count"))
99+
assert.Equal(t, "2", query.Get("ignored_count"))
100+
assert.Equal(t, "1", query.Get("cli_events_count"))
101+
}
102+
103+
// Omitted filters must not be sent at all. An empty value is not the same as no
104+
// filter to every API, and sending one narrows or widens the query by accident.
105+
func TestListFiltersAreOmittedWhenUnset(t *testing.T) {
106+
t.Run("event", func(t *testing.T) {
107+
query := captureListQuery(t, "gateway", "event", "list")
108+
for _, key := range []string{
109+
"search_term", "delivery_group", "next_attempt_at[gte]", "next_attempt_at[lte]",
110+
} {
111+
assert.NotContains(t, query, key)
112+
}
113+
})
114+
115+
t.Run("request", func(t *testing.T) {
116+
query := captureListQuery(t, "gateway", "request", "list")
117+
for _, key := range []string{
118+
"search_term", "events_count", "ignored_count", "cli_events_count",
119+
} {
120+
assert.NotContains(t, query, key)
121+
}
122+
})
123+
}
124+
125+
// The API spec documents further parameters carrying x-docs-hide: Hookdeck
126+
// keeps them out of its public documentation deliberately, so the CLI must not
127+
// offer them either. Read without that context they look like filters we simply
128+
// forgot, which is how they would get added — this pins the omission as
129+
// intentional. The MCP side is pinned by
130+
// TestPluralToolsOmitParametersHiddenFromTheAPIDocs.
131+
func TestListCommandsOmitFlagsHiddenFromTheAPIDocs(t *testing.T) {
132+
root := &cobra.Command{Use: "hookdeck"}
133+
root.AddCommand(newGatewayCmd().cmd)
134+
135+
cases := map[string][]string{
136+
"event": {"bulk-retry-id", "include", "progressive", "event-data-id", "cli-user-id"},
137+
"request": {"bulk-retry-id", "include", "progressive"},
138+
}
139+
140+
for resource, hidden := range cases {
141+
t.Run(resource, func(t *testing.T) {
142+
cmd, _, err := root.Find([]string{"gateway", resource, "list"})
143+
require.NoError(t, err)
144+
require.Equal(t, "list", cmd.Name())
145+
146+
for _, name := range hidden {
147+
assert.Nil(t, cmd.Flags().Lookup(name),
148+
"gateway %s list must not offer --%s: it carries x-docs-hide in the API spec",
149+
resource, name)
150+
}
151+
})
152+
}
153+
}

pkg/cmd/request_list.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type requestListCmd struct {
2828
body string
2929
path string
3030
parsedQuery string
31+
searchTerm string
32+
eventsCount string
33+
ignoredCount string
34+
cliEventsCount string
3135
orderBy string
3236
dir string
3337
limit int
@@ -45,9 +49,15 @@ func newRequestListCmd() *requestListCmd {
4549
Short: ShortList(ResourceRequest),
4650
Long: `List requests (raw inbound webhooks). Filter by source ID.
4751
52+
Use --search-term to match a value partially against the body, headers, parsed query or path
53+
at once. --events-count 0 finds requests that produced no events, which is the usual reason a
54+
webhook appears to have gone missing.
55+
4856
Examples:
4957
hookdeck gateway request list
50-
hookdeck gateway request list --source-id src_abc123 --limit 20`,
58+
hookdeck gateway request list --source-id src_abc123 --limit 20
59+
hookdeck gateway request list --search-term cus_1234
60+
hookdeck gateway request list --events-count 0`,
5161
RunE: rc.runRequestListCmd,
5262
}
5363

@@ -64,6 +74,10 @@ Examples:
6474
rc.cmd.Flags().StringVar(&rc.body, "body", "", "Filter by body (JSON string)")
6575
rc.cmd.Flags().StringVar(&rc.path, "path", "", "Filter by path")
6676
rc.cmd.Flags().StringVar(&rc.parsedQuery, "parsed-query", "", "Filter by parsed query (JSON string)")
77+
rc.cmd.Flags().StringVar(&rc.searchTerm, "search-term", "", "Partial match against body, headers, parsed query or path (min 3 characters)")
78+
rc.cmd.Flags().StringVar(&rc.eventsCount, "events-count", "", "Filter by number of events produced (integer or operators)")
79+
rc.cmd.Flags().StringVar(&rc.ignoredCount, "ignored-count", "", "Filter by number of ignored events (integer or operators)")
80+
rc.cmd.Flags().StringVar(&rc.cliEventsCount, "cli-events-count", "", "Filter by number of CLI events (integer or operators)")
6781
rc.cmd.Flags().StringVar(&rc.orderBy, "order-by", "", "Sort key (e.g. created_at)")
6882
rc.cmd.Flags().StringVar(&rc.dir, "dir", "", "Sort direction (asc, desc)")
6983
rc.cmd.Flags().IntVar(&rc.limit, "limit", 100, "Limit number of results")
@@ -120,6 +134,18 @@ func (rc *requestListCmd) runRequestListCmd(cmd *cobra.Command, args []string) e
120134
if rc.parsedQuery != "" {
121135
params["parsed_query"] = rc.parsedQuery
122136
}
137+
if rc.searchTerm != "" {
138+
params["search_term"] = rc.searchTerm
139+
}
140+
if rc.eventsCount != "" {
141+
params["events_count"] = rc.eventsCount
142+
}
143+
if rc.ignoredCount != "" {
144+
params["ignored_count"] = rc.ignoredCount
145+
}
146+
if rc.cliEventsCount != "" {
147+
params["cli_events_count"] = rc.cliEventsCount
148+
}
123149
if rc.orderBy != "" {
124150
params["order_by"] = rc.orderBy
125151
}

0 commit comments

Comments
 (0)