forked from Gitlink/gitlink-cli
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().String("body-file", "", tr.T("flag.api.body_file"))
|
||||||
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
||||||
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
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().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||||
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
|
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
|
||||||
apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run"))
|
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)
|
env, err := cli.Do(method, path, body, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var apiErr *client.APIError
|
var apiErr *client.APIError
|
||||||
|
|
@ -107,6 +112,27 @@ func runAPI(c *cobra.Command, args []string) error {
|
||||||
return output.Print(env, resolveFormat())
|
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) {
|
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||||
bodyStr, _ := c.Flags().GetString("body")
|
bodyStr, _ := c.Flags().GetString("body")
|
||||||
bodyFile, _ := c.Flags().GetString("body-file")
|
bodyFile, _ := c.Flags().GetString("body-file")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -42,7 +44,7 @@ func TestNewAPICmd(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify flags exist
|
// 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 {
|
for _, f := range flags {
|
||||||
if cmd.Flags().Lookup(f) == nil {
|
if cmd.Flags().Lookup(f) == nil {
|
||||||
t.Fatalf("flag %q not found", f)
|
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 {
|
func writeBatchPlan(t *testing.T, payload interface{}) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := json.Marshal(payload)
|
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) {
|
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
|
||||||
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
|
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
|
||||||
t.Fatal("raw file path should not get .json suffix")
|
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)
|
items = append(items, raw)
|
||||||
}
|
}
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
// Some endpoints wrap in {"data": [...], "total_count": N}
|
arr, ok := listArray(data)
|
||||||
if arr, ok := data["data"]; ok {
|
if !ok {
|
||||||
if slice, ok := arr.([]interface{}); ok {
|
|
||||||
for _, item := range slice {
|
|
||||||
raw, _ := json.Marshal(item)
|
|
||||||
items = append(items, raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single object, not paginated
|
// Single object, not paginated
|
||||||
raw, _ := json.Marshal(data)
|
raw, _ := json.Marshal(data)
|
||||||
return []json.RawMessage{raw}, nil
|
return []json.RawMessage{raw}, nil
|
||||||
}
|
}
|
||||||
|
for _, item := range arr {
|
||||||
|
raw, _ := json.Marshal(item)
|
||||||
|
items = append(items, raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
|
@ -71,3 +68,26 @@ func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage,
|
||||||
|
|
||||||
return all, nil
|
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_file": "Read request body JSON from a file",
|
||||||
"flag.api.body_stdin": "Read request body JSON from stdin",
|
"flag.api.body_stdin": "Read request body JSON from stdin",
|
||||||
"flag.api.header": "Additional headers (key:value)",
|
"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.api.query": "Query parameters (key=val&key2=val2)",
|
||||||
"flag.auth.token": "Login by pasting an existing token",
|
"flag.auth.token": "Login by pasting an existing token",
|
||||||
"flag.branch.from": "Source branch or commit",
|
"flag.branch.from": "Source branch or commit",
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@
|
||||||
"flag.api.body_file": "从文件读取 JSON 请求体",
|
"flag.api.body_file": "从文件读取 JSON 请求体",
|
||||||
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
|
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
|
||||||
"flag.api.header": "附加请求头(key:value)",
|
"flag.api.header": "附加请求头(key:value)",
|
||||||
|
"flag.api.paginate": "抓取所有分页结果并合并为一个数组输出",
|
||||||
"flag.api.query": "查询参数(key=val&key2=val2)",
|
"flag.api.query": "查询参数(key=val&key2=val2)",
|
||||||
"flag.auth.token": "通过粘贴已有 Token 登录",
|
"flag.auth.token": "通过粘贴已有 Token 登录",
|
||||||
"flag.branch.from": "源分支或 Commit",
|
"flag.branch.from": "源分支或 Commit",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue