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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ test_scripts/
tests/mail/reports/

/log/

# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin
18 changes: 16 additions & 2 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type APIOptions struct {
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
}

Expand Down Expand Up @@ -96,6 +97,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")

cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
Expand Down Expand Up @@ -155,6 +157,9 @@ func apiRun(opts *APIOptions) error {
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}

request, err := buildAPIRequest(opts)
if err != nil {
Expand Down Expand Up @@ -184,7 +189,7 @@ func apiRun(opts *APIOptions) error {
}

if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}

Expand All @@ -195,6 +200,7 @@ func apiRun(opts *APIOptions) error {
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
})
Expand All @@ -210,7 +216,15 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}

func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
return output.MarkRaw(err)
}
return nil
}

switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
Expand Down
173 changes: 173 additions & 0 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,179 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
}
}

func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})

var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.JqExpr != ".data" {
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
}
}

func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})

var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.JqExpr != ".data" {
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
}
}

func TestApiCmd_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})

cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --jq + --output conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}

func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})

reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-jq", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/jq",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
map[string]interface{}{"name": "Bob"},
},
},
},
})

cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
t.Errorf("expected jq-filtered names, got: %s", out)
}
// Should NOT contain the full envelope structure
if strings.Contains(out, `"code"`) {
t.Errorf("expected jq to filter out envelope, got: %s", out)
}
}

func TestApiCmd_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})

cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --jq + --format ndjson conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}

func TestApiCmd_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})

cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
}

func TestApiCmd_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
})

reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
"has_more": false,
},
},
})

cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
t.Errorf("expected jq-filtered ids, got: %s", out)
}
if strings.Contains(out, `"code"`) {
t.Errorf("expected jq to filter out envelope, got: %s", out)
}
}

func TestApiCmd_MethodUppercase(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ FLAGS:
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
-o, --output <path> output file path for binary responses
--jq <expr> jq expression to filter JSON output
-q <expr> shorthand for --jq
--dry-run print request without executing

AI AGENT SKILLS:
Expand Down
15 changes: 13 additions & 2 deletions cmd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type ServiceMethodOptions struct {
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
}

Expand Down Expand Up @@ -157,6 +158,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")

_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
Expand Down Expand Up @@ -185,6 +187,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}

config, err := f.ResolveConfig(opts.As)
if err != nil {
Expand Down Expand Up @@ -223,7 +228,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())

if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}

Expand All @@ -234,6 +239,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CheckError: checkErr,
Expand Down Expand Up @@ -400,7 +406,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e
}
}

func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
}

switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
Expand Down
Loading
Loading