Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions cli/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down
237 changes: 237 additions & 0 deletions cli/cmd/api/paginate_text_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
Loading