Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5393bd6
refactor(output)!: strict typed Format — reject unknown format & ille…
sang-neo03 Jul 21, 2026
e0e034c
refactor(output): scan pretty-rendered output before writing to stdout
sang-neo03 Jul 22, 2026
9f22d89
refactor(output): always warn on stderr when jq may drop a content-sa…
sang-neo03 Jul 22, 2026
ed60f31
refactor(output): unify api/service pagination into client.PaginateTo…
sang-neo03 Jul 22, 2026
0a61b41
fix(output): close pretty-scan truncation gap and normalize dry-run f…
sang-neo03 Jul 22, 2026
d668d75
fix(output): canonicalize shortcut format and align pretty scan window
sang-neo03 Jul 22, 2026
8b30b07
Merge remote-tracking branch 'origin/main' into refactor/output-emitt…
sang-neo03 Jul 22, 2026
35de0b9
Merge remote-tracking branch 'origin/main' into refactor/output-emitt…
sang-neo03 Jul 22, 2026
da5b752
Merge remote-tracking branch 'origin/main' into refactor/output-emitt…
sang-neo03 Jul 22, 2026
463e417
fix(output): scan full pretty output; block mode fails closed
sang-neo03 Jul 23, 2026
cdc88bd
docs(output): clarify FullText scan contract; guard OutFormat unknown…
sang-neo03 Jul 23, 2026
4687e71
fix(output): close block-mode scan race; error on pretty without rend…
sang-neo03 Jul 23, 2026
d6afdaf
fix(output): content-safety full-text capability, format hardening, p…
sang-neo03 Jul 23, 2026
037cd47
fix(output): scan map keys, drop dead scan fn, validate PartialFailur…
sang-neo03 Jul 23, 2026
720c927
fix(output): scan rendered bytes so block mode can't be bypassed by f…
sang-neo03 Jul 23, 2026
1ecaf83
fix(output): rendered-byte content scan, pretty rendering, block cap;…
sang-neo03 Jul 23, 2026
ff7b737
revert(mail): keep mailbox and sender in watch logs and warnings
sang-neo03 Jul 23, 2026
4e221ed
refactor(client): PaginateToOutput takes an options struct
sang-neo03 Jul 23, 2026
dc9b6c1
fix(mail): restore triage table/json rendering via output helpers
sang-neo03 Jul 23, 2026
6249392
chore(output): remove now-unused PrintNdjson
sang-neo03 Jul 23, 2026
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
121 changes: 35 additions & 86 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ package api

import (
"context"
"fmt"
"io"
"regexp"
"strings"

Expand Down Expand Up @@ -234,6 +232,15 @@ func apiRun(opts *APIOptions) error {
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw API responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
Expand All @@ -250,9 +257,17 @@ func apiRun(opts *APIOptions) error {

if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts, format), *fileMeta)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw api command has no
// pretty renderer for responses, so reject it before client init rather than
// fall back. (Dry-run keeps its own plain-text pretty preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
Expand All @@ -263,14 +278,20 @@ func apiRun(opts *APIOptions) error {
}

out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}

if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: ac.CheckResponse,
MarkErr: errs.MarkRaw,
})
}

resp, err := ac.DoAPI(opts.Ctx, request)
Expand Down Expand Up @@ -304,89 +325,17 @@ func apiRun(opts *APIOptions) error {
return nil
}

func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts, format))
}

func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.Format) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
Format: format.String(),
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
}

func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}

switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}
66 changes: 39 additions & 27 deletions cmd/api/api_paginate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ func apiPaginateRequest() client.RawApiRequest {
}
}

// apiPaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}

func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
Expand Down Expand Up @@ -112,10 +129,10 @@ func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
}, ac.CheckResponse, errs.MarkRaw)

if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
Expand Down Expand Up @@ -195,10 +212,10 @@ func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
}, ac.CheckResponse, errs.MarkRaw)

if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
Expand Down Expand Up @@ -239,14 +256,14 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {

err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)

if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
Expand All @@ -256,7 +273,7 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}

func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Expand All @@ -271,22 +288,17 @@ func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
})

err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)

if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
}
}

Expand Down Expand Up @@ -314,10 +326,10 @@ func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
})

err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)

if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
Expand Down Expand Up @@ -349,10 +361,10 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)

err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)

if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
t.Fatal("PaginateToOutput() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
Expand All @@ -379,10 +391,10 @@ func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
})

err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)

if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
Expand Down
Loading
Loading