Files
larksuite-cli/internal/output/format.go
liangshuo-1 f7a56f38b1 feat(contact +search-user): add --queries multi-name fanout (#707)
Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
2026-04-29 17:03:21 +08:00

197 lines
5.2 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
)
// Known array field names for pagination.
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"users",
}
// FindArrayField finds the primary array field in a response's data object.
// It first checks knownArrayFields in priority order, then falls back to
// the lexicographically smallest unknown array field for deterministic results.
func FindArrayField(data map[string]interface{}) string {
for _, name := range knownArrayFields {
if arr, ok := data[name]; ok {
if _, isArr := arr.([]interface{}); isArr {
return name
}
}
}
// Fallback: lexicographically first array field (deterministic)
var candidates []string
for k, v := range data {
if _, isArr := v.([]interface{}); isArr {
candidates = append(candidates, k)
}
}
if len(candidates) > 0 {
sort.Strings(candidates)
return candidates[0]
}
return ""
}
// toGeneric normalises any Go value (structs, typed slices, …) into
// plain map[string]interface{} / []interface{} via a JSON round-trip so
// that subsequent type assertions in format handlers work uniformly.
func toGeneric(v interface{}) interface{} {
switch v.(type) {
case map[string]interface{}, []interface{}, nil:
return v // already generic
}
b, err := json.Marshal(v)
if err != nil {
return v
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber() // preserve int64 precision (avoid float64 truncation)
var out interface{}
if err := dec.Decode(&out); err != nil {
return v
}
return out
}
// ExtractItems extracts the data array from a response.
// It tries two strategies in order:
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
//
// If data is already a plain []interface{}, it is returned as-is.
func ExtractItems(data interface{}) []interface{} {
resultMap, ok := data.(map[string]interface{})
if !ok {
if arr, ok := data.([]interface{}); ok {
return arr
}
return nil
}
// Strategy 1: Lark API envelope — result["data"][arrayField]
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
if field := FindArrayField(dataObj); field != "" {
if items, ok := dataObj[field].([]interface{}); ok {
return items
}
}
}
// Strategy 2: direct map — result[arrayField]
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
if field := FindArrayField(resultMap); field != "" {
if items, ok := resultMap[field].([]interface{}); ok {
return items
}
}
return nil
}
// FormatValue formats a single response and writes it to w.
func FormatValue(w io.Writer, data interface{}, format Format) {
data = toGeneric(data)
switch format {
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
PrintNdjson(w, items)
} else {
PrintNdjson(w, data)
}
case FormatTable:
items := ExtractItems(data)
if items != nil {
FormatAsTable(w, items)
} else {
FormatAsTable(w, data)
}
case FormatCSV:
items := ExtractItems(data)
if items != nil {
FormatAsCSV(w, items)
} else {
FormatAsCSV(w, data)
}
default: // FormatJSON
PrintJson(w, data)
}
}
// PaginatedFormatter holds state across paginated calls to ensure
// consistent columns (table/csv use the first page's columns for all pages).
type PaginatedFormatter struct {
W io.Writer
Format Format
isFirstPage bool
cols []string // locked after first page
}
// NewPaginatedFormatter creates a formatter that tracks pagination state.
func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
return &PaginatedFormatter{W: w, Format: format, isFirstPage: true}
}
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
PrintNdjson(pf.W, arr)
} else {
PrintNdjson(pf.W, data)
}
case FormatTable:
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
widths := computeColumnWidths(rows, cols)
if isFirst {
writeHeader(w, cols, widths)
}
for _, row := range rows {
writeRow(w, row, cols, widths)
}
})
case FormatCSV:
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
writeCSVRows(w, rows, cols, isFirst)
})
}
}
// formatStructuredPage handles column-locking logic shared by table and csv.
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
rows, pageCols, isList := prepareRows(data)
if len(rows) == 0 {
if pf.isFirstPage && isList {
fmt.Fprintln(pf.W, "(empty)")
}
return
}
if pf.isFirstPage {
// Lock columns from first page
pf.cols = pageCols
pf.isFirstPage = false
emit(pf.W, rows, pf.cols, true)
} else {
// Reuse first page's columns — missing keys become empty, extra keys ignored
emit(pf.W, rows, pf.cols, false)
}
}