diff --git a/cli/cmd/api/api.go b/cli/cmd/api/api.go index 538e33f7e43..bf6d6c911c5 100644 --- a/cli/cmd/api/api.go +++ b/cli/cmd/api/api.go @@ -63,10 +63,12 @@ to POST. Use -X/--method to override (any non-empty method is accepted: DELETE / PUT / PATCH / HEAD / OPTIONS / TRACE / custom). Auth, tenant, and request-id headers are applied automatically from the -active profile. In text mode (default) the raw server response body is written -to stdout. In --format json the parsed server response is placed directly +active profile. In --format text the raw server response body is written +to stdout. In --format json (default) the parsed server response is placed directly under envelope.data — drill in with --jq '.data...' at the server's own depth (e.g. '.data.data[]' for a list endpoint). Only -X DELETE is confirmation-gated. +With --paginate, text mode writes the merged {data, total} JSON object with a +trailing newline and no envelope. Examples: weknora api /api/v1/knowledge-bases # GET @@ -162,7 +164,12 @@ Examples: "weknora api -X DELETE /api/v1/knowledge-bases/kb_x -y", "echo '{\"name\":\"foo\"}' | weknora api /api/v1/knowledge-bases --input -", }, - Output: "text mode (default): the raw server response body on stdout. json mode: the parsed server response is placed directly under envelope.data — project with --jq '.data...' at the server's own depth (e.g. '.data.data[]' for a list endpoint, '.data.data.id' for a created object). With --paginate, envelope.data is the merged {data, total}.", + Output: "text mode: the raw server response body on stdout. " + + "json mode (default): the parsed server response is placed directly under envelope.data - " + + "project with --jq '.data...' at the server's own depth " + + "(e.g. '.data.data[]' for a list endpoint, '.data.data.id' for a created object). " + + "With --paginate, envelope.data is the merged {data, total}; " + + "text mode writes that merged JSON object with a trailing newline and no envelope.", Warnings: []string{ "-X DELETE is destructive-gated and -X PUT/PATCH are write-gated (exit 10 / input.confirmation_required unless -y), matching typed delete/update. -X POST (create-shaped) and GET are unguarded — you own the safety of creates made through this escape hatch.", "Raw passthrough: the typed error envelope does NOT fully apply. The server's own response goes under envelope.data at its native depth; a non-2xx HTTP status surfaces via the exit code, not a typed error.type/retry_argv. Do not rely on error.type/retryable for `api` the way you do for typed subcommands.", @@ -419,6 +426,9 @@ func emitRawBody(body []byte, fopts *cmdutil.FormatOptions) error { // response is passed through via passThroughFallback which respects the // --format envelope contract (same shape as runAPISingle's fallback path). func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, path string) error { + if fopts.Mode == cmdutil.FormatText && fopts.JQ != "" { + return cmdutil.NewFlagError(fmt.Errorf("--jq requires --format json|ndjson")) + } if !strings.HasPrefix(path, "/") { return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path)) } @@ -481,7 +491,13 @@ func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOp "data": allData, "total": lastTotal, } - return fopts.Emit(iostreams.IO.Out, merged, nil) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, merged, nil) + } + if err := json.NewEncoder(iostreams.IO.Out).Encode(merged); err != nil { + return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write response body") + } + return nil } // extractPageSize parses the page_size query parameter from path, returning 0 diff --git a/cli/cmd/api/paginate_text_test.go b/cli/cmd/api/paginate_text_test.go new file mode 100644 index 00000000000..d22e90e294a --- /dev/null +++ b/cli/cmd/api/paginate_text_test.go @@ -0,0 +1,237 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/cmd" + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +func TestAPI_PaginateFormats(t *testing.T) { + one := []string{`{"data":[{"id":"1"}],"total":1,"page":1,"page_size":1}`} + pages := []string{ + `{"data":[{"id":"1"}],"total":3,"page":1,"page_size":1}`, + `{"data":[{"id":"2"}],"total":3,"page":2,"page_size":1}`, + `{"data":[{"id":"3"}],"total":3,"page":3,"page_size":1}`, + } + empty := []string{`{"data":[],"total":0,"page":1,"page_size":1}`} + merged := `{"data":[{"id":"1"},{"id":"2"},{"id":"3"}],"total":3}` + envelope := `{"ok":true,"data":` + merged + `}` + preciseData := `[9007199254740993,{"n":9007199254740993,"nested":[1.25e+3,true,null,"007"]}]` + precise := []string{`{"data":` + preciseData + `,"total":2,"page_size":2}`} + preciseMerged := `{"data":` + preciseData + `,"total":2}` + + cases := []struct { + name string + bodies []string + flags []string + env string + json string + raw string + tty bool + post bool + single bool + wantExit int + }{ + { + name: "text single page", bodies: one, flags: []string{"--format", "text"}, + json: `{"data":[{"id":"1"}],"total":1}`, + }, + {name: "text multiple capped pages", bodies: pages, flags: []string{"--format", "text"}, json: merged}, + {name: "text empty", bodies: empty, flags: []string{"--format", "text"}, json: `{"data":[],"total":0}`}, + {name: "text environment", bodies: pages, env: "text", json: merged}, + { + name: "text flag overrides json environment", bodies: pages, env: "json", + flags: []string{"--format", "text"}, json: merged, + }, + { + name: "json flag overrides text environment", bodies: pages, env: "text", + flags: []string{"--format", "json"}, json: envelope, + }, + {name: "default pipe", bodies: pages, json: envelope}, + {name: "default tty", bodies: pages, tty: true, json: envelope}, + {name: "json", bodies: pages, flags: []string{"--format", "json"}, json: envelope}, + {name: "ndjson", bodies: pages, flags: []string{"--format", "ndjson"}, json: merged}, + { + name: "json jq", bodies: pages, env: "text", + flags: []string{"--format", "json", "--jq", ".data.data | length"}, json: "3", + }, + { + name: "ndjson jq", bodies: pages, env: "text", + flags: []string{"--format", "ndjson", "--jq", ".data | length"}, json: "3", + }, + { + name: "text environment jq rejected", bodies: pages, env: "text", + flags: []string{"--jq", ".data | length"}, wantExit: 2, + }, + { + name: "text environment malformed jq rejected", bodies: pages, env: "text", + flags: []string{"--jq", "["}, wantExit: 2, + }, + {name: "text jq rejected", bodies: pages, flags: []string{"--format", "text", "--jq", ".data"}, wantExit: 2}, + {name: "text precise values", bodies: precise, flags: []string{"--format", "text"}, json: preciseMerged}, + { + name: "json precise values", bodies: precise, flags: []string{"--format", "json"}, + json: `{"ok":true,"data":` + preciseMerged + `}`, + }, + {name: "ndjson precise values", bodies: precise, flags: []string{"--format", "ndjson"}, json: preciseMerged}, + { + name: "text no metadata", bodies: []string{` {"data":[9007199254740993]} `}, + flags: []string{"--format", "text"}, raw: " {\"data\":[9007199254740993]} \n", + }, + { + name: "text non json fallback", bodies: []string{"healthy"}, + flags: []string{"--format", "text"}, raw: "healthy\n", + }, + { + name: "text fallback existing newline", bodies: []string{"healthy\n"}, + flags: []string{"--format", "text"}, raw: "healthy\n", + }, + {name: "text fallback empty body", bodies: []string{""}, flags: []string{"--format", "text"}}, + {name: "text non GET", bodies: one, flags: []string{"--format", "text"}, post: true, raw: one[0] + "\n"}, + { + name: "text non paginated", bodies: one, flags: []string{"--format", "text"}, + single: true, raw: one[0] + "\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("WEKNORA_FORMAT", tc.env) + t.Setenv("WEKNORA_PROFILE", "") + out, errOut := iostreams.SetForTest(t) + if tc.tty { + out, errOut = iostreams.SetForTestWithTTY(t) + } + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := int(calls.Add(1)) + assert.Equal(t, "/api/v1/items", r.URL.Path) + assert.Equal(t, "active", r.URL.Query().Get("status")) + method := http.MethodGet + if tc.post { + method = http.MethodPost + } + assert.Equal(t, method, r.Method) + if !tc.post && !tc.single { + assert.Equal(t, strconv.Itoa(n), r.URL.Query().Get("page")) + assert.Equal(t, "50", r.URL.Query().Get("page_size")) + } else { + assert.Empty(t, r.URL.Query().Get("page")) + } + if n > len(tc.bodies) { + t.Errorf("unexpected request %d", n) + http.Error(w, "too many requests", http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, tc.bodies[n-1]) + })) + defer srv.Close() + f := &cmdutil.Factory{Client: func() (*sdk.Client, error) { return sdk.NewClient(srv.URL), nil }} + root := cmd.NewRootCmd(f) + root.SetOut(out) + root.SetErr(errOut) + args := append([]string{}, tc.flags...) + args = append(args, "api", "/api/v1/items?status=active") + if !tc.single { + args = append(args, "--paginate") + } + if tc.post { + args = append(args, "-X", "POST") + } + root.SetArgs(args) + err := root.Execute() + if tc.wantExit != 0 { + require.Error(t, err) + assert.Equal(t, tc.wantExit, cmdutil.ExitCode(err)) + assert.Contains(t, err.Error(), "--jq requires --format json|ndjson") + assert.Zero(t, calls.Load()) + assert.Empty(t, out.String()) + return + } + require.NoError(t, err) + assert.Equal(t, int32(len(tc.bodies)), calls.Load()) + assert.Empty(t, errOut.String()) + if tc.json == "" { + assert.Equal(t, tc.raw, out.String()) + return + } + assert.True(t, strings.HasSuffix(out.String(), "\n")) + // UseNumber keeps this comparison sensitive to RawMessage precision loss. + decode := func(s string) any { + d := json.NewDecoder(strings.NewReader(s)) + d.UseNumber() + var v any + require.NoError(t, d.Decode(&v)) + var extra any + require.ErrorIs(t, d.Decode(&extra), io.EOF, "expected one JSON value") + return v + } + assert.Equal(t, decode(tc.json), decode(out.String())) + if !tc.tty { + assert.Equal(t, 1, strings.Count(out.String(), "\n")) + } + }) + } +} + +type paginationFailWriter struct { + body bytes.Buffer + err error + tail bool +} + +func (w *paginationFailWriter) Write(p []byte) (int, error) { + if w.tail { + // Fail on the newline even if the encoder writes it with the body. + if i := bytes.IndexByte(p, '\n'); i >= 0 { + n, _ := w.body.Write(p[:i]) + return n, w.err + } + return w.body.Write(p) + } + return 0, w.err +} + +func TestAPI_PaginateTextWriteError(t *testing.T) { + for _, tail := range []bool{false, true} { + t.Run(strconv.FormatBool(tail), func(t *testing.T) { + t.Setenv("WEKNORA_PROFILE", "") + iostreams.SetForTest(t) + sentinel := errors.New("synthetic output failure") + out := &paginationFailWriter{err: sentinel, tail: tail} + iostreams.IO.Out = out + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[{"id":"1"}],"total":1,"page_size":1}`) + })) + defer srv.Close() + f := &cmdutil.Factory{Client: func() (*sdk.Client, error) { return sdk.NewClient(srv.URL), nil }} + root := cmd.NewRootCmd(f) + root.SetArgs([]string{"api", "/api/v1/items", "--paginate", "--format", "text"}) + err := root.Execute() + require.ErrorIs(t, err, sentinel) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeLocalFileIO, typed.Code) + assert.Equal(t, 1, cmdutil.ExitCode(err)) + if tail { + assert.NotEmpty(t, out.body.String()) + } else { + assert.Empty(t, out.body.String()) + } + }) + } +}