mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
c74d9b63fb
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.
Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.
Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
163 lines
6.1 KiB
Go
163 lines
6.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
|
||
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
|
||
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
|
||
const maxFileListPageSize = 200
|
||
|
||
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
|
||
func validateFileListPageSize(n int) error {
|
||
if n < 1 || n > maxFileListPageSize {
|
||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||
//
|
||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
|
||
// file 域不分 dev/online,无 --env。
|
||
//
|
||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||
// server 字段 created_at → 产品语义 uploaded_at。
|
||
var AppsFileList = common.Shortcut{
|
||
Service: appsService,
|
||
Command: "+file-list",
|
||
Description: "List files in a Miaoda app's storage (cursor pagination)",
|
||
Risk: "read",
|
||
Tips: []string{
|
||
"Example: lark-cli apps +file-list --app-id <app_id>",
|
||
"Tip: filter fields with --jq, e.g. -q '.data.items[].path'",
|
||
},
|
||
Scopes: []string{"spark:app:read"},
|
||
AuthTypes: []string{"user"},
|
||
HasFormat: true,
|
||
Flags: []common.Flag{
|
||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||
{Name: "name", Desc: "filter by exact file name"},
|
||
{Name: "path", Desc: "filter by exact remote path"},
|
||
{Name: "type", Desc: "filter by MIME type"},
|
||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
|
||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||
},
|
||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||
return err
|
||
}
|
||
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
|
||
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
|
||
return err
|
||
}
|
||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||
continue
|
||
}
|
||
n, err := normalizeTimestamp(rctx.Str(f))
|
||
if err != nil {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
|
||
}
|
||
_ = rctx.Cmd.Flags().Set(f, n)
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||
return common.NewDryRunAPI().
|
||
GET(appFileListPath(appID)).
|
||
Desc("List Miaoda app files").
|
||
Params(buildFileListParams(rctx))
|
||
},
|
||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
appID, err := requireAppID(rctx.Str("app-id"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。
|
||
items := projectFileItems(data["items"])
|
||
data["items"] = items
|
||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||
renderFileListPretty(w, items)
|
||
})
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfo(created_*→uploaded_*)。
|
||
func projectFileItems(raw interface{}) []fileInfo {
|
||
arr, _ := raw.([]interface{})
|
||
out := make([]fileInfo, 0, len(arr))
|
||
for _, it := range arr {
|
||
if m, ok := it.(map[string]interface{}); ok {
|
||
out = append(out, projectFileInfo(m))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// buildFileListParams 组装 file_list 查询参数:page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。
|
||
func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||
params := map[string]interface{}{
|
||
"page_size": rctx.Int("page-size"),
|
||
}
|
||
addStr := func(flag, key string) {
|
||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||
params[key] = v
|
||
}
|
||
}
|
||
addStr("name", "name")
|
||
addStr("path", "path")
|
||
addStr("type", "type")
|
||
addStr("uploaded-since", "uploaded_since")
|
||
addStr("uploaded-until", "uploaded_until")
|
||
addStr("page-token", "page_token")
|
||
if v := rctx.Int("size-gt"); v > 0 {
|
||
params["size_gt"] = v
|
||
}
|
||
if v := rctx.Int("size-lt"); v > 0 {
|
||
params["size_lt"] = v
|
||
}
|
||
return params
|
||
}
|
||
|
||
// renderFileListPretty 5 列对齐表:file_name / path / size / type / uploaded_at。
|
||
func renderFileListPretty(w io.Writer, items []fileInfo) {
|
||
if len(items) == 0 {
|
||
io.WriteString(w, "No files found.\n")
|
||
return
|
||
}
|
||
headers := []string{"file_name", "path", "size", "type", "uploaded_at"}
|
||
rows := make([][]string, 0, len(items))
|
||
for _, it := range items {
|
||
rows = append(rows, []string{
|
||
dashIfEmpty(it.FileName),
|
||
it.Path,
|
||
humanBytes(it.SizeBytes),
|
||
dashIfEmpty(it.Type),
|
||
dashIfEmpty(it.UploadedAt),
|
||
})
|
||
}
|
||
renderAlignedTable(w, headers, rows)
|
||
}
|