Skip to content

Commit 84e7bb5

Browse files
authored
Merge pull request #225 from flashcatcloud/fix/warn-dropped-data-fields
fix(cli): warn when --data fields cannot be sent
2 parents b74a2db + 8e624fd commit 84e7bb5

42 files changed

Lines changed: 504 additions & 356 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎internal/cli/alert.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ Request fields:
386386
return err
387387
}
388388
req := new(flashduty.AlertMergeRequest)
389-
if err := genBindBody(body, req); err != nil {
389+
if err := genBindBody(ctx.Cmd.ErrOrStderr(), body, req); err != nil {
390390
return err
391391
}
392392
resp, err := ctx.Client.Alerts.WriteMerge(cmdContext(ctx.Cmd), req)

‎internal/cli/automation.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ func runAutomationFire(ctx *RunContext, triggerID, token, text, dataJSON string)
512512
return nil, err
513513
}
514514
req := new(flashduty.AutomationFireAPITriggerRequest)
515-
if err := genBindBody(body, req); err != nil {
515+
if err := genBindBody(ctx.Cmd.ErrOrStderr(), body, req); err != nil {
516516
return nil, err
517517
}
518518
token = strings.TrimSpace(token)

‎internal/cli/channel.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ func newChannelEscalateRuleListCmd() *cobra.Command {
197197
return err
198198
}
199199
req := new(flashduty.ChannelScopedListRequest)
200-
if err := genBindBody(body, req); err != nil {
200+
if err := genBindBody(ctx.Cmd.ErrOrStderr(), body, req); err != nil {
201201
return err
202202
}
203203
out, _, err := ctx.Client.Channels.ChannelEscalateRuleList(cmdContext(ctx.Cmd), req)

‎internal/cli/gen_support.go‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"maps"
89
"os"
910
"reflect"
11+
"slices"
1012
"strconv"
1113
"strings"
1214
"time"
@@ -215,7 +217,11 @@ func genFoldPositional(args []string, body map[string]any, wire, kind string) er
215217
// command un-driveable. After the json pass, additively bind any url-tagged
216218
// field from the body by its url wire-name. For POST structs the url pass is a
217219
// no-op, so existing behavior is unchanged.
218-
func genBindBody(body map[string]any, req any) error {
220+
//
221+
// json.Unmarshal discards body fields the request struct has no field for, so a
222+
// --data field newer than this build's SDK would vanish before the request is
223+
// sent while the call still succeeds. Each such field is named on warn instead.
224+
func genBindBody(warn io.Writer, body map[string]any, req any) error {
219225
var missing []string
220226
var inspect func(reflect.Type)
221227
inspect = func(rt reflect.Type) {
@@ -271,9 +277,103 @@ func genBindBody(body map[string]any, req any) error {
271277
return fmt.Errorf("failed to bind request: %w", err)
272278
}
273279
bindURLTagged(body, reflect.ValueOf(req))
280+
if dropped := unboundFields(body, reflect.TypeOf(req), ""); len(dropped) > 0 {
281+
_, _ = fmt.Fprintf(warn, "warning: --data fields not sent, unknown to this CLI version: %s\n",
282+
strings.Join(dropped, ", "))
283+
}
274284
return nil
275285
}
276286

287+
var jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]()
288+
289+
// unboundFields lists the paths in v (dotted, "[]" for array elements) that
290+
// json.Unmarshal into t discards because t has no field for them. Types that
291+
// decode themselves (json.RawMessage, time.Time) and interface/scalar targets
292+
// take whatever they are given, so nothing below them is reported.
293+
func unboundFields(v any, t reflect.Type, path string) []string {
294+
for t.Kind() == reflect.Pointer {
295+
t = t.Elem()
296+
}
297+
if reflect.PointerTo(t).Implements(jsonUnmarshalerType) {
298+
return nil
299+
}
300+
var out []string
301+
switch t.Kind() {
302+
case reflect.Struct:
303+
m, _ := v.(map[string]any)
304+
for _, k := range slices.Sorted(maps.Keys(m)) {
305+
p := joinFieldPath(path, k)
306+
ft, ok := wireField(t, k)
307+
if !ok {
308+
out = append(out, p)
309+
continue
310+
}
311+
out = append(out, unboundFields(m[k], ft, p)...)
312+
}
313+
case reflect.Map:
314+
m, _ := v.(map[string]any)
315+
for _, k := range slices.Sorted(maps.Keys(m)) {
316+
out = append(out, unboundFields(m[k], t.Elem(), joinFieldPath(path, k))...)
317+
}
318+
case reflect.Slice, reflect.Array:
319+
items, _ := v.([]any)
320+
for _, item := range items {
321+
out = append(out, unboundFields(item, t.Elem(), path+"[]")...)
322+
}
323+
slices.Sort(out)
324+
out = slices.Compact(out)
325+
}
326+
return out
327+
}
328+
329+
// wireField resolves a body key to the struct field json.Unmarshal (or, for GET
330+
// query structs, bindURLTagged) would fill: exact wire-name match first, then
331+
// encoding/json's case-insensitive fallback, descending into untagged embedded
332+
// structs.
333+
func wireField(t reflect.Type, key string) (reflect.Type, bool) {
334+
var fold reflect.Type
335+
var walk func(reflect.Type) (reflect.Type, bool)
336+
walk = func(t reflect.Type) (reflect.Type, bool) {
337+
for i := 0; i < t.NumField(); i++ {
338+
f := t.Field(i)
339+
tag := f.Tag.Get("json")
340+
if tag == "" {
341+
tag = f.Tag.Get("url")
342+
}
343+
name := strings.Split(tag, ",")[0]
344+
if f.Anonymous && name == "" {
345+
et := f.Type
346+
for et.Kind() == reflect.Pointer {
347+
et = et.Elem()
348+
}
349+
if et.Kind() == reflect.Struct {
350+
if ft, ok := walk(et); ok {
351+
return ft, true
352+
}
353+
continue
354+
}
355+
}
356+
if !f.IsExported() || name == "-" {
357+
continue
358+
}
359+
if name == "" {
360+
name = f.Name
361+
}
362+
if name == key {
363+
return f.Type, true
364+
}
365+
if fold == nil && strings.EqualFold(name, key) {
366+
fold = f.Type
367+
}
368+
}
369+
return nil, false
370+
}
371+
if ft, ok := walk(t); ok {
372+
return ft, true
373+
}
374+
return fold, fold != nil
375+
}
376+
277377
// bindURLTagged fills fields carrying a `url` struct tag (GET query params) from
278378
// the body map keyed by the url wire-name. json.Unmarshal cannot reach these
279379
// because they lack a json tag. The 6 GET request types use only int64/string

‎internal/cli/gen_support_test.go‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"strings"
89
"testing"
910
"unicode/utf8"
@@ -16,14 +17,60 @@ func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) {
1617
Value *bool `json:"value"`
1718
})
1819

19-
if err := genBindBody(map[string]any{"value": nil}, req); err != nil {
20+
if err := genBindBody(io.Discard, map[string]any{"value": nil}, req); err != nil {
2021
t.Fatalf("genBindBody required nullable field: %v", err)
2122
}
2223
if req.Value != nil {
2324
t.Fatalf("Value = %v, want nil", req.Value)
2425
}
2526
}
2627

28+
func TestGenBindBodyWarnsOnFieldsTheRequestCannotCarry(t *testing.T) {
29+
type target struct {
30+
Kind string `json:"kind"`
31+
Query json.RawMessage `json:"query,omitempty"`
32+
}
33+
type embedded struct {
34+
Team int64 `json:"team_id,omitempty"`
35+
}
36+
req := new(struct {
37+
embedded
38+
Name string `json:"name"`
39+
Targets []target `json:"targets,omitempty"`
40+
Labels map[string]target `json:"labels,omitempty"`
41+
Page int64 `url:"p,omitempty"`
42+
})
43+
body := map[string]any{
44+
"name": "r",
45+
"NAME": "folded",
46+
"team_id": 1,
47+
"p": 2,
48+
"extra": true,
49+
"targets": []any{
50+
map[string]any{"kind": "x", "purpose": "a", "query": map[string]any{"anything": 1}},
51+
map[string]any{"kind": "y", "purpose": "b"},
52+
},
53+
"labels": map[string]any{"k": map[string]any{"kind": "z", "note": "c"}},
54+
}
55+
56+
var warn bytes.Buffer
57+
if err := genBindBody(&warn, body, req); err != nil {
58+
t.Fatalf("genBindBody: %v", err)
59+
}
60+
want := "warning: --data fields not sent, unknown to this CLI version: extra, labels.k.note, targets[].purpose\n"
61+
if warn.String() != want {
62+
t.Fatalf("warning = %q, want %q", warn.String(), want)
63+
}
64+
65+
warn.Reset()
66+
if err := genBindBody(&warn, map[string]any{"name": "r", "targets": []any{map[string]any{"kind": "x"}}}, req); err != nil {
67+
t.Fatalf("genBindBody: %v", err)
68+
}
69+
if warn.Len() != 0 {
70+
t.Fatalf("warning on a fully bound body: %q", warn.String())
71+
}
72+
}
73+
2774
// oversizedInsightRows returns n insight-incident rows whose fat description
2875
// fields push any page of them well past compactListOutputLimit, plus the
2976
// incident IDs in row order so a test can tell emitted rows from dropped ones.

‎internal/cli/insight.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ Request fields:
303303
return err
304304
}
305305
req := new(flashduty.InsightFilter)
306-
if err := genBindBody(body, req); err != nil {
306+
if err := genBindBody(ctx.Cmd.ErrOrStderr(), body, req); err != nil {
307307
return err
308308
}
309309
resp, err := ctx.Client.Analytics.IncidentExport(cmdContext(ctx.Cmd), req)
@@ -382,8 +382,9 @@ func countCSVDataRows(raw []byte) (int, error) {
382382
// is verified against. Only the total is needed, so a single 1-item page is
383383
// fetched.
384384
func insightIncidentTotal(ctx *RunContext, body map[string]any) (int64, error) {
385+
// The export call already warned about this body's unsendable fields.
385386
req := new(flashduty.InsightIncidentListRequest)
386-
if err := genBindBody(body, req); err != nil {
387+
if err := genBindBody(io.Discard, body, req); err != nil {
387388
return 0, err
388389
}
389390
req.Page = 1

‎internal/cli/zz_generated_a2a_agents.go‎

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)