feat(api): add --paginate and unwrap GitLink list responses in PaginateAll
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
9749a4c832
commit
5511d492cb
|
|
@ -40,6 +40,7 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
|||
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
|
||||
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
||||
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
||||
apiCmd.Flags().Bool("paginate", false, tr.T("flag.api.paginate"))
|
||||
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
|
||||
apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run"))
|
||||
|
|
@ -94,6 +95,10 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
if paginate, _ := c.Flags().GetBool("paginate"); paginate {
|
||||
return runAPIPaginate(cli, method, path, query)
|
||||
}
|
||||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
if err != nil {
|
||||
var apiErr *client.APIError
|
||||
|
|
@ -107,6 +112,27 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
// runAPIPaginate walks every page and prints the concatenated items as one array.
|
||||
// PaginateAll drives GET only, so a non-GET method must fail loudly rather than
|
||||
// silently degrade.
|
||||
func runAPIPaginate(cli *client.Client, method, path string, query url.Values) error {
|
||||
if method != "GET" {
|
||||
return fmt.Errorf("--paginate only supports GET requests, got %s", method)
|
||||
}
|
||||
|
||||
items, err := cli.PaginateAll(path, query)
|
||||
if err != nil {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
return output.Print(errEnv, resolveFormat())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return output.Print(output.SuccessEnvelope(items, nil), resolveFormat())
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
|
@ -42,7 +44,7 @@ func TestNewAPICmd(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify flags exist
|
||||
flags := []string{"body", "query", "header", "batch-file", "dry-run", "continue-on-error", "var"}
|
||||
flags := []string{"body", "query", "paginate", "header", "batch-file", "dry-run", "continue-on-error", "var"}
|
||||
for _, f := range flags {
|
||||
if cmd.Flags().Lookup(f) == nil {
|
||||
t.Fatalf("flag %q not found", f)
|
||||
|
|
@ -390,6 +392,94 @@ func TestRunAPIBatchContinueOnError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// captureStdout redirects os.Stdout while fn runs, since output.Print writes there directly.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
done <- buf.String()
|
||||
}()
|
||||
fn()
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
return <-done
|
||||
}
|
||||
|
||||
type paginateEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func TestRunAPIPaginateCombinesPages(t *testing.T) {
|
||||
// Real GitLink list shape wraps the array under a resource key, not "data".
|
||||
var pages []string
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/repos/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
page := r.URL.Query().Get("page")
|
||||
pages = append(pages, page)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch page {
|
||||
case "1":
|
||||
w.Write([]byte(`{"total_count":3,"issues":[{"id":1},{"id":2}]}`))
|
||||
case "2":
|
||||
w.Write([]byte(`{"total_count":3,"issues":[{"id":3}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected page: %s", page)
|
||||
}
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/repos/owner/repo/issues", "--paginate", "--query", "limit=2"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("paginate error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var env paginateEnvelope
|
||||
if err := json.Unmarshal([]byte(out), &env); err != nil {
|
||||
t.Fatalf("unmarshal output %q: %v", out, err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got %s", out)
|
||||
}
|
||||
if len(env.Data) != 3 {
|
||||
t.Fatalf("expected 3 combined items, got %d (%s)", len(env.Data), out)
|
||||
}
|
||||
for i, want := range []float64{1, 2, 3} {
|
||||
if env.Data[i]["id"] != want {
|
||||
t.Fatalf("item[%d].id = %v, want %v", i, env.Data[i]["id"], want)
|
||||
}
|
||||
}
|
||||
if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" {
|
||||
t.Fatalf("expected pages [1 2], got %v", pages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIPaginateRejectsNonGet(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("non-GET paginate should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"POST", "/items", "--paginate"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for --paginate with non-GET method")
|
||||
}
|
||||
}
|
||||
|
||||
func writeBatchPlan(t *testing.T, payload interface{}) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(payload)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# 新增 `api --paginate` 自动翻页
|
||||
|
||||
`gitlink-cli api GET <PATH> --paginate` 对齐 `gh api --paginate`:自动逐页抓取并把所有条目拼接成一个数组输出,免去手动传 `page`/`limit` 逐页拉取。仅支持 GET,其它方法会明确报错而不是静默退化。
|
||||
|
||||
配套修复了 `PaginateAll` 无法解包真实 GitLink 列表响应的问题。此前它只认顶层裸数组或 `data` 键下的数组,而 GitLink 列表接口把数组包在资源专属键下(`{"total_count":N,"pulls":[...]}`、`{"issues":[...]}`、`{"branches":[...]}` 等),这类响应会被当成单个对象直接返回、根本不翻页。现在解析顺序为:优先取 `data` 数组;否则取 map 中唯一的数组字段(覆盖 pulls/issues/branches/labels 等);无数组字段或存在多个数组字段(歧义)时,保留“单对象作为单元素返回”的旧行为。短页终止(本页条目数小于 limit 即停止)与既有的裸数组、`data` 包裹用例保持不变。
|
||||
|
||||
本次变更包含 `PaginateAll` 解包逻辑修复、`--paginate` 标志与 `runAPIPaginate` 路由、中英文帮助文案,以及单元测试:client 层验证 `{total_count, issues:[...]}` 两页拼接并正确解包 `issues`;cmd 层端到端验证 `--paginate` 合并多页输出与非 GET 报错。
|
||||
|
|
@ -547,6 +547,48 @@ func TestPaginateAllNotOK(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllGitLinkWrapperShape(t *testing.T) {
|
||||
// GitLink list endpoints wrap the array under a resource-specific key
|
||||
// ({"total_count":N,"issues":[...]}) rather than the generic "data" key.
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Query().Get("page") {
|
||||
case "1":
|
||||
w.Write([]byte(`{"total_count":3,"issues":[{"id":1},{"id":2}]}`))
|
||||
case "2":
|
||||
w.Write([]byte(`{"total_count":3,"issues":[{"id":3}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected page: %s", r.URL.Query().Get("page"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
params := url.Values{}
|
||||
params.Set("limit", "2")
|
||||
items, err := c.PaginateAll("/repos/owner/repo/issues", params)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAll error: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("expected 3 combined items, got %d", len(items))
|
||||
}
|
||||
for i, want := range []float64{1, 2, 3} {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(items[i], &obj); err != nil {
|
||||
t.Fatalf("unmarshal item %d: %v", i, err)
|
||||
}
|
||||
if obj["id"] != want {
|
||||
t.Fatalf("item[%d].id = %v, want %v", i, obj["id"], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
|
||||
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
|
||||
t.Fatal("raw file path should not get .json suffix")
|
||||
|
|
|
|||
|
|
@ -39,19 +39,16 @@ func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage,
|
|||
items = append(items, raw)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// Some endpoints wrap in {"data": [...], "total_count": N}
|
||||
if arr, ok := data["data"]; ok {
|
||||
if slice, ok := arr.([]interface{}); ok {
|
||||
for _, item := range slice {
|
||||
raw, _ := json.Marshal(item)
|
||||
items = append(items, raw)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
arr, ok := listArray(data)
|
||||
if !ok {
|
||||
// Single object, not paginated
|
||||
raw, _ := json.Marshal(data)
|
||||
return []json.RawMessage{raw}, nil
|
||||
}
|
||||
for _, item := range arr {
|
||||
raw, _ := json.Marshal(item)
|
||||
items = append(items, raw)
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
|
|
@ -71,3 +68,26 @@ func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage,
|
|||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// listArray resolves the item array of one wrapped list page. GitLink wraps the
|
||||
// array under the generic "data" key on some endpoints and under a
|
||||
// resource-specific key on others ({"pulls":[...]}, {"issues":[...]},
|
||||
// {"branches":[...]}, ...), so prefer "data" and otherwise accept the sole
|
||||
// array-valued field. A map with no array field — or several, which is
|
||||
// ambiguous — is not a list page, so ok is false.
|
||||
func listArray(data map[string]interface{}) (arr []interface{}, ok bool) {
|
||||
if d, isArr := data["data"].([]interface{}); isArr {
|
||||
return d, true
|
||||
}
|
||||
for _, v := range data {
|
||||
slice, isArr := v.([]interface{})
|
||||
if !isArr {
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
return nil, false
|
||||
}
|
||||
arr, ok = slice, true
|
||||
}
|
||||
return arr, ok
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"flag.api.body_file": "Read request body JSON from a file",
|
||||
"flag.api.body_stdin": "Read request body JSON from stdin",
|
||||
"flag.api.header": "Additional headers (key:value)",
|
||||
"flag.api.paginate": "Fetch every page of results and output as one combined array",
|
||||
"flag.api.query": "Query parameters (key=val&key2=val2)",
|
||||
"flag.auth.token": "Login by pasting an existing token",
|
||||
"flag.branch.from": "Source branch or commit",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"flag.api.body_file": "从文件读取 JSON 请求体",
|
||||
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
|
||||
"flag.api.header": "附加请求头(key:value)",
|
||||
"flag.api.paginate": "抓取所有分页结果并合并为一个数组输出",
|
||||
"flag.api.query": "查询参数(key=val&key2=val2)",
|
||||
"flag.auth.token": "通过粘贴已有 Token 登录",
|
||||
"flag.branch.from": "源分支或 Commit",
|
||||
|
|
|
|||
Loading…
Reference in New Issue