Skip to content

Commit 94fc168

Browse files
authored
Merge pull request #111 from flashcatcloud/codex/cli-v1.3.31-sdk
feat: sync SDK and validate required request fields
2 parents 584a63c + 155ee72 commit 94fc168

9 files changed

Lines changed: 222 additions & 5 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/go-flashduty v0.5.8
6+
github.com/flashcatcloud/go-flashduty v0.5.9
77
github.com/mattn/go-runewidth v0.0.24
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/go-flashduty v0.5.8 h1:hOQtseanaASXrpQsFfzuSonBK5EzoGQWdhzsZO4pWNQ=
5-
github.com/flashcatcloud/go-flashduty v0.5.8/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
4+
github.com/flashcatcloud/go-flashduty v0.5.9 h1:3KigI41yWz4dD1U26GZBxoOZ4RzeRiPjeXQZKjb71zo=
5+
github.com/flashcatcloud/go-flashduty v0.5.9/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
66
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=

internal/cli/gen_support.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,10 @@ func genFoldPositional(args []string, body map[string]any, wire, kind string) er
138138
return nil
139139
}
140140

141-
// genBindBody marshals the assembled body map into the typed request struct so
142-
// the call benefits from the SDK's wire encoding (nullable pointers, etc.).
141+
// genBindBody validates and marshals the assembled body map into the typed
142+
// request struct. SDK request tags without omitempty/omitzero represent required
143+
// OpenAPI fields; checking their presence before unmarshalling distinguishes an
144+
// omitted field from an explicitly supplied zero value.
143145
//
144146
// POST request structs tag fields with `json`, so json.Unmarshal binds them.
145147
// GET query structs tag fields with `url` and carry NO json tag, so the
@@ -148,6 +150,53 @@ func genFoldPositional(args []string, body map[string]any, wire, kind string) er
148150
// field from the body by its url wire-name. For POST structs the url pass is a
149151
// no-op, so existing behavior is unchanged.
150152
func genBindBody(body map[string]any, req any) error {
153+
var missing []string
154+
var inspect func(reflect.Type)
155+
inspect = func(rt reflect.Type) {
156+
for rt.Kind() == reflect.Ptr {
157+
rt = rt.Elem()
158+
}
159+
if rt.Kind() != reflect.Struct {
160+
return
161+
}
162+
for i := 0; i < rt.NumField(); i++ {
163+
field := rt.Field(i)
164+
if field.PkgPath != "" {
165+
continue
166+
}
167+
if field.Anonymous {
168+
inspect(field.Type)
169+
continue
170+
}
171+
172+
tag := field.Tag.Get("json")
173+
if tag == "" {
174+
tag = field.Tag.Get("url")
175+
}
176+
parts := strings.Split(tag, ",")
177+
if parts[0] == "" || parts[0] == "-" {
178+
continue
179+
}
180+
optional := false
181+
for _, option := range parts[1:] {
182+
if option == "omitempty" || option == "omitzero" {
183+
optional = true
184+
break
185+
}
186+
}
187+
if !optional {
188+
value, ok := body[parts[0]]
189+
if !ok || (value == nil && field.Type.Kind() != reflect.Ptr) {
190+
missing = append(missing, parts[0])
191+
}
192+
}
193+
}
194+
}
195+
inspect(reflect.TypeOf(req))
196+
if len(missing) > 0 {
197+
return fmt.Errorf("missing required request fields: %s", strings.Join(missing, ", "))
198+
}
199+
151200
b, err := json.Marshal(body)
152201
if err != nil {
153202
return fmt.Errorf("failed to encode request: %w", err)

internal/cli/gen_support_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package cli
2+
3+
import "testing"
4+
5+
func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) {
6+
req := new(struct {
7+
Value *bool `json:"value"`
8+
})
9+
10+
if err := genBindBody(map[string]any{"value": nil}, req); err != nil {
11+
t.Fatalf("genBindBody required nullable field: %v", err)
12+
}
13+
if req.Value != nil {
14+
t.Fatalf("Value = %v, want nil", req.Value)
15+
}
16+
}

internal/cli/incident_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,70 @@ func TestCommandIncidentListHelpSurfacesInsightIncidentExport(t *testing.T) {
6060
t.Fatalf("help output missing incident export discovery hint:\n%s", out)
6161
}
6262
}
63+
64+
func TestCommandIncidentPostMortemContentResetRejectsMissingRequiredFields(t *testing.T) {
65+
tests := []struct {
66+
name string
67+
args []string
68+
}{
69+
{
70+
name: "missing expected revision",
71+
args: []string{
72+
"incident", "post-mortem-content-reset", "postmortem-1",
73+
"--idempotency-key", "retry-1",
74+
"--markdown", "# Test",
75+
},
76+
},
77+
{
78+
name: "empty data object",
79+
args: []string{
80+
"incident", "post-mortem-content-reset",
81+
"--data", "{}",
82+
},
83+
},
84+
{
85+
name: "null expected revision",
86+
args: []string{
87+
"incident", "post-mortem-content-reset",
88+
"--data", `{"expected_revision":null,"idempotency_key":"retry-1","markdown":"# Test","post_mortem_id":"postmortem-1"}`,
89+
},
90+
},
91+
}
92+
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
saveAndResetGlobals(t)
96+
stub := newGFStub(t)
97+
98+
_, err := execCommand(tt.args...)
99+
if err == nil || !strings.Contains(err.Error(), "missing required request field") {
100+
t.Fatalf("error = %v, want missing required request field", err)
101+
}
102+
if stub.lastPath != "" {
103+
t.Fatalf("request reached %q despite missing required request fields", stub.lastPath)
104+
}
105+
})
106+
}
107+
}
108+
109+
func TestCommandIncidentPostMortemContentResetAcceptsExplicitZeroRevision(t *testing.T) {
110+
saveAndResetGlobals(t)
111+
stub := newGFStub(t)
112+
113+
_, err := execCommand(
114+
"incident", "post-mortem-content-reset", "postmortem-1",
115+
"--expected-revision", "0",
116+
"--idempotency-key", "retry-1",
117+
"--markdown", "# Test",
118+
)
119+
if err != nil {
120+
t.Fatalf("execCommand: %v", err)
121+
}
122+
if stub.lastPath != "/incident/post-mortem/content/reset" {
123+
t.Fatalf("path = %q, want /incident/post-mortem/content/reset", stub.lastPath)
124+
}
125+
got, ok := stub.lastBody["expected_revision"]
126+
if !ok || got != float64(0) {
127+
t.Fatalf("expected_revision = %#v (present=%v), want explicit 0", got, ok)
128+
}
129+
}

internal/cli/zz_generated_incidents.go

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

internal/cli/zz_generated_manifest.go

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

0 commit comments

Comments
 (0)