From a83ca124d54a36548554b5d6bee96855e41e3ea0 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 10 Nov 2026 03:50:33 -0800 Subject: [PATCH 1/2] feat(cli)!: report what a write returned instead of echoing its path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated commands whose SDK method returns no typed data printed a fixed acknowledgement assembled from their own HTTP verb and path, e.g. "OK: POST /incident/ack", and discarded the response. That line restated what the caller had just typed, so it said nothing about the outcome of the write it was reporting, and in --json it arrived as a rendered English sentence under a "message" key with no field to read. All 118 such endpoints genuinely carry no payload: 110 mutations whose 200 envelope declares `data` as an empty object, and 8 CSV/file downloads with no application/json 200 body at all, whose bytes already stream through Response.Raw. So the fix is not to invent a payload but to say there was none, and to hand back the request id the CLI had been dropping on every write — the one fact of the call a caller cannot derive from its own arguments, and what it has to quote when reporting a write that went wrong. $ flashduty incident ack inc-1 OK: the API returned no payload (request_id 01HK8XQE3Z7JM2NTFQ5YJ8P9R4). $ flashduty incident ack inc-1 --json { "request_id": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4" } One rule in the generator derives this from the SDK method's own shape, so it reaches every generated command with no per-command list and no flag. Three curated commands carried the same canned line and now call the same helper: a curated command shadows its generated twin at registration, so changing only the generator would have left the command users actually reach unchanged. The helper, printRawOrNoPayload, also absorbs the Response.Raw check each call site used to repeat, which is what keeps the two in step: the claim "no payload" is now made by the same code that establishes it, so a caller cannot print it while holding bytes. mapping-data upload was missing that check and would have made exactly that claim. Output shape, for callers parsing it: `--json` and `--toon` now carry request_id instead of message, and carry an empty object when the server reports no id. The Response.Raw branch is untouched, so a CSV or file download still streams verbatim and `> file.csv` is unaffected. Tests pin the default line by equality rather than substring, because callers read this through `head` and a line pushed down by output added above it has to fail. A spec-driven test asserts the premise that every one of these operations declares no payload, so an endpoint that later grows a real response fails the build instead of being dropped in silence. --- e2e/edge_case_test.go | 6 +- e2e/helpers_test.go | 42 +++ e2e/incident_extended_test.go | 2 +- e2e/incident_test.go | 2 +- internal/cli/alert.go | 6 +- internal/cli/alert_test.go | 4 +- internal/cli/command.go | 5 +- internal/cli/command_test.go | 12 +- internal/cli/gen_support.go | 68 +++++ internal/cli/gen_write_ack_test.go | 249 ++++++++++++++++++ internal/cli/gfstub_test.go | 12 +- internal/cli/insight.go | 3 +- internal/cli/outputfields_test.go | 2 +- internal/cli/upload.go | 5 +- internal/cli/zz_generated_alert_enrichment.go | 60 +---- internal/cli/zz_generated_alert_rules.go | 12 +- internal/cli/zz_generated_alerts.go | 12 +- internal/cli/zz_generated_analytics.go | 24 +- internal/cli/zz_generated_applications.go | 12 +- internal/cli/zz_generated_artifacts.go | 6 +- internal/cli/zz_generated_calendars.go | 18 +- internal/cli/zz_generated_channels.go | 126 ++------- internal/cli/zz_generated_data_sources.go | 6 +- .../zz_generated_dedicated_integrations.go | 18 +- .../cli/zz_generated_error_ingestion_rules.go | 30 +-- internal/cli/zz_generated_incidents.go | 144 ++-------- internal/cli/zz_generated_integrations.go | 18 +- ...z_generated_issue_preset_severity_rules.go | 36 +-- internal/cli/zz_generated_issues.go | 12 +- internal/cli/zz_generated_members.go | 30 +-- .../zz_generated_notification_templates.go | 12 +- .../cli/zz_generated_roles_permissions.go | 30 +-- internal/cli/zz_generated_rule_folders.go | 12 +- internal/cli/zz_generated_schedules.go | 12 +- internal/cli/zz_generated_status_pages.go | 72 +---- internal/cli/zz_generated_teams.go | 6 +- internal/cmd/cligen/main.go | 18 +- internal/cmd/cligen/write_ack_test.go | 104 ++++++++ 38 files changed, 626 insertions(+), 622 deletions(-) create mode 100644 internal/cli/gen_write_ack_test.go create mode 100644 internal/cmd/cligen/write_ack_test.go diff --git a/e2e/edge_case_test.go b/e2e/edge_case_test.go index 8335fa3..f86ec93 100644 --- a/e2e/edge_case_test.go +++ b/e2e/edge_case_test.go @@ -134,11 +134,13 @@ func TestJSONOnAckCommand(t *testing.T) { id := extractIncidentID(t, r.Stdout) t.Cleanup(func() { runCLI(t, "incident", "close", id) }) - // ack is served by the generated twin; --json wraps the OK line as {"message":"..."}. + // ack is served by the generated twin. /incident/ack answers with an empty + // data envelope, so --json emits the one thing the call did report: its + // request id. r = runCLI(t, "incident", "ack", id, "--json") requireSuccess(t, r) requireValidJSON(t, r.Stdout) - requireContains(t, r.Stdout, "OK: POST /incident/ack") + requireRequestIDOnly(t, r.Stdout) } // Test 307: --json on close command diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index d619f20..48e1162 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" "time" @@ -217,6 +218,47 @@ func requireValidJSON(t *testing.T, output string) { } } +// noPayloadAckRe matches the whole single line a command prints when its +// endpoint answers with no payload. Unlike the unit tests, which pin the line +// by equality against a stub's fixed request id, e2e runs against the real API +// and cannot know the id — so it pins the shape AND requires the id to be +// non-empty, which is the part a live server must supply. +var noPayloadAckRe = regexp.MustCompile(`^OK: the API returned no payload \(request_id \S+\)\.$`) + +// requireNoPayloadAck fails the test unless output is exactly the no-payload +// acknowledgement line. Equality on the whole output (not a substring) is the +// point: a caller reading this through `head` must find the line first, so +// anything printed above it is a regression. +func requireNoPayloadAck(t *testing.T, output string) { + t.Helper() + got := strings.TrimRight(output, "\n") + if !noPayloadAckRe.MatchString(got) { + t.Fatalf("output must be exactly the no-payload acknowledgement line, got:\n%s", output) + } +} + +// requireRequestIDOnly fails the test unless output is the structured form of +// the no-payload acknowledgement: an object carrying a non-empty request_id and +// nothing a caller would mistake for a payload. It also guards against the old +// shape, which wrapped a rendered sentence as {"message": "OK: POST /..."} and +// gave a machine caller nothing to read. +func requireRequestIDOnly(t *testing.T, output string) { + t.Helper() + var ack struct { + RequestID string `json:"request_id"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &ack); err != nil { + t.Fatalf("output is not a JSON object (%v):\n%s", err, output) + } + if ack.RequestID == "" { + t.Fatalf("structured acknowledgement carries no request_id:\n%s", output) + } + if ack.Message != "" { + t.Fatalf("structured acknowledgement still renders a prose message:\n%s", output) + } +} + // requireTableHeaders fails the test if any of the given headers are missing // from the first line of the output. func requireTableHeaders(t *testing.T, output string, headers ...string) { diff --git a/e2e/incident_extended_test.go b/e2e/incident_extended_test.go index 96e6660..0a662dd 100644 --- a/e2e/incident_extended_test.go +++ b/e2e/incident_extended_test.go @@ -195,7 +195,7 @@ func TestIncidentAckSingleID(t *testing.T) { // Served by the generated twin (positional id → incident_ids). r = runCLI(t, "incident", "ack", id) requireSuccess(t, r) - requireContains(t, r.Stdout, "OK: POST /incident/ack") + requireNoPayloadAck(t, r.Stdout) } // Test 204: close single ID diff --git a/e2e/incident_test.go b/e2e/incident_test.go index 2019b47..d340f97 100644 --- a/e2e/incident_test.go +++ b/e2e/incident_test.go @@ -104,7 +104,7 @@ func TestIncidentLifecycle(t *testing.T) { // Step 3: Ack (served by the generated twin; positional id → incident_ids). r = runCLI(t, "incident", "ack", id) requireSuccess(t, r) - requireContains(t, r.Stdout, "OK: POST /incident/ack") + requireNoPayloadAck(t, r.Stdout) // Step 4: Get - should be Processing r = runCLI(t, "incident", "get", id) diff --git a/internal/cli/alert.go b/internal/cli/alert.go index fc5a615..c82a8d9 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -393,11 +393,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /alert/merge") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/alert_test.go b/internal/cli/alert_test.go index d22a2e8..51e5a5c 100644 --- a/internal/cli/alert_test.go +++ b/internal/cli/alert_test.go @@ -154,8 +154,8 @@ func TestCommandAlertMergeCommentFileReachesWireByteForByte(t *testing.T) { if stub.lastBody["incident_id"] != "inc-1" { t.Fatalf("[alert-merge-comment-file] expected incident_id %q, got %#v", "inc-1", stub.lastBody["incident_id"]) } - if !strings.Contains(out, "OK: POST /alert/merge") { - t.Fatalf("[alert-merge-comment-file] unexpected output:\n%s", out) + if got := strings.TrimRight(out, "\n"); got != gfStubNoPayloadLine { + t.Fatalf("[alert-merge-comment-file] default output must be exactly %q, got:\n%s", gfStubNoPayloadLine, out) } } diff --git a/internal/cli/command.go b/internal/cli/command.go index d31b696..e08b567 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -295,8 +295,9 @@ func (ctx *RunContext) WriteResult(message string) { // WriteRaw writes a non-JSON response body (e.g. a CSV/file download surfaced // on Response.Raw by the *export endpoints) straight to the output writer, so -// shell redirection (`> file.csv`) captures the bytes verbatim instead of the -// canned "OK: POST ..." acknowledgment. +// shell redirection (`> file.csv`) captures the bytes verbatim rather than the +// no-payload acknowledgement those commands print when no bytes arrive (see +// printRawOrNoPayload). func (ctx *RunContext) WriteRaw(body []byte) error { _, err := ctx.Writer.Write(body) return err diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index b12af01..7b602ed 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -496,8 +496,8 @@ func TestCommandIncidentUnack(t *testing.T) { if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1,inc-2"; got != want { t.Fatalf("[incident-unack] expected ids %q, got %q", want, got) } - if !strings.Contains(out, "OK: POST /incident/unack") { - t.Fatalf("[incident-unack] unexpected output:\n%s", out) + if got := strings.TrimRight(out, "\n"); got != gfStubNoPayloadLine { + t.Fatalf("[incident-unack] default output must be exactly %q, got:\n%s", gfStubNoPayloadLine, out) } } @@ -516,8 +516,8 @@ func TestCommandIncidentWake(t *testing.T) { if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1"; got != want { t.Fatalf("[incident-wake] expected ids %q, got %q", want, got) } - if !strings.Contains(out, "OK: POST /incident/wake") { - t.Fatalf("[incident-wake] unexpected output:\n%s", out) + if got := strings.TrimRight(out, "\n"); got != gfStubNoPayloadLine { + t.Fatalf("[incident-wake] default output must be exactly %q, got:\n%s", gfStubNoPayloadLine, out) } } @@ -1312,8 +1312,8 @@ func TestCommandIncidentDisableMerge(t *testing.T) { if got, want := strings.Join(stub.bodyStrings("incident_ids"), ","), "inc-1,inc-2"; got != want { t.Fatalf("[incident-disable-merge] expected ids %q, got %q", want, got) } - if !strings.Contains(out, "OK: POST /incident/disable-merge") { - t.Fatalf("[incident-disable-merge] unexpected output:\n%s", out) + if got := strings.TrimRight(out, "\n"); got != gfStubNoPayloadLine { + t.Fatalf("[incident-disable-merge] default output must be exactly %q, got:\n%s", gfStubNoPayloadLine, out) } } diff --git a/internal/cli/gen_support.go b/internal/cli/gen_support.go index 54cbe4f..92adaea 100644 --- a/internal/cli/gen_support.go +++ b/internal/cli/gen_support.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" "github.com/flashcatcloud/flashduty-cli/internal/output" @@ -433,6 +434,73 @@ func bindURLTagged(body map[string]any, rv reflect.Value) { } } +// noPayloadLine is the table-mode line for a call whose body carried +// nothing: the mutation endpoints whose 200 envelope declares `data` as an +// empty object, and an export/download endpoint that answered with zero bytes. +// It replaces an echo of the request — the verb and path the caller had just +// typed — which stated nothing the caller did not already know, yet read as a +// description of a stored result that was never fetched. +const noPayloadLine = "OK: the API returned no payload." + +// noPayloadLineFmt is noPayloadLine carrying the request id: the one +// fact of such a call a caller cannot derive from its own arguments, and what +// it has to quote when reporting a write that went wrong. The id can be +// genuinely absent — a 200 with an empty body never parses an envelope, leaving +// only the Flashcat-Request-Id header — which is why the no-id wording above +// exists rather than printing "(request_id )". +const noPayloadLineFmt = "OK: the API returned no payload (request_id %s)." + +// printRawOrNoPayload renders the response of a command whose SDK method +// returns no typed data (methodInfo.HasData false, reflected at generation +// time), which is every generated mutation plus the CSV/file downloads. Its two +// names are its two outcomes, and which one applies is only knowable at +// runtime: the SDK fills Response.Raw exactly when a 2xx body is not envelope +// JSON, so a download's bytes land there while a mutation's empty envelope +// leaves it nil. +// +// Non-empty Raw streams verbatim, so shell redirection (`> file.csv`) captures +// a download byte for byte. Otherwise the call really did carry nothing, and the +// output says so — the alternative being to print a sentence that contradicts +// bytes the command is holding. Deciding that here rather than in each caller is +// what keeps the claim and the response in step: a caller cannot forget the +// check, and the acknowledgement cannot outrun what arrived. +// +// A command that does something else with a download's bytes — insight +// incident-export counts its rows before writing them — keeps its own Raw +// branch and calls printNoPayload directly for the empty case. +func printRawOrNoPayload(ctx *RunContext, resp *flashduty.Response) error { + if resp != nil && len(resp.Raw) > 0 { + return ctx.WriteRaw(resp.Raw) + } + return printNoPayload(ctx, resp) +} + +// printNoPayload states that a call carried nothing back, and reports the +// request id when the server gave one. Structured mode emits that id as data +// rather than a rendered sentence, so `--json` stays parseable; an absent id is +// left out instead of invented as "". +func printNoPayload(ctx *RunContext, resp *flashduty.Response) error { + ack := noPayloadAck{} + if resp != nil { + ack.RequestID = resp.RequestID + } + message := noPayloadLine + if ack.RequestID != "" { + message = fmt.Sprintf(noPayloadLineFmt, ack.RequestID) + } + return ctx.WriteResultJSON(ack, message) +} + +// noPayloadAck is the structured form of the no-payload branch +// above. It carries only what the API actually reported; there is no payload +// field, because the absence of one is the statement. The toon tag is not +// redundant with the json one: toon.Marshal reads only its own tag, so without +// it TOON output would spell the field RequestID while JSON spelled it +// request_id. +type noPayloadAck struct { + RequestID string `json:"request_id,omitempty" toon:"request_id,omitempty"` +} + // printGenericResult renders a generated command's typed response. In // machine-readable mode (TOON/JSON) it marshals the whole value — which is what // the agent reads. A list-shaped response (a top-level array of objects, or an diff --git a/internal/cli/gen_write_ack_test.go b/internal/cli/gen_write_ack_test.go new file mode 100644 index 0000000..86fa1ac --- /dev/null +++ b/internal/cli/gen_write_ack_test.go @@ -0,0 +1,249 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/flashcatcloud/go-flashduty" +) + +// newRawStub points the client at a server that answers every request with the +// given status, headers and body bytes, bypassing newGFStub's envelope. Tests +// below use it for the two things an envelope stub cannot produce: a success +// body that is not envelope JSON (which the SDK surfaces on Response.Raw), and +// a 200 that reports no request id at all. +func newRawStub(t *testing.T, contentType string, body []byte, setRequestID bool) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if setRequestID { + w.Header().Set("Flashcat-Request-Id", gfStubRequestID) + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + _, _ = w.Write(body) + })) + t.Cleanup(server.Close) + + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(server.URL)) + } +} + +// TestGeneratedWriteAckIsTheWholeDefaultOutput pins the default output of a +// generated command whose endpoint returns no payload. Equality on the whole +// output, not a substring, is the contract: callers read this through `head`, +// so a line pushed down by anything printed above it is a regression a +// strings.Contains assertion would pass. +// +// What it must NOT be is the previous output, "OK: POST /channel/disable" — an +// echo of the request, which restated the path the caller had just typed and +// so read as a description of a result nobody had fetched. +func TestGeneratedWriteAckIsTheWholeDefaultOutput(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + stdout, stderr, err := execCommandSplit("channel", "disable", "1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimRight(stdout, "\n"); got != gfStubNoPayloadLine { + t.Fatalf("default output must be exactly %q, got:\n%s", gfStubNoPayloadLine, stdout) + } + if stderr != "" { + t.Errorf("a plain write must print nothing on stderr, got:\n%s", stderr) + } +} + +// TestGeneratedWriteAckStructuredCarriesOnlyTheRequestID guards the machine +// path. The old shape was {"message": "OK: POST /channel/disable"} — a rendered +// English sentence in a JSON field, which a caller could only string-match. The +// new shape carries the request id as data, and carries nothing else, because +// nothing else is known: the endpoint's 200 envelope declares `data` as an +// empty object. +// +// The TOON subtest is not duplicate coverage. TOON is a different marshaller +// (toon.Marshal, vs json.MarshalIndent over output.NullUnsetInstants), so a +// change that broke only the TOON path would pass every JSON assertion. +func TestGeneratedWriteAckStructuredCarriesOnlyTheRequestID(t *testing.T) { + t.Run("json", func(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + stdout, _, err := execCommandSplit("channel", "disable", "1", "--json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var ack map[string]any + if err := json.Unmarshal([]byte(stdout), &ack); err != nil { + t.Fatalf("output is not a JSON object (%v):\n%s", err, stdout) + } + if got := ack["request_id"]; got != gfStubRequestID { + t.Errorf("request_id = %v, want %q", got, gfStubRequestID) + } + if len(ack) != 1 { + t.Errorf("structured acknowledgement must carry request_id alone, got %v", ack) + } + }) + + t.Run("toon", func(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + stdout, _, err := execCommandSplit("channel", "disable", "1", "--output-format", "toon") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout, "request_id") || !strings.Contains(stdout, gfStubRequestID) { + t.Errorf("toon output missing the request id:\n%s", stdout) + } + if strings.Contains(stdout, "OK:") { + t.Errorf("toon output must not carry the table-mode sentence:\n%s", stdout) + } + }) +} + +// TestGeneratedWriteAckOmitsAnAbsentRequestID covers the case the two wordings +// exist for. A 200 with an empty body never parses an envelope, so the id can +// only come from the Flashcat-Request-Id header; with neither, there is no id. +// The line must then say so rather than print a hole, "(request_id )", and the +// structured form must emit an empty object rather than invent request_id: "". +func TestGeneratedWriteAckOmitsAnAbsentRequestID(t *testing.T) { + t.Run("table", func(t *testing.T) { + saveAndResetGlobals(t) + newRawStub(t, "", nil, false) + + stdout, _, err := execCommandSplit("channel", "disable", "1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimRight(stdout, "\n"); got != noPayloadLine { + t.Fatalf("output must be exactly %q, got:\n%s", noPayloadLine, stdout) + } + }) + + t.Run("json", func(t *testing.T) { + saveAndResetGlobals(t) + newRawStub(t, "", nil, false) + + stdout, _, err := execCommandSplit("channel", "disable", "1", "--json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var ack map[string]any + if err := json.Unmarshal([]byte(stdout), &ack); err != nil { + t.Fatalf("output is not a JSON object (%v):\n%s", err, stdout) + } + if len(ack) != 0 { + t.Errorf("an unknown request id must be absent, not empty; got %v", ack) + } + }) + + // TOON marshals an all-omitempty struct through its own code path, so what + // it prints for a record with nothing in it does not follow from the JSON + // case above. Whatever it is, it must not claim an id: a literal + // "request_id" with nothing after it would read as a server that answered + // with a blank one. + t.Run("toon", func(t *testing.T) { + saveAndResetGlobals(t) + newRawStub(t, "", nil, false) + + stdout, _, err := execCommandSplit("channel", "disable", "1", "--output-format", "toon") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(stdout, "request_id") { + t.Errorf("toon output names a request id the server never sent:\n%q", stdout) + } + }) +} + +// exportCSV is the body newRawStub serves for the download tests below. Its +// bytes are deliberately not valid JSON, which is what makes the SDK surface +// them on Response.Raw. +const exportCSV = "issue_id,error_type\nabc,Error\n" + +// issueExportArgs drives the generated `rum issue-export` command, one of the +// eight endpoints whose 200 declares no application/json body at all. The +// window goes through --data because both bounds are required epoch +// milliseconds. +var issueExportArgs = []string{ + "rum", "issue-export", + "--data", `{"start_time":1772611200000,"end_time":1775961914595}`, +} + +// TestGeneratedDownloadStreamsRawBodyInsteadOfTheAck is the other half of the +// branch printRawOrNoPayload chooses between. Response.Raw is populated only for a success +// body that is not envelope JSON, so that guard — not anything in the spec read +// at generation time — is what tells a CSV download apart from a mutation at +// runtime. The bytes must reach stdout verbatim, with no acknowledgement line +// wrapped around them, or `> file.csv` writes a corrupt file. +func TestGeneratedDownloadStreamsRawBodyInsteadOfTheAck(t *testing.T) { + saveAndResetGlobals(t) + newRawStub(t, "text/csv", []byte(exportCSV), true) + + stdout, _, err := execCommandSplit(issueExportArgs...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if stdout != exportCSV { + t.Fatalf("CSV body must reach stdout verbatim:\nwant: %q\n got: %q", exportCSV, stdout) + } +} + +// TestGeneratedDownloadAcksWhenTheServerSendsNoBytes is the same command +// answered with an empty body. There is nothing to stream, so it falls through +// to the acknowledgement — and "the API returned no payload" is the literal +// truth of a zero-byte download, which is why one wording serves both the +// mutations and this case instead of two. +func TestGeneratedDownloadAcksWhenTheServerSendsNoBytes(t *testing.T) { + saveAndResetGlobals(t) + newRawStub(t, "text/csv", nil, true) + + stdout, _, err := execCommandSplit(issueExportArgs...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimRight(stdout, "\n"); got != gfStubNoPayloadLine { + t.Fatalf("output must be exactly %q, got:\n%s", gfStubNoPayloadLine, stdout) + } +} + +// cannedPathEcho matches the output this change removed: a literal +// acknowledgement that names an HTTP verb and a request path. +var cannedPathEcho = regexp.MustCompile(`"OK: (GET|POST|PUT|PATCH|DELETE) /`) + +// TestNoCommandEchoesItsRequestPath is the regression guard on the committed +// artifact rather than on the generator: the generated files are derived, so a +// generator change that is not regenerated would leave these untouched. It +// covers the curated commands too, because three of them carried the same +// canned line and a curated command WINS over its generated twin at +// registration (genAddLeaf keeps the first name it sees), so fixing only the +// generator would have left the command users actually reach unfixed. +func TestNoCommandEchoesItsRequestPath(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(filepath.Join(".", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if loc := cannedPathEcho.FindIndex(src); loc != nil { + t.Errorf("%s still acknowledges a write by echoing its request path (%q) — "+ + "report what the API returned, or say it returned nothing (printRawOrNoPayload)", + name, src[loc[0]:loc[1]]) + } + } +} diff --git a/internal/cli/gfstub_test.go b/internal/cli/gfstub_test.go index 814d1fc..a20da82 100644 --- a/internal/cli/gfstub_test.go +++ b/internal/cli/gfstub_test.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -11,6 +12,15 @@ import ( "github.com/flashcatcloud/go-flashduty" ) +// gfStubRequestID is the request id newGFStub reports in every envelope. A +// command whose endpoint returns no payload prints it (see gfStubNoPayloadLine), +// so the two are pinned to one constant rather than two literals that can drift. +const gfStubRequestID = "test-request-id" + +// gfStubNoPayloadLine is the exact line a command prints against newGFStub when +// its endpoint answered with no payload. +var gfStubNoPayloadLine = fmt.Sprintf(noPayloadLineFmt, gfStubRequestID) + // gfStub is an httptest-backed stand-in for the go-flashduty API. Migrated // commands build a *flashduty.Client (a concrete type, not an interface), so // they can't be mocked the way the legacy flashdutyClient interface is — they @@ -107,7 +117,7 @@ func newGFStub(t *testing.T) *gfStub { } resp := map[string]any{ - "request_id": "test-request-id", + "request_id": gfStubRequestID, "error": map[string]any{"code": "OK", "message": ""}, "data": payload, } diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 5e2ea6d..3202654 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -311,8 +311,7 @@ Request fields: return err } if resp == nil || len(resp.Raw) == 0 { - ctx.WriteResult("OK: POST /insight/incident/export") - return nil + return printNoPayload(ctx, resp) } rows, err := countCSVDataRows(resp.Raw) if err != nil { diff --git a/internal/cli/outputfields_test.go b/internal/cli/outputfields_test.go index 5dd9e59..d732d43 100644 --- a/internal/cli/outputfields_test.go +++ b/internal/cli/outputfields_test.go @@ -124,7 +124,7 @@ func TestGlobalFieldsOnAcknowledgement(t *testing.T) { if err != nil { t.Fatalf("execCommand: %v", err) } - if !strings.Contains(stdout, `"message"`) { + if !strings.Contains(stdout, `"request_id"`) { t.Errorf("acknowledgement should print unchanged, got %q", stdout) } if !strings.Contains(stderr, `note: --fields does not apply to the output of "flashduty channel disable"`) { diff --git a/internal/cli/upload.go b/internal/cli/upload.go index 715f57c..75bb4e9 100644 --- a/internal/cli/upload.go +++ b/internal/cli/upload.go @@ -70,7 +70,7 @@ API: POST /enrichment/mapping/data/upload (mapping-data-write-upload)`, } defer func() { _ = file.Close() }() - _, err = ctx.Client.AlertEnrichment.MappingDataWriteUpload(cmdContext(ctx.Cmd), &flashduty.MappingDataUploadRequest{ + resp, err := ctx.Client.AlertEnrichment.MappingDataWriteUpload(cmdContext(ctx.Cmd), &flashduty.MappingDataUploadRequest{ SchemaID: schemaID, DoNotTruncateFirst: doNotTruncateFirst, File: file, @@ -79,8 +79,7 @@ API: POST /enrichment/mapping/data/upload (mapping-data-write-upload)`, if err != nil { return err } - ctx.WriteResult("OK: POST /enrichment/mapping/data/upload") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_alert_enrichment.go b/internal/cli/zz_generated_alert_enrichment.go index 479aabb..e53707e 100644 --- a/internal/cli/zz_generated_alert_enrichment.go +++ b/internal/cli/zz_generated_alert_enrichment.go @@ -386,11 +386,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/upsert") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -662,11 +658,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /field/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -730,11 +722,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /field/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -987,11 +975,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/api/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1075,11 +1059,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/api/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1134,11 +1114,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/data/download") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1281,11 +1257,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/data/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1334,11 +1306,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/data/truncate") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1621,11 +1589,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/schema/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1688,11 +1652,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /enrichment/mapping/schema/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_alert_rules.go b/internal/cli/zz_generated_alert_rules.go index 317cdc4..228b755 100644 --- a/internal/cli/zz_generated_alert_rules.go +++ b/internal/cli/zz_generated_alert_rules.go @@ -793,11 +793,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /monit/rule/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -841,11 +837,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /monit/rule/delete/batch") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_alerts.go b/internal/cli/zz_generated_alerts.go index c154a68..1536e25 100644 --- a/internal/cli/zz_generated_alerts.go +++ b/internal/cli/zz_generated_alerts.go @@ -1095,11 +1095,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /alert/merge") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1166,11 +1162,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /alert/pipeline/upsert") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_analytics.go b/internal/cli/zz_generated_analytics.go index 802f349..0434518 100644 --- a/internal/cli/zz_generated_analytics.go +++ b/internal/cli/zz_generated_analytics.go @@ -991,11 +991,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /insight/channel/export") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1153,11 +1149,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /insight/incident/export") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1576,11 +1568,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /insight/responder/export") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1759,11 +1747,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /insight/team/export") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_applications.go b/internal/cli/zz_generated_applications.go index c4f21de..a9a6aa7 100644 --- a/internal/cli/zz_generated_applications.go +++ b/internal/cli/zz_generated_applications.go @@ -911,11 +911,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/application/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1016,11 +1012,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/application/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_artifacts.go b/internal/cli/zz_generated_artifacts.go index e02cae5..145be12 100644 --- a/internal/cli/zz_generated_artifacts.go +++ b/internal/cli/zz_generated_artifacts.go @@ -345,11 +345,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: GET /safari/artifact/stream") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_calendars.go b/internal/cli/zz_generated_calendars.go index a9ca9f2..38da9a3 100644 --- a/internal/cli/zz_generated_calendars.go +++ b/internal/cli/zz_generated_calendars.go @@ -62,11 +62,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /calendar/event/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -366,11 +362,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /calendar/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -580,11 +572,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /calendar/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_channels.go b/internal/cli/zz_generated_channels.go index e6044c5..a3fe7f4 100644 --- a/internal/cli/zz_generated_channels.go +++ b/internal/cli/zz_generated_channels.go @@ -196,11 +196,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -248,11 +244,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -300,11 +292,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -456,11 +444,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/escalate/rule/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -510,11 +494,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/escalate/rule/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -564,11 +544,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/escalate/rule/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -796,11 +772,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/escalate/rule/reorder") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -901,11 +873,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/escalate/rule/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1193,11 +1161,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/inhibit/rule/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1247,11 +1211,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/inhibit/rule/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1301,11 +1261,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/inhibit/rule/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1442,11 +1398,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/inhibit/rule/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1760,11 +1712,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/silence/rule/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1814,11 +1762,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/silence/rule/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1868,11 +1812,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/silence/rule/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2027,11 +1967,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/silence/rule/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2150,11 +2086,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/unsubscribe/rule/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2204,11 +2136,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/unsubscribe/rule/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2258,11 +2186,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/unsubscribe/rule/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2385,11 +2309,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/unsubscribe/rule/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2739,11 +2659,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /route/upsert") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_data_sources.go b/internal/cli/zz_generated_data_sources.go index 300afd1..19f3078 100644 --- a/internal/cli/zz_generated_data_sources.go +++ b/internal/cli/zz_generated_data_sources.go @@ -652,11 +652,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /monit/datasource/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_dedicated_integrations.go b/internal/cli/zz_generated_dedicated_integrations.go index a0f3be1..fdd120f 100644 --- a/internal/cli/zz_generated_dedicated_integrations.go +++ b/internal/cli/zz_generated_dedicated_integrations.go @@ -296,11 +296,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/integration/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -348,11 +344,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/integration/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -400,11 +392,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /channel/integration/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_error_ingestion_rules.go b/internal/cli/zz_generated_error_ingestion_rules.go index ec8157b..0231ca3 100644 --- a/internal/cli/zz_generated_error_ingestion_rules.go +++ b/internal/cli/zz_generated_error_ingestion_rules.go @@ -116,11 +116,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/error-ingestion/rules/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -170,11 +166,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/error-ingestion/rules/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -224,11 +216,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/error-ingestion/rules/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -386,11 +374,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/error-ingestion/rules/history/revert") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -515,11 +499,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/error-ingestion/rules/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_incidents.go b/internal/cli/zz_generated_incidents.go index 5b6fa9b..03e8897 100644 --- a/internal/cli/zz_generated_incidents.go +++ b/internal/cli/zz_generated_incidents.go @@ -386,11 +386,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/ack") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -598,11 +594,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/assign") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -666,11 +658,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/comment") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -784,11 +772,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/comment-type/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -881,11 +865,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/comment-type/reorder") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -943,11 +923,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/comment-type/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1128,11 +1104,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/disable-merge") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1382,11 +1354,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/field/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2245,11 +2213,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/merge") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2519,11 +2483,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/post-mortem/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2781,11 +2741,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/remove") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2838,11 +2794,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/reopen") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2921,11 +2873,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -3004,11 +2952,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/resolve") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -3069,11 +3013,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/responder/add") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -3127,11 +3067,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/snooze") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -3180,11 +3116,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/unack") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -3232,11 +3164,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/wake") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -3362,11 +3290,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/war-room/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -4483,11 +4407,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/post-mortem/template/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -4657,11 +4577,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/post-mortem/basics/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -4719,11 +4635,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/post-mortem/follow-ups/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -4777,11 +4689,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/post-mortem/status/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -4835,11 +4743,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /incident/post-mortem/title/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_integrations.go b/internal/cli/zz_generated_integrations.go index 6057add..34719ec 100644 --- a/internal/cli/zz_generated_integrations.go +++ b/internal/cli/zz_generated_integrations.go @@ -559,11 +559,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /integration/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -611,11 +607,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /integration/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -663,11 +655,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /integration/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_issue_preset_severity_rules.go b/internal/cli/zz_generated_issue_preset_severity_rules.go index c605ebd..b33fed6 100644 --- a/internal/cli/zz_generated_issue_preset_severity_rules.go +++ b/internal/cli/zz_generated_issue_preset_severity_rules.go @@ -123,11 +123,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/preset-severity/rules/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -177,11 +173,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/preset-severity/rules/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -231,11 +223,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/preset-severity/rules/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -395,11 +383,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/preset-severity/rules/history/revert") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -517,11 +501,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/preset-severity/rules/reorder") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -591,11 +571,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/preset-severity/rules/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_issues.go b/internal/cli/zz_generated_issues.go index 2f57003..8df4f03 100644 --- a/internal/cli/zz_generated_issues.go +++ b/internal/cli/zz_generated_issues.go @@ -128,11 +128,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/export") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -465,11 +461,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /rum/issue/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_members.go b/internal/cli/zz_generated_members.go index b220ee9..f55c21e 100644 --- a/internal/cli/zz_generated_members.go +++ b/internal/cli/zz_generated_members.go @@ -73,11 +73,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /member/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -136,11 +132,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /member/role/grant") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -514,11 +506,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /member/info/reset") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -577,11 +565,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /member/role/revoke") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -635,11 +619,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /member/role/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_notification_templates.go b/internal/cli/zz_generated_notification_templates.go index 2dfea64..81f56ac 100644 --- a/internal/cli/zz_generated_notification_templates.go +++ b/internal/cli/zz_generated_notification_templates.go @@ -547,11 +547,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /template/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -720,11 +716,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /template/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_roles_permissions.go b/internal/cli/zz_generated_roles_permissions.go index 1019be9..0b7721f 100644 --- a/internal/cli/zz_generated_roles_permissions.go +++ b/internal/cli/zz_generated_roles_permissions.go @@ -292,11 +292,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /role/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -345,11 +341,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /role/disable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -397,11 +389,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /role/enable") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -454,11 +442,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /role/member/grant") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -512,11 +496,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /role/member/revoke") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_rule_folders.go b/internal/cli/zz_generated_rule_folders.go index ae9df7b..8b06ae1 100644 --- a/internal/cli/zz_generated_rule_folders.go +++ b/internal/cli/zz_generated_rule_folders.go @@ -204,11 +204,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /monit/folder/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -261,11 +257,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /monit/folder/move") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_schedules.go b/internal/cli/zz_generated_schedules.go index 25fd931..c970f28 100644 --- a/internal/cli/zz_generated_schedules.go +++ b/internal/cli/zz_generated_schedules.go @@ -264,11 +264,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /schedule/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1354,11 +1350,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /schedule/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_status_pages.go b/internal/cli/zz_generated_status_pages.go index 2a6179f..9c79dae 100644 --- a/internal/cli/zz_generated_status_pages.go +++ b/internal/cli/zz_generated_status_pages.go @@ -348,11 +348,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/change/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -688,11 +684,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/change/timeline/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -762,11 +754,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/change/timeline/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -834,11 +822,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/change/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -895,11 +879,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/component/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1117,11 +1097,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1441,11 +1417,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/migration/cancel") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1567,11 +1539,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/section/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1683,11 +1651,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/subscriber/export") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1747,11 +1711,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/subscriber/import") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -1889,11 +1849,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/template/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } @@ -2145,11 +2101,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /status-page/update") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cli/zz_generated_teams.go b/internal/cli/zz_generated_teams.go index f08cddd..e103fb6 100644 --- a/internal/cli/zz_generated_teams.go +++ b/internal/cli/zz_generated_teams.go @@ -277,11 +277,7 @@ Request fields: if err != nil { return err } - if resp != nil && len(resp.Raw) > 0 { - return ctx.WriteRaw(resp.Raw) - } - ctx.WriteResult("OK: POST /team/delete") - return nil + return printRawOrNoPayload(ctx, resp) }) }, } diff --git a/internal/cmd/cligen/main.go b/internal/cmd/cligen/main.go index b496680..c939cac 100644 --- a/internal/cmd/cligen/main.go +++ b/internal/cmd/cligen/main.go @@ -1394,14 +1394,20 @@ func emitCmd(fn string, s service, o specOp, mi methodInfo) string { fmt.Fprintf(&b, "\t\t\t\treturn printGenericResult(ctx, out, body, listDefaultsFor(%q, %q))\n", s.Name, o.Method) default: // (*Response, error): a mutation, OR an export/attachment endpoint - // whose CSV/file body the SDK surfaces on Response.Raw. Capture the - // response and stream Raw verbatim when present (so `> file.csv` - // works); otherwise fall back to the canned OK acknowledgment. + // whose CSV/file body the SDK surfaces on Response.Raw. Which one a + // given call turned out to be is not knowable here, only at runtime — + // Raw is filled exactly when a 2xx body is not envelope JSON — so + // printRawOrNoPayload decides, streaming a download verbatim and + // otherwise saying the call carried nothing. + // + // For the mutations the spec says it will carry nothing: every one of + // their 200 envelopes declares `data` as an empty object. There is no + // stored object and no id to report, so the output states that and + // hands back the request id rather than echoing the path the caller + // typed. fmt.Fprintf(&b, "\t\t\t\tresp, err := %s\n", call) b.WriteString("\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n") - b.WriteString("\t\t\t\tif resp != nil && len(resp.Raw) > 0 {\n\t\t\t\t\treturn ctx.WriteRaw(resp.Raw)\n\t\t\t\t}\n") - fmt.Fprintf(&b, "\t\t\t\tctx.WriteResult(%q)\n", "OK: "+o.HTTP+" "+o.Path) - b.WriteString("\t\t\t\treturn nil\n") + b.WriteString("\t\t\t\treturn printRawOrNoPayload(ctx, resp)\n") } b.WriteString("\t\t\t})\n\t\t},\n\t}\n") diff --git a/internal/cmd/cligen/write_ack_test.go b/internal/cmd/cligen/write_ack_test.go new file mode 100644 index 0000000..addd620 --- /dev/null +++ b/internal/cmd/cligen/write_ack_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "strings" + "testing" +) + +// loadRealServices builds the exact service set run() generates from: the spec +// shipped in the linked go-flashduty module, read against the compiled SDK. +func loadRealServices(t *testing.T) ([]service, map[string]map[string]methodInfo) { + t.Helper() + path, err := specPath() + if err != nil { + t.Fatalf("locate spec: %v", err) + } + spec, err := loadSpec(path) + if err != nil { + t.Fatalf("load spec: %v", err) + } + sdk := reflectSDK() + return collectServices(asMap(spec["paths"]), asMap(asMap(spec["components"])["schemas"]), sdk), sdk +} + +// TestNoPayloadOpsDeclareNoPayload pins the premise the acknowledgement rests +// on. An operation whose SDK method returns no typed data (methodInfo.HasData +// false) is routed to printRawOrNoPayload, which tells the caller the API returned +// nothing. That is only honest while the spec agrees, and today it does: every +// such operation either declares its 200 `data` as an empty object (the +// mutations) or declares no application/json 200 body at all (the CSV/file +// downloads, whose bytes arrive on Response.Raw instead). Either way RespTree, +// the generator's own read of the declared `data` fields, is empty. +// +// If an endpoint later grows a real payload, this fails rather than letting the +// CLI keep reporting "no payload" over a response it silently dropped — the SDK +// method must gain a data return first, which moves the command to +// printGenericResult on its own. +// +// What it does not prove: a spec that grew real fields while still declaring +// `data` as a free-form object would leave RespTree empty and slip past. The +// SDK method signature is the backstop there — a method with no data return has +// nowhere to decode such a payload into, so the CLI could not have printed it +// either way, and the fix is to declare the fields in the spec. +// +// Operations the SDK map does not answer for are skipped, which loses nothing: +// emitService skips those too (with a warning) and emits no command at all, and +// coverage_test in internal/cli fails when any spec operation has no command. +func TestNoPayloadOpsDeclareNoPayload(t *testing.T) { + services, sdk := loadRealServices(t) + + checked := 0 + for _, s := range services { + for _, o := range s.Ops { + mi, ok := sdk[s.Name][o.Method] + if !ok || mi.HasData { + continue + } + checked++ + if len(o.RespTree) > 0 { + names := make([]string, 0, len(o.RespTree)) + for _, f := range o.RespTree { + names = append(names, f.Wire) + } + t.Errorf("%s %s (%s) declares a 200 payload (%s) but %s.%s returns no data, so its command would acknowledge a response it discards", + o.HTTP, o.Path, o.OpID, strings.Join(names, ","), s.Name, o.Method) + } + } + } + if checked == 0 { + t.Fatal("found no operation without a data return — the lookup is broken, not the spec clean") + } + t.Logf("checked %d operations whose command acknowledges an empty response", checked) +} + +// TestEmittedNoDataCommandDelegatesToTheSharedHelper guards the generator side +// of the same contract: the emitted body must hand its response to the one +// helper that decides between streaming a download and reporting no payload, +// and must carry no acknowledgement string of its own. The committed generated +// files are checked separately (TestNoCommandEchoesItsRequestPath, in +// internal/cli), which would not catch a generator edited without regenerating. +func TestEmittedNoDataCommandDelegatesToTheSharedHelper(t *testing.T) { + services, sdk := loadRealServices(t) + + emitted := 0 + for _, s := range services { + for _, o := range s.Ops { + mi, ok := sdk[s.Name][o.Method] + if !ok || mi.HasData { + continue + } + emitted++ + src := emitCmd("genTestCmd", s, o, mi) + if !strings.Contains(src, "return printRawOrNoPayload(ctx, resp)") { + t.Fatalf("%s %s: emitted command does not render its response through printRawOrNoPayload:\n%s", o.HTTP, o.Path, src) + } + if strings.Contains(src, `"OK: `) { + t.Fatalf("%s %s: emitted command still carries a canned acknowledgement string:\n%s", o.HTTP, o.Path, src) + } + } + } + if emitted == 0 { + t.Fatal("emitted no command without a data return — the lookup is broken") + } + t.Logf("checked the emitted body of %d commands", emitted) +} From c44ffe156e7df001b7f5bb8cc9223df735bd8204 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 10 Nov 2026 04:04:57 -0800 Subject: [PATCH 2/2] test(cli): name the no-payload tests after what they pin The helper these cover is printRawOrNoPayload/printNoPayload, so test names and file names reading WriteAck pointed at an identifier that no longer exists. Renames only, plus one comment reflowed; no assertion changed. --- ...ite_ack_test.go => gen_no_payload_test.go} | 31 ++++++++++--------- .../{write_ack_test.go => no_payload_test.go} | 0 2 files changed, 16 insertions(+), 15 deletions(-) rename internal/cli/{gen_write_ack_test.go => gen_no_payload_test.go} (87%) rename internal/cmd/cligen/{write_ack_test.go => no_payload_test.go} (100%) diff --git a/internal/cli/gen_write_ack_test.go b/internal/cli/gen_no_payload_test.go similarity index 87% rename from internal/cli/gen_write_ack_test.go rename to internal/cli/gen_no_payload_test.go index 86fa1ac..d0bf71e 100644 --- a/internal/cli/gen_write_ack_test.go +++ b/internal/cli/gen_no_payload_test.go @@ -36,7 +36,7 @@ func newRawStub(t *testing.T, contentType string, body []byte, setRequestID bool } } -// TestGeneratedWriteAckIsTheWholeDefaultOutput pins the default output of a +// TestGeneratedNoPayloadLineIsTheWholeDefaultOutput pins the default output of a // generated command whose endpoint returns no payload. Equality on the whole // output, not a substring, is the contract: callers read this through `head`, // so a line pushed down by anything printed above it is a regression a @@ -45,7 +45,7 @@ func newRawStub(t *testing.T, contentType string, body []byte, setRequestID bool // What it must NOT be is the previous output, "OK: POST /channel/disable" — an // echo of the request, which restated the path the caller had just typed and // so read as a description of a result nobody had fetched. -func TestGeneratedWriteAckIsTheWholeDefaultOutput(t *testing.T) { +func TestGeneratedNoPayloadLineIsTheWholeDefaultOutput(t *testing.T) { saveAndResetGlobals(t) newGFStub(t) @@ -61,7 +61,7 @@ func TestGeneratedWriteAckIsTheWholeDefaultOutput(t *testing.T) { } } -// TestGeneratedWriteAckStructuredCarriesOnlyTheRequestID guards the machine +// TestGeneratedNoPayloadStructuredCarriesOnlyTheRequestID guards the machine // path. The old shape was {"message": "OK: POST /channel/disable"} — a rendered // English sentence in a JSON field, which a caller could only string-match. The // new shape carries the request id as data, and carries nothing else, because @@ -71,7 +71,7 @@ func TestGeneratedWriteAckIsTheWholeDefaultOutput(t *testing.T) { // The TOON subtest is not duplicate coverage. TOON is a different marshaller // (toon.Marshal, vs json.MarshalIndent over output.NullUnsetInstants), so a // change that broke only the TOON path would pass every JSON assertion. -func TestGeneratedWriteAckStructuredCarriesOnlyTheRequestID(t *testing.T) { +func TestGeneratedNoPayloadStructuredCarriesOnlyTheRequestID(t *testing.T) { t.Run("json", func(t *testing.T) { saveAndResetGlobals(t) newGFStub(t) @@ -109,12 +109,12 @@ func TestGeneratedWriteAckStructuredCarriesOnlyTheRequestID(t *testing.T) { }) } -// TestGeneratedWriteAckOmitsAnAbsentRequestID covers the case the two wordings +// TestGeneratedNoPayloadOmitsAnAbsentRequestID covers the case the two wordings // exist for. A 200 with an empty body never parses an envelope, so the id can // only come from the Flashcat-Request-Id header; with neither, there is no id. // The line must then say so rather than print a hole, "(request_id )", and the // structured form must emit an empty object rather than invent request_id: "". -func TestGeneratedWriteAckOmitsAnAbsentRequestID(t *testing.T) { +func TestGeneratedNoPayloadOmitsAnAbsentRequestID(t *testing.T) { t.Run("table", func(t *testing.T) { saveAndResetGlobals(t) newRawStub(t, "", nil, false) @@ -178,13 +178,14 @@ var issueExportArgs = []string{ "--data", `{"start_time":1772611200000,"end_time":1775961914595}`, } -// TestGeneratedDownloadStreamsRawBodyInsteadOfTheAck is the other half of the -// branch printRawOrNoPayload chooses between. Response.Raw is populated only for a success -// body that is not envelope JSON, so that guard — not anything in the spec read -// at generation time — is what tells a CSV download apart from a mutation at -// runtime. The bytes must reach stdout verbatim, with no acknowledgement line -// wrapped around them, or `> file.csv` writes a corrupt file. -func TestGeneratedDownloadStreamsRawBodyInsteadOfTheAck(t *testing.T) { +// TestGeneratedDownloadStreamsRawBodyInsteadOfTheNoPayloadLine is the other +// half of the branch printRawOrNoPayload chooses between. Response.Raw is +// populated only for a success body that is not envelope JSON, so that guard — +// not anything in the spec read at generation time — is what tells a CSV +// download apart from a mutation at runtime. The bytes must reach stdout +// verbatim, with no acknowledgement line wrapped around them, or `> file.csv` +// writes a corrupt file. +func TestGeneratedDownloadStreamsRawBodyInsteadOfTheNoPayloadLine(t *testing.T) { saveAndResetGlobals(t) newRawStub(t, "text/csv", []byte(exportCSV), true) @@ -197,12 +198,12 @@ func TestGeneratedDownloadStreamsRawBodyInsteadOfTheAck(t *testing.T) { } } -// TestGeneratedDownloadAcksWhenTheServerSendsNoBytes is the same command +// TestGeneratedDownloadReportsNoPayloadWhenTheServerSendsNoBytes is the same command // answered with an empty body. There is nothing to stream, so it falls through // to the acknowledgement — and "the API returned no payload" is the literal // truth of a zero-byte download, which is why one wording serves both the // mutations and this case instead of two. -func TestGeneratedDownloadAcksWhenTheServerSendsNoBytes(t *testing.T) { +func TestGeneratedDownloadReportsNoPayloadWhenTheServerSendsNoBytes(t *testing.T) { saveAndResetGlobals(t) newRawStub(t, "text/csv", nil, true) diff --git a/internal/cmd/cligen/write_ack_test.go b/internal/cmd/cligen/no_payload_test.go similarity index 100% rename from internal/cmd/cligen/write_ack_test.go rename to internal/cmd/cligen/no_payload_test.go