mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
29 Commits
v1.0.54
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e691781eb | ||
|
|
c8de4e3692 | ||
|
|
a72331d007 | ||
|
|
9950a00da4 | ||
|
|
cf3c5f13eb | ||
|
|
b1e58d1340 | ||
|
|
0a17ddc45d | ||
|
|
773b93cb10 | ||
|
|
82a983888b | ||
|
|
9847b16d1a | ||
|
|
bed30c4ecb | ||
|
|
a7be567066 | ||
|
|
e96acad2c5 | ||
|
|
7ac8a7d30e | ||
|
|
31523b7f50 | ||
|
|
02a37029c2 | ||
|
|
556d7e3a77 | ||
|
|
f18a082a4f | ||
|
|
b8c5176483 | ||
|
|
82937a0a37 | ||
|
|
1cafb94a62 | ||
|
|
0b33daa136 | ||
|
|
5a61b97ac3 | ||
|
|
e01f2dfdd5 | ||
|
|
45f807459e | ||
|
|
8906e87fb1 | ||
|
|
d5a53d921d | ||
|
|
0ff7f0407e | ||
|
|
6e067f2180 |
30
.github/CODEOWNERS
vendored
30
.github/CODEOWNERS
vendored
@@ -1,30 +0,0 @@
|
||||
/internal/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,8 +35,6 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -17,7 +17,6 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
|
||||
114
CHANGELOG.md
114
CHANGELOG.md
@@ -2,115 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||
- **vfs**: Reject blank local paths (#1460)
|
||||
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
@@ -1175,11 +1066,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
|
||||
9
Makefile
9
Makefile
@@ -8,13 +8,6 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
@@ -41,7 +34,7 @@ fmt-check:
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
|
||||
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -66,24 +66,6 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "page_size") {
|
||||
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_BotMode(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -91,29 +91,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -132,27 +109,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -170,27 +126,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -210,29 +145,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -355,32 +267,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -38,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -35,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -46,14 +44,6 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
@@ -71,14 +61,6 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +92,16 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
dm := domainMeta{Name: name}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||
revokeToken := token.RefreshToken
|
||||
tokenTypeHint := "refresh_token"
|
||||
if revokeToken == "" {
|
||||
revokeToken = token.AccessToken
|
||||
tokenTypeHint = "access_token"
|
||||
}
|
||||
if revokeToken != "" {
|
||||
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-refresh-token" &&
|
||||
values.Get("token_type_hint") == "refresh_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"error": "server_error"},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
gotErr := stderr.String()
|
||||
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||
}
|
||||
if !strings.Contains(gotErr, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
|
||||
// service command tree for any depth — flat dotted resources AND genuinely
|
||||
// nested resources — so it round-trips through apicatalog.Resolve instead of
|
||||
// assuming a fixed root->service->resource->method shape.
|
||||
func TestCommandCatalogPath(t *testing.T) {
|
||||
chain := func(names ...string) *cobra.Command {
|
||||
var parent, leaf *cobra.Command
|
||||
for _, n := range names {
|
||||
c := &cobra.Command{Use: n}
|
||||
if parent != nil {
|
||||
parent.AddCommand(c)
|
||||
}
|
||||
parent = c
|
||||
leaf = c
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
leaf *cobra.Command
|
||||
want []string
|
||||
}{
|
||||
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
|
||||
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
|
||||
{"service level", chain("lark-cli", "im"), []string{"im"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The root command (no parent) has no catalog path.
|
||||
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
|
||||
t.Errorf("root path = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,15 @@ const probeTimeout = 3 * time.Second
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
|
||||
@@ -31,10 +31,10 @@ type fakeRT struct {
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
@@ -84,15 +84,14 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -104,6 +103,9 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
@@ -121,13 +123,11 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
@@ -137,27 +137,28 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -119,37 +118,38 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command. It reconstructs the catalog path from the
|
||||
// command ancestry and resolves it through the same navigation Module the
|
||||
// command tree is built from (apicatalog), so it stays correct for nested
|
||||
// resources instead of hard-coding a root->service->resource->method depth.
|
||||
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||
// target and yield no scopes.
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
return nil
|
||||
}
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||
// the service command tree's construction, so any depth (flat or nested)
|
||||
// round-trips through apicatalog.Resolve.
|
||||
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
var path []string
|
||||
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||
path = append([]string{c.Name()}, path...)
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
return path
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
|
||||
@@ -143,79 +143,6 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
|
||||
@@ -134,16 +134,12 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -152,7 +148,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
|
||||
@@ -96,79 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -243,17 +242,12 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>
|
||||
lark-cli schema <service.resource.method> [--format pretty]
|
||||
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
|
||||
@@ -5,17 +5,17 @@ package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,10 +24,336 @@ type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Args are the positional path segments, in either the dotted single-arg
|
||||
// form ("im.messages.reply") or the space-separated form ("im messages
|
||||
// reply"); apicatalog.ParsePath normalizes both.
|
||||
Args []string
|
||||
// Positional args
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
}
|
||||
|
||||
func printServices(w io.Writer) {
|
||||
services := registry.ListFromMetaProjects()
|
||||
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
|
||||
for _, s := range services {
|
||||
spec := registry.LoadFromMeta(s)
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
|
||||
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
|
||||
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
danger := ""
|
||||
if d, _ := m["danger"].(bool); d {
|
||||
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
methodPath := registry.GetStrFromMap(method, "path")
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
httpColor := output.Yellow
|
||||
if httpMethod == "GET" {
|
||||
httpColor = output.Green
|
||||
} else if httpMethod == "DELETE" {
|
||||
httpColor = output.Red
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
|
||||
if desc != "" {
|
||||
fmt.Fprintf(w, " %s\n", desc)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Parameters
|
||||
params, _ := method["parameters"].(map[string]interface{})
|
||||
if len(params) > 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
for _, paramName := range sortedParamKeys(params) {
|
||||
p, _ := params[paramName].(map[string]interface{})
|
||||
pType := registry.GetStrFromMap(p, "type")
|
||||
if pType == "" {
|
||||
pType = "string"
|
||||
}
|
||||
location := registry.GetStrFromMap(p, "location")
|
||||
required, _ := p["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
locColor := output.Dim
|
||||
if location == "path" {
|
||||
locColor = output.Yellow
|
||||
}
|
||||
// Options (enum values)
|
||||
optStr := formatOptions(p)
|
||||
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
|
||||
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
|
||||
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
|
||||
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(p); rangeStr != "" {
|
||||
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --data for write methods
|
||||
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Response
|
||||
responseBody, _ := method["responseBody"].(map[string]interface{})
|
||||
if len(responseBody) > 0 {
|
||||
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
|
||||
printNestedFields(w, responseBody, " ", "")
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Identity
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
var identities []string
|
||||
for _, t := range tokens {
|
||||
if s, ok := t.(string); ok {
|
||||
switch s {
|
||||
case "user":
|
||||
identities = append(identities, "user")
|
||||
case "tenant":
|
||||
identities = append(identities, "bot")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(identities) > 0 {
|
||||
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes (all)
|
||||
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
|
||||
var scopeStrs []string
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok {
|
||||
scopeStrs = append(scopeStrs, str)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
|
||||
}
|
||||
|
||||
// CLI example
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
|
||||
for _, fieldName := range sortedFieldKeys(fields) {
|
||||
f, _ := fields[fieldName].(map[string]interface{})
|
||||
fullName := fieldName
|
||||
if prefix != "" {
|
||||
fullName = prefix + "." + fieldName
|
||||
}
|
||||
fType := registry.GetStrFromMap(f, "type")
|
||||
required, _ := f["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
optStr := formatOptions(f)
|
||||
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
|
||||
desc := registry.GetStrFromMap(f, "description")
|
||||
if desc != "" {
|
||||
desc = util.TruncateStrWithEllipsis(desc, 100)
|
||||
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
|
||||
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(f); rangeStr != "" {
|
||||
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
|
||||
printNestedFields(w, props, indent+" ", fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
|
||||
func formatOptions(f map[string]interface{}) string {
|
||||
opts, ok := f["options"].([]interface{})
|
||||
if !ok || len(opts) == 0 {
|
||||
return ""
|
||||
}
|
||||
var vals []string
|
||||
for _, o := range opts {
|
||||
if om, ok := o.(map[string]interface{}); ok {
|
||||
if v := registry.GetStrFromMap(om, "value"); v != "" {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
|
||||
}
|
||||
|
||||
// formatRange returns "min..max" if field has min/max, else "".
|
||||
func formatRange(f map[string]interface{}) string {
|
||||
minVal := registry.GetStrFromMap(f, "min")
|
||||
maxVal := registry.GetStrFromMap(f, "max")
|
||||
if minVal == "" && maxVal == "" {
|
||||
return ""
|
||||
}
|
||||
if minVal != "" && maxVal != "" {
|
||||
return minVal + ".." + maxVal
|
||||
}
|
||||
if minVal != "" {
|
||||
return ">=" + minVal
|
||||
}
|
||||
return "<=" + maxVal
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in alphabetical order.
|
||||
func sortedKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
|
||||
func sortedParamKeys(params map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
pi, _ := params[keys[i]].(map[string]interface{})
|
||||
pj, _ := params[keys[j]].(map[string]interface{})
|
||||
ri, _ := pi["required"].(bool)
|
||||
rj, _ := pj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
|
||||
func sortedFieldKeys(fields map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
fi, _ := fields[keys[i]].(map[string]interface{})
|
||||
fj, _ := fields[keys[j]].(map[string]interface{})
|
||||
ri, _ := fi["required"].(bool)
|
||||
rj, _ := fj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
|
||||
for i := len(parts); i >= 1; i-- {
|
||||
candidateName := strings.Join(parts[:i], ".")
|
||||
if res, ok := resources[candidateName]; ok {
|
||||
if resMap, ok := res.(map[string]interface{}); ok {
|
||||
return resMap, candidateName, parts[i:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
|
||||
@@ -39,7 +365,12 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Args = append([]string(nil), args...)
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -49,89 +380,433 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// Tolerated for agent compatibility; ignored — schema only emits the JSON
|
||||
// envelope, and its output is identity-independent (strict-mode filtering
|
||||
// comes from ResolveStrictMode, never from --as).
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().Bool("json", true, "")
|
||||
cmd.Flags().String("as", "", "")
|
||||
_ = cmd.Flags().MarkHidden("format")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("as")
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
return completions, directive
|
||||
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
var completions []string
|
||||
|
||||
for resName, resVal := range resources {
|
||||
if strings.HasPrefix(resName, afterService) {
|
||||
completions = append(completions, serviceName+"."+resName+".")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(afterService, resName+".") {
|
||||
continue
|
||||
}
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
return completions
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
}
|
||||
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
|
||||
if target.Kind == apicatalog.TargetMethod {
|
||||
if len(refs) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"Method %s not available in current identity mode", target.Method.SchemaPath()).
|
||||
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
}
|
||||
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
}
|
||||
output.PrintJson(out, schema.Envelopes(refs))
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
|
||||
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
|
||||
// preserving the historical message + hint text.
|
||||
func resolveError(err error) error {
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
return err
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
}
|
||||
switch re.Kind {
|
||||
case apicatalog.ErrService:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrResource:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrMethod:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrPath:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
|
||||
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
|
||||
}
|
||||
return err
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -20,46 +21,29 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"calendar.events.list"})
|
||||
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
|
||||
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
|
||||
if gotOpts.Path != "calendar.events.list" {
|
||||
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
|
||||
}
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
|
||||
// Agents are habituated to --format/--json/--as from api/service commands.
|
||||
// schema must accept them without erroring and always emit the JSON envelope —
|
||||
// its output is structured JSON and identity-independent, so the values have
|
||||
// no effect.
|
||||
argSets := [][]string{
|
||||
{"--format", "json"},
|
||||
{"--format", "pretty"},
|
||||
{"--format", "table"}, // no table rendering for a nested schema -> JSON
|
||||
{"--format", "csv"},
|
||||
{"--json"},
|
||||
{"--json", "--format", "ndjson"},
|
||||
{"--as", "user"},
|
||||
{"--as", "bot"},
|
||||
{"--as", "user", "--json"},
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, extra := range argSets {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list in pretty mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +51,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{})
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -92,7 +76,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create"})
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -195,6 +179,23 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -211,6 +212,168 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Completion candidate generation (dotted + space forms, strict-mode filtering,
|
||||
// dotted-resource handling) now lives in internal/apicatalog and is covered by
|
||||
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// methodLong composes a method command's long help in one place: the
|
||||
// description, the affordance guidance block (when the method has one), the
|
||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||
// the flag list.
|
||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||
// the method carries no affordance. It reads the single typed model
|
||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
if strings.TrimSpace(it) != "" {
|
||||
nonEmpty = append(nonEmpty, it)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
}
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
if ex.Command == "" {
|
||||
continue
|
||||
}
|
||||
if ex.Description != "" {
|
||||
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
{"description":"no command, skipped","command":""}
|
||||
],
|
||||
"related": ["im.messages.list"]
|
||||
}`)
|
||||
out := renderAffordance(meta.Method{Affordance: raw})
|
||||
for _, want := range []string{
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "no command, skipped") {
|
||||
t.Errorf("example with empty command should be skipped:\n%s", out)
|
||||
}
|
||||
|
||||
// Absent or empty affordance renders nothing (so methods without an overlay
|
||||
// add nothing to their help).
|
||||
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
|
||||
t.Error("empty affordance should render nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// A method with no affordance adds no guidance block.
|
||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||
if strings.Contains(cmd2.Long, "Examples:") {
|
||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Flag annotations the grouped service-method help renderer reads.
|
||||
const (
|
||||
flagGroupAnnotation = "lark_flag_group" // display group key
|
||||
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
|
||||
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
|
||||
|
||||
groupParams = "params" // typed path/query flags
|
||||
groupBody = "body" // --data, --file
|
||||
groupRaw = "raw" // --params
|
||||
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
|
||||
groupOutput = "output" // --output/--format/--jq
|
||||
|
||||
subRequired = "required"
|
||||
subOptional = "optional"
|
||||
)
|
||||
|
||||
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
|
||||
// Parameters carries only typed path/query flags; raw --params, request body and
|
||||
// execution/output controls each get their own group so an agent can tell the
|
||||
// distinct input kinds apart.
|
||||
var serviceFlagGroupOrder = []struct{ key, title string }{
|
||||
{groupParams, "API Parameters"},
|
||||
{groupBody, "Request Body"},
|
||||
{groupRaw, "Raw Parameter Input"},
|
||||
{groupExecution, "Execution"},
|
||||
{groupOutput, "Output"},
|
||||
}
|
||||
|
||||
// applyGroupedUsage installs the grouped usage renderer on a service method
|
||||
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
|
||||
// list; global (inherited) flags and the Risk/Tips sections appended by the
|
||||
// root help func are unaffected. Rendered by hand rather than via
|
||||
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
|
||||
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
|
||||
// that disables the linker's method-level dead-code elimination and costs
|
||||
// ~19 MB of binary size.
|
||||
func applyGroupedUsage(cmd *cobra.Command) {
|
||||
cmd.SetUsageFunc(func(c *cobra.Command) error {
|
||||
w := c.OutOrStderr()
|
||||
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
|
||||
if c.HasAvailableLocalFlags() {
|
||||
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
|
||||
}
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func annotate(f *pflag.Flag, key string, vals []string) {
|
||||
if f.Annotations == nil {
|
||||
f.Annotations = map[string][]string{}
|
||||
}
|
||||
f.Annotations[key] = vals
|
||||
}
|
||||
|
||||
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
|
||||
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
|
||||
if f := fs.Lookup(name); f != nil {
|
||||
annotate(f, flagGroupAnnotation, []string{group})
|
||||
}
|
||||
}
|
||||
|
||||
func annotationOf(f *pflag.Flag, key string) []string {
|
||||
if f.Annotations != nil {
|
||||
return f.Annotations[key]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagGroupOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func flagSubOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderServiceFlagGroups renders the command's local flags into ordered,
|
||||
// titled groups; the API Parameters group is further split into Required /
|
||||
// Optional. It is the body of the usage func applyGroupedUsage installs.
|
||||
func renderServiceFlagGroups(cmd *cobra.Command) string {
|
||||
var b strings.Builder
|
||||
seen := map[*pflag.Flag]bool{}
|
||||
for _, g := range serviceFlagGroupOrder {
|
||||
flags := groupFlags(cmd, g.key, seen)
|
||||
if len(flags) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", g.title)
|
||||
if g.key == groupParams {
|
||||
writeSection(&b, " Required:", subFlags(flags, subRequired))
|
||||
writeSection(&b, " Optional:", subFlags(flags, subOptional))
|
||||
} else {
|
||||
writeSection(&b, "", flags)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
// Anything untagged (e.g. -h/--help) goes last under "Other".
|
||||
var other []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || seen[f] {
|
||||
return
|
||||
}
|
||||
other = append(other, f)
|
||||
})
|
||||
if len(other) > 0 {
|
||||
fmt.Fprintln(&b, "Other:")
|
||||
writeSection(&b, "", other)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// groupFlags returns the visible local flags tagged with group key, marking them
|
||||
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
|
||||
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
|
||||
var flags []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || flagGroupOf(f) != key {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
seen[f] = true
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
|
||||
var out []*pflag.Flag
|
||||
for _, f := range flags {
|
||||
s := flagSubOf(f)
|
||||
// Untagged subgroup defaults to Optional so nothing is dropped.
|
||||
if s == sub || (s == "" && sub == subOptional) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSection prints an optional (sub)header and the flags, aligned in a
|
||||
// column, each flag row followed by its note lines indented under the usage.
|
||||
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
if header != "" {
|
||||
fmt.Fprintf(b, "%s\n", header)
|
||||
}
|
||||
specs := make([]string, len(flags))
|
||||
maxSpec := 0
|
||||
for i, f := range flags {
|
||||
specs[i] = flagSpec(f)
|
||||
if len(specs[i]) > maxSpec {
|
||||
maxSpec = len(specs[i])
|
||||
}
|
||||
}
|
||||
for i, f := range flags {
|
||||
_, usage := pflag.UnquoteUsage(f)
|
||||
if showsDefault(f) {
|
||||
usage += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
|
||||
for _, note := range annotationOf(f, flagNoteAnnotation) {
|
||||
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
|
||||
func flagSpec(f *pflag.Flag) string {
|
||||
typeName, _ := pflag.UnquoteUsage(f)
|
||||
spec := " --" + f.Name
|
||||
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
|
||||
spec = " -" + f.Shorthand + ", --" + f.Name
|
||||
}
|
||||
if typeName != "" {
|
||||
spec += " " + typeName
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
|
||||
// commands use, so the grouped rendering shows the same "(default x)" hints as
|
||||
// cobra's flat list.
|
||||
func showsDefault(f *pflag.Flag) bool {
|
||||
switch f.DefValue {
|
||||
case "", "0", "false", "[]":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "chats/:chat_id/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
|
||||
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Documented body field -> --data belongs under Request Body.
|
||||
"requestBody": map[string]interface{}{
|
||||
"id_list": map[string]interface{}{"type": "list", "required": true},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
idx := func(s string) int { return strings.Index(out, s) }
|
||||
|
||||
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
|
||||
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
|
||||
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
|
||||
if i < 0 {
|
||||
t.Fatalf("missing section %q in:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
|
||||
t.Errorf("section order wrong:\n%s", out)
|
||||
}
|
||||
|
||||
// Required/Optional subsections under API Parameters.
|
||||
if i := idx(" Required:"); i < iParams || i > iBody {
|
||||
t.Errorf("Required subsection misplaced:\n%s", out)
|
||||
}
|
||||
if i := idx(" Optional:"); i < iParams || i > iBody {
|
||||
t.Errorf("Optional subsection misplaced:\n%s", out)
|
||||
}
|
||||
|
||||
// Typed flags are API Parameters; required path flag under Required, enum
|
||||
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
}
|
||||
|
||||
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
|
||||
// and carries the precedence rule.
|
||||
if i := idx("--data"); i < iBody || i > iRaw {
|
||||
t.Errorf("--data not under Request Body:\n%s", out)
|
||||
}
|
||||
if i := idx("--params"); i < iRaw || i > iExec {
|
||||
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "typed flags override matching keys in --params") {
|
||||
t.Errorf("missing --params precedence rule:\n%s", out)
|
||||
}
|
||||
|
||||
// Control flags land in Execution/Output.
|
||||
if i := idx("--dry-run"); i < iExec || i > iOut {
|
||||
t.Errorf("--dry-run not under Execution:\n%s", out)
|
||||
}
|
||||
if idx("--format") < iOut {
|
||||
t.Errorf("--format not under Output:\n%s", out)
|
||||
}
|
||||
|
||||
// The usage template is wired to the grouped renderer (no flat Flags: list).
|
||||
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
|
||||
t.Errorf("usage template not grouped:\n%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
|
||||
// fields still offers --data (escape hatch) but must NOT imply a declared body —
|
||||
// it goes under Raw Parameter Input, not "Request Body".
|
||||
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
|
||||
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
if strings.Contains(out, "Request Body:") {
|
||||
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
|
||||
}
|
||||
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
|
||||
if iRaw < 0 || iData < iRaw {
|
||||
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "no documented fields") {
|
||||
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type boundParamFlag struct {
|
||||
field meta.Field
|
||||
read func() interface{}
|
||||
}
|
||||
|
||||
// paramsOnlyField is a path/query parameter that got no typed flag because its
|
||||
// kebab name is already taken by another flag (a standard flag like --format, or
|
||||
// a root persistent flag). It stays reachable via --params; the binder keeps it,
|
||||
// with the flag that claimed the name, so --help can show the exact --params form
|
||||
// and steer the reader off the wrong flag.
|
||||
type paramsOnlyField struct {
|
||||
field meta.Field
|
||||
claimed *pflag.Flag
|
||||
}
|
||||
|
||||
// paramFlagBinder owns one service method's generated typed param flags: it
|
||||
// registers them (kind, help, enum completion, reserved-name skip) and applies
|
||||
// the --params overlay, where a changed typed flag overrides its key in the
|
||||
// --params JSON. Holding the field<->flag binding here keeps the request builder
|
||||
// from re-deriving which flags map to which param keys.
|
||||
type paramFlagBinder struct {
|
||||
bound []boundParamFlag
|
||||
paramsOnly []paramsOnlyField
|
||||
}
|
||||
|
||||
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
|
||||
// cmd and returns a binder for the --params overlay. A name already taken by
|
||||
// another flag is skipped — pflag panics on a local duplicate and a generated
|
||||
// flag would silently shadow a persistent one — and recorded as paramsOnly so
|
||||
// the parameter stays reachable (and discoverable) via --params. The taken set
|
||||
// is derived, not hand-listed: local flags (the standard set, registered before
|
||||
// this runs) via cmd, the lazily-added --help materialized here, and the root's
|
||||
// persistent flags via reserved (nil for direct callers that have no root).
|
||||
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
|
||||
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
|
||||
b := ¶mFlagBinder{}
|
||||
for _, f := range params {
|
||||
name := f.FlagName()
|
||||
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
|
||||
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
|
||||
continue
|
||||
}
|
||||
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
|
||||
if values := enumStrings(f.EnumValues()); len(values) > 0 {
|
||||
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return values, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
// Group as an API parameter and mark required/optional for the
|
||||
// Required/Optional subsections of the grouped --help renderer.
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupParams})
|
||||
sub := subOptional
|
||||
if f.Required {
|
||||
sub = subRequired
|
||||
}
|
||||
annotate(fl, flagSubAnnotation, []string{sub})
|
||||
}
|
||||
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// flagClaiming returns the flag already occupying name (so a typed param flag
|
||||
// would collide), or nil when the name is free. It checks the command's own
|
||||
// flags (the standard set + the materialized --help) and the root's persistent
|
||||
// flags — so the reserved set is whatever is actually registered, never a
|
||||
// hand-kept list that drifts when a global flag is added.
|
||||
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
return fl
|
||||
}
|
||||
if reserved != nil {
|
||||
return reserved.Lookup(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
|
||||
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
|
||||
// the same fieldFacts a typed flag would show on its usage line, and what the
|
||||
// colliding flag actually does — so neither a human nor an agent sets the
|
||||
// wrong one (e.g. --format, which is the output format, not the API parameter).
|
||||
func (b *paramFlagBinder) paramsOnlyHelp() string {
|
||||
if len(b.paramsOnly) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
|
||||
for _, p := range b.paramsOnly {
|
||||
name := p.field.Name
|
||||
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
|
||||
for _, fact := range fieldFacts(p.field) {
|
||||
fmt.Fprintf(&sb, " %s\n", fact)
|
||||
}
|
||||
if p.claimed != nil {
|
||||
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// hasTypedFlag reports whether the binder registered a typed flag for the
|
||||
// param named name. False for params-only fields — a flag with the same kebab
|
||||
// name may exist (that's the collision), but it is not this param's input.
|
||||
// Nil-safe for direct buildServiceRequest callers that have no binder.
|
||||
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if pf.field.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// overlay lets an explicit typed flag override the same key in --params
|
||||
// (--params is the base). Only changed flags apply, so the --params-only path is
|
||||
// unchanged. A nil binder or cmd is a no-op.
|
||||
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
|
||||
if b == nil || cmd == nil {
|
||||
return
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if cmd.Flags().Changed(pf.field.FlagName()) {
|
||||
params[pf.field.Name] = pf.read()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
|
||||
// and returns a reader for its parsed value; the kind→pflag-type switch lives
|
||||
// only here.
|
||||
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
|
||||
switch kind {
|
||||
case "integer":
|
||||
return flagReader(fs.Int(name, 0, usage))
|
||||
case "boolean":
|
||||
return flagReader(fs.Bool(name, false, usage))
|
||||
case "array":
|
||||
return flagReader(fs.StringArray(name, nil, usage))
|
||||
default:
|
||||
return flagReader(fs.String(name, "", usage))
|
||||
}
|
||||
}
|
||||
|
||||
func flagReader[T any](p *T) func() interface{} {
|
||||
return func() interface{} { return *p }
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
|
||||
// optional enum query param — the canonical case from the screenshot feedback.
|
||||
func imChatMembersCreate() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "chats/{chat_id}/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query", "required": false,
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id"},
|
||||
map[string]interface{}{"value": "user_id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
|
||||
if cmd.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag for path param chat_id")
|
||||
}
|
||||
if cmd.Flags().Lookup("member-id-type") == nil {
|
||||
t.Error("expected generated --member-id-type flag for query param member_id_type")
|
||||
}
|
||||
}
|
||||
|
||||
// A query param literally named "format" kebab-collides with the global
|
||||
// --format flag. Generation must skip it (never re-register, never panic) and
|
||||
// leave the standard --format flag intact.
|
||||
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
}
|
||||
|
||||
var cmd *cobra.Command
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
|
||||
}
|
||||
}()
|
||||
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
}()
|
||||
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
|
||||
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --params is the base; an explicit typed flag overrides the same key.
|
||||
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "chats/oc_flag/members") {
|
||||
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "oc_params") {
|
||||
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Override works for a non-string (integer) param too, exercising the int
|
||||
// register/read path end to end.
|
||||
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
|
||||
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: with no typed flags passed, behavior is byte-identical to today.
|
||||
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
|
||||
// flag overlaying onto it must not panic (assignment to a nil map) — null is
|
||||
// treated as "no base params", with the flag value applied on top.
|
||||
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Startup smoke test: registering every embedded method must not panic on a
|
||||
// generated-flag name collision (pflag panics on duplicate registration, which
|
||||
// would crash the whole CLI at startup), and a known path param must surface as
|
||||
// a typed flag end to end.
|
||||
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("registering all service commands panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
RegisterServiceCommands(root, f)
|
||||
|
||||
create, _, err := root.Find([]string{"im", "chat.members", "create"})
|
||||
if err != nil {
|
||||
t.Fatalf("im chat.members create not registered: %v", err)
|
||||
}
|
||||
if create.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag on im chat.members create")
|
||||
}
|
||||
}
|
||||
|
||||
// Locks the boolean and array branches of bindParamFlag end to end (string and
|
||||
// integer are covered above): a bool flag yields true and a repeatable array
|
||||
// flag yields all its elements in the request.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override (--params base, typed flag wins) is covered for string and integer
|
||||
// above; this locks the same semantics for the boolean and array kinds.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
|
||||
"--with-deleted", "--ids", "a", "--ids", "b",
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "from_params") {
|
||||
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A param whose kebab name collides with a global flag (here "format" vs the
|
||||
// global --format) gets no typed flag, but the collision is no longer silent:
|
||||
// non-colliding params still get flags, the global --format is untouched, and
|
||||
// --help shows the exact --params form and steers the reader off --format.
|
||||
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "things/{thing_id}",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
|
||||
map[string]interface{}{"value": "full"},
|
||||
map[string]interface{}{"value": "metadata"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
|
||||
|
||||
if cmd.Flags().Lookup("thing-id") == nil {
|
||||
t.Error("non-colliding param should still get a typed --thing-id flag")
|
||||
}
|
||||
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
|
||||
}
|
||||
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The collision guard derives reserved names from the actual flag sets — local
|
||||
// flags plus the root's persistent flags passed in — so a future persistent
|
||||
// flag is covered with no hand-maintained list. Here a param named "profile"
|
||||
// (a root persistent flag) is skipped while a normal param is bound.
|
||||
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
|
||||
reserved.String("profile", "", "use a specific profile")
|
||||
|
||||
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"profile": map[string]interface{}{"type": "string", "location": "query"},
|
||||
"id": map[string]interface{}{"type": "string", "location": "path"},
|
||||
}})
|
||||
b := newParamFlagBinder(cmd, m.Params(), reserved)
|
||||
|
||||
if cmd.Flags().Lookup("id") == nil {
|
||||
t.Error("non-colliding param should get a typed flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("profile") != nil {
|
||||
t.Error("param colliding with a reserved persistent flag must not be registered")
|
||||
}
|
||||
found := false
|
||||
for _, p := range b.paramsOnly {
|
||||
if p.field.Name == "profile" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("colliding param should be recorded for the --params help note")
|
||||
}
|
||||
}
|
||||
|
||||
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
|
||||
// boolean and one integer query param, where false and 0 are meaningful values.
|
||||
func boolIntQueryMethod(required bool) meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
|
||||
// so --flag=false / --flag 0 are real values and must be sent — not silently
|
||||
// dropped as "empty", which would let the API default win over an explicit
|
||||
// user choice.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly provided false satisfies a required query parameter — the
|
||||
// pre-flight must not report "missing" for a value the user just set.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
|
||||
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The same presence-is-intent rule applies to the --params JSON base: a key
|
||||
// deliberately written as false/0 is sent. (Zero values used to be silently
|
||||
// dropped; this locks the corrected semantics as the contract.)
|
||||
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "" stays unusable: a required parameter fed an empty-string placeholder is
|
||||
// still caught by the friendly pre-flight error, not sent as an empty value.
|
||||
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
|
||||
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared optional query param fed "" is dropped (unusable value), not sent
|
||||
// as an empty query value — the declared-param loop owns the decision and the
|
||||
// undeclared passthrough must not resurrect it. Undeclared keys stay the
|
||||
// verbatim raw escape hatch.
|
||||
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "user_id_type") {
|
||||
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"custom_key": "v1"`) {
|
||||
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// min/max from the metadata surface on the typed flag's help line, in the same
|
||||
// vocabulary as the envelope's minimum/maximum.
|
||||
func TestParamFlagUsage_Bounds(t *testing.T) {
|
||||
cases := []struct{ name, min, max, want string }{
|
||||
{"both", "1", "100", "min: 1, max: 100"},
|
||||
{"min only", "1", "", "min: 1"},
|
||||
{"max only", "", "64", "max: 64"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
|
||||
t.Errorf("usage = %q, want contains %q", usage, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("no bounds, no clause", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_token": map[string]interface{}{"type": "string", "location": "query"},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
|
||||
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The sanitized field description rides the help line — a bare name like
|
||||
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
|
||||
// at sentence ends (。): the later sentence often holds the key affordance.
|
||||
func TestParamFlagUsage_Description(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"user_mailbox_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
|
||||
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, "补充说明") {
|
||||
t.Errorf("text after the note separator must be cut, got %q", usage)
|
||||
}
|
||||
|
||||
t.Run("long description truncated", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": strings.Repeat("长", 80),
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, "...") {
|
||||
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, strings.Repeat("长", 61)) {
|
||||
t.Errorf("description should not exceed the cap, got %q", usage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query", "description": "返回格式。",
|
||||
},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
|
||||
t.Errorf("clause join must not double the punctuation, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pins the convergence contract: the params-only addendum renders the SAME
|
||||
// fieldFacts list the typed flag's usage line joins inline — a fact added to
|
||||
// fieldFacts reaches both surfaces, and neither can drift over what a param's
|
||||
// help says (the addendum once rendered values-only enums and silently lacked
|
||||
// the API default).
|
||||
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
|
||||
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"mode": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": "模式选择。",
|
||||
"default": "fast",
|
||||
"min": "1", "max": "8",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "fast", "description": "快速"},
|
||||
map[string]interface{}{"value": "full"},
|
||||
},
|
||||
},
|
||||
}}).Params()[0]
|
||||
|
||||
facts := fieldFacts(f)
|
||||
if len(facts) != 4 { // description, enum, bounds, API default
|
||||
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
|
||||
}
|
||||
usage := paramFlagUsage(f)
|
||||
help := (¶mFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
|
||||
for _, fact := range facts {
|
||||
if !strings.Contains(usage, fact) {
|
||||
t.Errorf("usage line missing fact %q: %q", fact, usage)
|
||||
}
|
||||
if !strings.Contains(help, fact) {
|
||||
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds reach the registered flag's help end to end.
|
||||
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
|
||||
},
|
||||
})
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
|
||||
fl := cmd.Flags().Lookup("page-size")
|
||||
if fl == nil {
|
||||
t.Fatal("expected generated --page-size flag")
|
||||
}
|
||||
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
|
||||
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// The missing-required hint must name both recovery paths — the typed flag and
|
||||
// the --params fallback — so a reader who only knows one input style can
|
||||
// proceed without a round-trip through schema.
|
||||
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint %q should contain %q", ve.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A params-only required field (kebab name claimed by the standard --format
|
||||
// flag) has no typed flag to offer: the hint must give only the --params form,
|
||||
// never steer the reader to the colliding flag.
|
||||
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
|
||||
t.Errorf("hint %q should carry the --params form", ve.Hint)
|
||||
}
|
||||
if strings.Contains(ve.Hint, "set --format") {
|
||||
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Help rendering for generated param flags. fieldFacts is the single list of
|
||||
// agent-relevant facts a param exposes; every help surface (the typed flag's
|
||||
// usage line, the params-only --params addendum) renders that one list, so the
|
||||
// surfaces cannot drift over which facts exist. Values come from the
|
||||
// meta.Field accessors, so nothing here depends on internal/schema.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// fieldFacts returns a param field's facts in display order, each as a compact
|
||||
// one-line clause: the sanitized description, the allowed enum values (with
|
||||
// meanings), the min/max constraint, and the API default. This is the ONE
|
||||
// place that decides what a param's help says — add a fact here (e.g. a future
|
||||
// deprecation marker) and every surface shows it. Unabridged prose and
|
||||
// per-option detail stay in `lark-cli schema`.
|
||||
func fieldFacts(f meta.Field) []string {
|
||||
var facts []string
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
if b := formatBoundsInline(f); b != "" {
|
||||
facts = append(facts, b)
|
||||
}
|
||||
if s := literalStr(f.CoercedDefault()); s != "" {
|
||||
facts = append(facts, "API default: "+s)
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line:
|
||||
//
|
||||
// <param_name>, required|optional[. <fact>]...
|
||||
//
|
||||
// It leads with the canonical underscore param name (the key this flag
|
||||
// overrides in --params) and required/optional, then joins the field's facts
|
||||
// inline.
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
// its first allowed enum value, else its example, else a placeholder.
|
||||
func paramExample(f meta.Field) string {
|
||||
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
|
||||
return fmt.Sprintf("%q", vals[0])
|
||||
}
|
||||
if s := literalStr(f.CoercedExample()); s != "" {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
return `"<value>"`
|
||||
}
|
||||
|
||||
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||
|
||||
// inlineClause compresses metadata prose into one help clause: markdown links
|
||||
// keep their text, the clause cuts at the first rune in stops, whitespace
|
||||
// collapses, trailing punctuation goes — sentence enders (the clause join adds
|
||||
// its own) and connectors a cut can strand, like a colon introducing a list the
|
||||
// newline cut dropped — and the result caps at max runes. The two policies
|
||||
// below differ only in where they cut and how much they keep.
|
||||
func inlineClause(s, stops string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = markdownLinkRe.ReplaceAllString(s, "$1")
|
||||
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
|
||||
// flag's usage string as the flag's metavar, so a description like wiki
|
||||
// space_id's "可替换为`my_library`" would render the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
if i := strings.IndexAny(s, stops); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.TrimRight(s, "。.::,,、")
|
||||
return util.TruncateStrWithEllipsis(s, max)
|
||||
}
|
||||
|
||||
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
|
||||
// keep only the first clause (cut at 。 too) and stay ultra-compact.
|
||||
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
|
||||
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
|
||||
// live in the envelope's enumDescriptions / `lark-cli schema`.
|
||||
func formatEnumInline(opts []meta.EnumOption) string {
|
||||
items := make([]string, len(opts))
|
||||
for i, o := range opts {
|
||||
if d := sanitizeOptionDesc(o.Description); d != "" {
|
||||
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
|
||||
} else {
|
||||
items[i] = fmt.Sprintf("%v", o.Value)
|
||||
}
|
||||
}
|
||||
return strings.Join(items, "|")
|
||||
}
|
||||
|
||||
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
|
||||
// 100", or the single declared side), or "" when the field declares neither.
|
||||
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
|
||||
// schema` state the same constraint.
|
||||
func formatBoundsInline(f meta.Field) string {
|
||||
min, max := f.MinBound(), f.MaxBound()
|
||||
switch {
|
||||
case min != nil && max != nil:
|
||||
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
|
||||
case min != nil:
|
||||
return "min: " + formatBound(*min)
|
||||
case max != nil:
|
||||
return "max: " + formatBound(*max)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatBound renders a bound without a float artifact (100 not 100.000000).
|
||||
func formatBound(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// literalStr renders a coerced literal (default/example) for flag help,
|
||||
// returning "" for a nil or empty value so the caller can omit the clause.
|
||||
func literalStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
func enumStrings(enum []interface{}) []string {
|
||||
out := make([]string, 0, len(enum))
|
||||
for _, e := range enum {
|
||||
out = append(out, fmt.Sprintf("%v", e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeOptionDesc(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"以 open_id 标识用户": "以 open_id 标识用户",
|
||||
"中文。English second clause": "中文", // first clause only (。)
|
||||
"head;tail": "head", // first clause (;)
|
||||
"line one\nline two": "line one", // first clause (newline)
|
||||
" spaced out ": "spaced out", // whitespace collapsed
|
||||
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeOptionDesc(in); got != want {
|
||||
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
|
||||
// rune-safe (no split mid-character).
|
||||
long := strings.Repeat("文", 60)
|
||||
got := sanitizeOptionDesc(long)
|
||||
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
|
||||
// A clause cut can strand a connector (e.g. a colon introducing a list the
|
||||
// newline cut drops, as in im.reactions.list's message_id); the help line
|
||||
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
|
||||
cases := map[string]string{
|
||||
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
|
||||
"see the list below:\nitem": "see the list below",
|
||||
"逗号结尾,\n下一行": "逗号结尾",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
|
||||
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
|
||||
// the flag's metavar: wiki space_id's description rendered the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
|
||||
want := "知识空间id,如果查询我的文档库可替换为my_library"
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,18 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
@@ -32,74 +30,85 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
// Drive the service list from the same navigation catalog the method walk
|
||||
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
|
||||
// merged metadata — so registration is catalog-sourced end to end. Kept as a
|
||||
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
|
||||
// service with no methods still gets its bare command (WalkMethods yields one
|
||||
// ref per method, so empty services would vanish).
|
||||
for _, svc := range registry.RuntimeCatalog().Services() {
|
||||
if svc.Name == "" || svc.ServicePath == "" {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, svc, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
|
||||
|
||||
// Build the service's subtree from the catalog's method walk
|
||||
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
|
||||
// is sourced from the same navigation Module as schema/scope rather than a
|
||||
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
resCmd := svcCmd
|
||||
for _, seg := range ref.ResourcePath {
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
|
||||
return d
|
||||
}
|
||||
return svc.Description
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
}
|
||||
|
||||
// ensureChildCommand returns the child of parent named name, creating it (with
|
||||
// short) when absent — so re-registration merges into an existing command tree
|
||||
// instead of duplicating a level.
|
||||
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == name {
|
||||
return c
|
||||
if c.Name() == specName {
|
||||
svc = c
|
||||
break
|
||||
}
|
||||
}
|
||||
cmd := &cobra.Command{Use: name, Short: short}
|
||||
parent.AddCommand(cmd)
|
||||
return cmd
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
|
||||
type ServiceMethodOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
ServicePath string
|
||||
Method meta.Method
|
||||
SchemaPath string
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
Spec map[string]interface{}
|
||||
Method map[string]interface{}
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
@@ -114,113 +123,41 @@ type ServiceMethodOptions struct {
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
|
||||
// binder owns the generated typed param flags — registration and the
|
||||
// --params overlay — replacing the raw paramFlags side-channel.
|
||||
binder *paramFlagBinder
|
||||
}
|
||||
|
||||
// detectFileFields returns the request-body file-upload field names.
|
||||
func detectFileFields(m meta.Method) []string {
|
||||
files := m.Files()
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
}
|
||||
|
||||
// NewCmdServiceMethodWithContext builds the command for one service method from
|
||||
// its (service, resource, method) coordinates, deriving the methodCommandSpec
|
||||
// via an apicatalog.MethodRef so direct callers and the catalog-driven
|
||||
// registration assemble the command identically.
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
m.Name = name
|
||||
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
|
||||
// No root in scope here; persistent-flag collisions don't apply to a
|
||||
// standalone command, and local/standard-flag collisions are still caught.
|
||||
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
|
||||
}
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
|
||||
// methodCommandSpec is the static description of one generated service method
|
||||
// command, read off an apicatalog.MethodRef — the single place command
|
||||
// construction gets the method's facts (schema path, HTTP base path, risk,
|
||||
// identities, params, file fields, request-body support), so the cobra command
|
||||
// is assembled from a typed spec rather than recomputing paths/flags inline.
|
||||
type methodCommandSpec struct {
|
||||
method meta.Method
|
||||
schemaPath string // "service.resource.method", for the --help hint
|
||||
servicePath string // service HTTP base path
|
||||
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
|
||||
restricts bool // method declares accessTokens (identity-restricted)
|
||||
identities []string // permitted --as values; empty when unrestricted
|
||||
params []meta.Field // path/query params -> typed flags
|
||||
fileFields []string // request-body file-upload field names
|
||||
// acceptsBody is whether the HTTP method allows a request body at all (so
|
||||
// --data is offered as a raw escape hatch). declaresBody is whether the
|
||||
// metadata documents body fields (data or file). They differ for e.g. a POST
|
||||
// with no documented requestBody: --data still works, but help must not imply
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
m := ref.Method
|
||||
return methodCommandSpec{
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
params: m.Params(),
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
affordance: renderAffordance(m),
|
||||
}
|
||||
}
|
||||
|
||||
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
|
||||
// whether --data applies (as a raw escape hatch even when no body is declared).
|
||||
func methodTakesBody(httpMethod string) bool {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildMethodCommand assembles the cobra command for a service method from its
|
||||
// static spec: the standard flags, the conditional --data/--file/--yes flags,
|
||||
// the generated typed param flags (via paramFlagBinder), and the risk/identity
|
||||
// policy annotations.
|
||||
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
|
||||
m := spec.method
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
ServicePath: spec.servicePath,
|
||||
Method: m,
|
||||
SchemaPath: spec.schemaPath,
|
||||
FileFields: spec.fileFields,
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
}
|
||||
var asStr string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: m.Name,
|
||||
Short: m.Description,
|
||||
// Long is assembled below, once the binder knows which params got no
|
||||
// typed flag.
|
||||
Use: name,
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
@@ -232,15 +169,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
|
||||
if spec.acceptsBody {
|
||||
dataUsage := "JSON request body. Supports - and @file."
|
||||
if !spec.declaresBody {
|
||||
// POST/etc. with no documented body fields: --data is a raw escape
|
||||
// hatch, not a declared body — say so rather than imply structure.
|
||||
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -251,61 +183,27 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
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")
|
||||
if spec.risk == cmdutil.RiskHighRiskWrite {
|
||||
if risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
// --file only for body methods that actually declare file-type fields.
|
||||
if len(spec.fileFields) > 0 && spec.acceptsBody {
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Single composition point for Long: description, affordance, schema
|
||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||
// taken, reachable via --params only).
|
||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
// registered above (e.g. --data/--file/--yes only exist for some methods).
|
||||
// --data sits under Request Body only when the metadata documents body
|
||||
// fields; otherwise it's a raw escape hatch, grouped with --params so help
|
||||
// doesn't imply a declared body the API doesn't have.
|
||||
if fl := cmd.Flags().Lookup("data"); fl != nil {
|
||||
if spec.declaresBody {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupBody})
|
||||
} else {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
}
|
||||
}
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
if len(spec.params) > 0 {
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupExecution)
|
||||
}
|
||||
for _, name := range []string{"output", "format", "jq"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupOutput)
|
||||
}
|
||||
applyGroupedUsage(cmd)
|
||||
|
||||
cmdutil.SetTips(cmd, m.Tips)
|
||||
cmdutil.SetRisk(cmd, spec.risk)
|
||||
if spec.restricts {
|
||||
cmdutil.SetSupportedIdentities(cmd, spec.identities)
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
|
||||
return cmd
|
||||
@@ -320,8 +218,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if opts.Method.RestrictsIdentity() {
|
||||
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -337,10 +235,12 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Identity is not printed to stderr here: it is part of the JSON envelope.
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -357,7 +257,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
@@ -402,7 +302,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -411,15 +311,23 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
if len(method.RequiredScopes) > 0 {
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(method.Scopes) == 0 {
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -428,12 +336,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range method.Scopes {
|
||||
if grantedSet[s] {
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
||||
}
|
||||
|
||||
@@ -454,44 +362,14 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
|
||||
WithIdentity(identity)
|
||||
}
|
||||
|
||||
// unusableParamValue reports whether a provided path/query parameter value
|
||||
// cannot form a usable request value: nil or an empty string. A key's presence
|
||||
// in params is the intent signal — a typed flag is overlaid only when
|
||||
// explicitly Changed, and a --params JSON key is deliberately written — so
|
||||
// false and 0 are real values and must not be conflated with "unset"
|
||||
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
|
||||
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
|
||||
// error when a required param is fed an empty placeholder, and never emits a
|
||||
// declared param as an empty path segment or query value. Undeclared keys are
|
||||
// not judged by this rule — they pass through verbatim as the raw escape hatch.
|
||||
func unusableParamValue(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return ok && s == ""
|
||||
}
|
||||
|
||||
// missingParamHint is the recovery hint for a missing required parameter. It
|
||||
// names both input paths — the typed flag when the binder registered one, and
|
||||
// the --params fallback — plus the schema pointer. A params-only field gets
|
||||
// only the --params form: a flag with its kebab name exists but belongs to
|
||||
// something else (e.g. the output --format), and the hint must not steer
|
||||
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
|
||||
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
|
||||
if opts.binder.hasTypedFlag(f.Name) {
|
||||
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
|
||||
}
|
||||
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
httpMethod := method.HTTPMethod
|
||||
schemaPath := opts.SchemaPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
@@ -509,55 +387,53 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
opts.binder.overlay(opts.Cmd, params)
|
||||
|
||||
url := opts.ServicePath + "/" + method.Path
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
|
||||
specs := method.Params()
|
||||
for _, s := range specs {
|
||||
if s.Location != "path" {
|
||||
parameters, _ := method["parameters"].(map[string]interface{})
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "path" {
|
||||
continue
|
||||
}
|
||||
val, ok := params[s.Name]
|
||||
if !ok || unusableParamValue(val) {
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
"missing required path parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, s.Name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, s.Name)
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{}
|
||||
for _, s := range specs {
|
||||
if s.Location != "query" {
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "query" {
|
||||
continue
|
||||
}
|
||||
value, exists := params[s.Name]
|
||||
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
|
||||
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
|
||||
value, exists := params[name]
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
"missing required query parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
}
|
||||
if exists && !unusableParamValue(value) {
|
||||
queryParams[s.Name] = value
|
||||
if exists && !util.IsEmptyValue(value) {
|
||||
queryParams[name] = value
|
||||
}
|
||||
// This loop owns declared query params: consume the key so the
|
||||
// passthrough below can't resurrect a value the gate dropped (an
|
||||
// unusable "" would otherwise be sent as an empty query value).
|
||||
delete(params, s.Name)
|
||||
}
|
||||
// Whatever remains is undeclared — the raw escape hatch for params the
|
||||
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
|
||||
for name, value := range params {
|
||||
queryParams[name] = value
|
||||
if _, ok := queryParams[name]; !ok {
|
||||
queryParams[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The test exercises --yes registration and the
|
||||
// gate behavior.
|
||||
func highRiskDeleteMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
@@ -24,11 +23,11 @@ func highRiskDeleteMethod() meta.Method {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
@@ -36,7 +35,7 @@ func writeMethodNoRisk() meta.Method {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -21,14 +20,14 @@ var testConfig = &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
|
||||
func driveSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
func driveSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "drive",
|
||||
"servicePath": "/open-apis/drive/v1",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
|
||||
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
|
||||
m := map[string]interface{}{
|
||||
"path": "files/{file_token}/copy",
|
||||
"httpMethod": httpMethod,
|
||||
@@ -42,7 +41,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
|
||||
},
|
||||
}
|
||||
}
|
||||
return meta.FromMap(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
@@ -50,23 +49,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
|
||||
func TestRegisterService(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
f := &cmdutil.Factory{}
|
||||
base := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "base",
|
||||
"description": "Base API",
|
||||
"servicePath": "/open-apis/base/v3",
|
||||
"resources": map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{
|
||||
"description": "List tables",
|
||||
"httpMethod": "GET",
|
||||
},
|
||||
}
|
||||
resources := map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{
|
||||
"description": "List tables",
|
||||
"httpMethod": "GET",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
registerService(parent, base, f)
|
||||
registerService(parent, spec, resources, f)
|
||||
|
||||
// service command exists
|
||||
svc, _, err := parent.Find([]string{"base"})
|
||||
@@ -91,18 +90,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
|
||||
parent.AddCommand(existing)
|
||||
|
||||
f := &cmdutil.Factory{}
|
||||
svc := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
|
||||
"resources": map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
||||
},
|
||||
}
|
||||
resources := map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
registerService(parent, svc, f)
|
||||
registerService(parent, spec, resources, f)
|
||||
|
||||
// Should reuse existing, not duplicate
|
||||
count := 0
|
||||
@@ -144,7 +143,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
|
||||
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("data") != nil {
|
||||
t.Error("GET method should not have --data flag")
|
||||
@@ -160,7 +159,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("data") == nil {
|
||||
t.Error("POST method should have --data flag")
|
||||
@@ -172,7 +171,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -269,15 +268,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"q": map[string]interface{}{"location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
|
||||
@@ -292,15 +291,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
|
||||
@@ -316,10 +315,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
|
||||
|
||||
@@ -334,10 +333,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
|
||||
|
||||
@@ -352,10 +351,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
|
||||
|
||||
@@ -370,10 +369,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
@@ -399,8 +398,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
|
||||
@@ -428,8 +427,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
|
||||
|
||||
@@ -451,8 +450,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
|
||||
|
||||
@@ -471,7 +470,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -493,7 +492,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -509,10 +508,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
@@ -543,8 +542,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
||||
|
||||
@@ -562,10 +561,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
@@ -580,10 +579,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
@@ -612,8 +611,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
@@ -631,8 +630,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
@@ -646,14 +645,14 @@ func imImageMethod() meta.Method {
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func imSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
@@ -685,7 +684,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
@@ -753,7 +752,7 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(meta.FromMap(tt.method))
|
||||
got := detectFileFields(tt.method)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
@@ -772,7 +771,7 @@ func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
|
||||
@@ -80,7 +80,6 @@ const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
||||
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
|
||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -25,13 +25,10 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() error {
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -25,13 +25,10 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() error {
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
|
||||
type VCRecordingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingEndedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingEndedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingEndedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingEndedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingEndedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingEndedOutput{
|
||||
Type: recordingEndedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
|
||||
var envelope recordingEndedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingEndedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
|
||||
type VCRecordingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingStartedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingStartedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingStartedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingStartedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingStartedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingStartedOutput{
|
||||
Type: recordingStartedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
|
||||
var envelope recordingStartedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingStartedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if !strings.Contains(def.Description, "recording_bean") {
|
||||
t.Errorf("Description should document recording_bean source, got %q", def.Description)
|
||||
}
|
||||
if !strings.Contains(def.Description, "connected to Feishu software") {
|
||||
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
|
||||
}
|
||||
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
|
||||
t.Errorf("Description should not mention future sources, got %q", def.Description)
|
||||
}
|
||||
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
|
||||
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
|
||||
}
|
||||
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
|
||||
switch tc.eventType {
|
||||
case eventTypeRecordingTranscriptGenerated:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
|
||||
case eventTypeRecordingEnded:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
|
||||
}
|
||||
if def.Schema.Custom.Type != wantSchemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingStarted(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingStarted {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_transcript_001",
|
||||
"event_type": "vc.recording.recording_transcript_generated_v1",
|
||||
"create_time": "1761782400100"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"transcript_items": [
|
||||
{
|
||||
"speaker": {
|
||||
"id": {
|
||||
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
|
||||
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
|
||||
"user_id": null
|
||||
},
|
||||
"user_type": 100,
|
||||
"user_role": 1,
|
||||
"user_name": "Alice"
|
||||
},
|
||||
"text": "hello world",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782399000",
|
||||
"end_time_ms": "1761782400000",
|
||||
"sentence_id": "987654321"
|
||||
},
|
||||
{
|
||||
"speaker": {
|
||||
"user_name": "Bob"
|
||||
},
|
||||
"text": "second sentence",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782401000",
|
||||
"end_time_ms": "1761782402000",
|
||||
"sentence_id": "987654322"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out VCRecordingTranscriptGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
|
||||
if out.Type != eventTypeRecordingTranscriptGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400100) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
if len(out.TranscriptItems) != 2 {
|
||||
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
|
||||
}
|
||||
item := out.TranscriptItems[0]
|
||||
if item.SpeakerName != "Alice" || item.Text != "hello world" {
|
||||
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
|
||||
}
|
||||
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
|
||||
}
|
||||
if item.SentenceID != "987654321" {
|
||||
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
|
||||
}
|
||||
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
|
||||
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
|
||||
}
|
||||
itemJSON, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal transcript item: %v", err)
|
||||
}
|
||||
var itemFields map[string]any
|
||||
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
|
||||
t.Fatalf("unmarshal transcript item JSON: %v", err)
|
||||
}
|
||||
wantItemFields := map[string]bool{
|
||||
"speaker_name": true,
|
||||
"text": true,
|
||||
"start_time": true,
|
||||
"end_time": true,
|
||||
"sentence_id": true,
|
||||
}
|
||||
for gotField := range itemFields {
|
||||
if !wantItemFields[gotField] {
|
||||
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for wantField := range wantItemFields {
|
||||
if _, ok := itemFields[wantField]; !ok {
|
||||
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for _, unexpected := range []string{
|
||||
`"seq_id"`,
|
||||
`"speaker"`,
|
||||
`"user_open_id"`,
|
||||
`"user_type"`,
|
||||
`"user_role"`,
|
||||
`"language"`,
|
||||
`"start_time_ms"`,
|
||||
`"end_time_ms"`,
|
||||
`"sequence_id"`,
|
||||
`"transcript_item"`,
|
||||
} {
|
||||
if strings.Contains(string(got), unexpected) {
|
||||
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
|
||||
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400200) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
|
||||
t.Fatalf("ended output should drop object metadata, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), `"timestamp"`) {
|
||||
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
|
||||
}
|
||||
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
|
||||
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
payload string
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeRecordingStarted,
|
||||
process: processVCRecordingStarted,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "transcript",
|
||||
eventType: eventTypeRecordingTranscriptGenerated,
|
||||
process: processVCRecordingTranscriptGenerated,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ended",
|
||||
eventType: eventTypeRecordingEnded,
|
||||
process: processVCRecordingEnded,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
|
||||
if got != nil {
|
||||
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
|
||||
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
|
||||
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
|
||||
t.Helper()
|
||||
got := runRecordingProcessRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func recordingTestEventTime(millis int64) string {
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
|
||||
type VCRecordingTranscriptItemOutput struct {
|
||||
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
|
||||
Text string `json:"text,omitempty" desc:"Transcript text"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
|
||||
}
|
||||
|
||||
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
|
||||
type VCRecordingTranscriptGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingTranscriptGeneratedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedItemIn struct {
|
||||
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
|
||||
Text string `json:"text"`
|
||||
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
|
||||
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
|
||||
SentenceID string `json:"sentence_id"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedSpeakerIn struct {
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedString string
|
||||
|
||||
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingTranscriptGeneratedOutput{
|
||||
Type: recordingTranscriptGeneratedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
|
||||
var envelope recordingTranscriptGeneratedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventTime(raw string) string {
|
||||
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, recordingTranscriptItem(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
|
||||
return VCRecordingTranscriptItemOutput{
|
||||
SpeakerName: recordingSpeakerName(item.Speaker),
|
||||
Text: item.Text,
|
||||
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
|
||||
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
|
||||
SentenceID: item.SentenceID,
|
||||
}
|
||||
}
|
||||
|
||||
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
|
||||
if speaker == nil {
|
||||
return ""
|
||||
}
|
||||
return speaker.UserName
|
||||
}
|
||||
|
||||
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = recordingTranscriptGeneratedString(str)
|
||||
return nil
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(data, &num); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = recordingTranscriptGeneratedString(num.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s recordingTranscriptGeneratedString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
@@ -11,18 +11,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
|
||||
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
|
||||
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
|
||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||
)
|
||||
@@ -62,53 +57,5 @@ func Keys() []event.KeyDefinition {
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingStarted,
|
||||
DisplayName: "Recording started",
|
||||
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingTranscriptGenerated,
|
||||
DisplayName: "Recording transcript generated",
|
||||
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingTranscriptGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingTranscriptGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingEnded,
|
||||
DisplayName: "Recording ended",
|
||||
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
@@ -44,13 +44,10 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() error {
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package apicatalog is the single navigation Module over the API metadata. It
|
||||
// owns every "which services/resources/methods exist and how does a path
|
||||
// resolve" question that was previously duplicated across cmd/schema,
|
||||
// cmd/service, internal/schema and internal/registry. It depends only on
|
||||
// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog),
|
||||
// so apicatalog never imports registry.
|
||||
package apicatalog
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// Source records whether a catalog includes the remote overlay. It is carried
|
||||
// so callers (and tests) can assert determinism instead of guessing.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic
|
||||
SourceRuntime Source = "runtime" // embedded + remote overlay
|
||||
)
|
||||
|
||||
// MethodFilter optionally drops methods (e.g. by identity in strict mode).
|
||||
// A nil filter includes everything.
|
||||
type MethodFilter func(meta.Method) bool
|
||||
|
||||
// Catalog is a navigation view over services with a name index. It owns its
|
||||
// ordering — New sorts by name — so WalkMethods/Resolve/Complete are
|
||||
// deterministic regardless of how the source adapter ordered its input.
|
||||
type Catalog struct {
|
||||
source Source
|
||||
services []meta.Service
|
||||
byName map[string]meta.Service
|
||||
}
|
||||
|
||||
// New builds a Catalog over the given services, owning its navigation order:
|
||||
// the slice is copied and sorted by name so callers may pass any order and the
|
||||
// ordering contract is not delegated to the adapter. The copy is shallow —
|
||||
// meta.Service values share their Resources maps, which are treated as
|
||||
// read-only.
|
||||
func New(source Source, services []meta.Service) Catalog {
|
||||
sorted := append([]meta.Service(nil), services...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
byName := make(map[string]meta.Service, len(sorted))
|
||||
for _, s := range sorted {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
return Catalog{source: source, services: sorted, byName: byName}
|
||||
}
|
||||
|
||||
// Source reports embedded vs runtime.
|
||||
func (c Catalog) Source() Source { return c.source }
|
||||
|
||||
// Services returns the services in name order. Treat the result as read-only:
|
||||
// it is the Catalog's own ordered slice and its element Resources maps are
|
||||
// shared.
|
||||
func (c Catalog) Services() []meta.Service { return c.services }
|
||||
|
||||
// Service looks up one service by name.
|
||||
func (c Catalog) Service(name string) (meta.Service, bool) {
|
||||
s, ok := c.byName[name]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Resolve maps a path (already split into segments) to a Target. An empty path
|
||||
// is TargetAll. Failures return a *ResolveError carrying the available
|
||||
// candidates so the command layer can render a hint.
|
||||
func (c Catalog) Resolve(parts []string) (Target, error) {
|
||||
if len(parts) == 0 {
|
||||
return Target{Kind: TargetAll}, nil
|
||||
}
|
||||
svc, ok := c.byName[parts[0]]
|
||||
if !ok {
|
||||
return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()}
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return Target{Kind: TargetService, Service: svc}, nil
|
||||
}
|
||||
res, path, remaining, ok := findResource(svc, parts[1:])
|
||||
if !ok {
|
||||
return Target{}, &ResolveError{
|
||||
Kind: ErrResource,
|
||||
Subject: svc.Name + "." + strings.Join(parts[1:], "."),
|
||||
Candidates: resourceNames(svc),
|
||||
}
|
||||
}
|
||||
resPath := strings.Join(path, ".")
|
||||
if len(remaining) == 0 {
|
||||
return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
m, ok := res.Method(methodName)
|
||||
if !ok {
|
||||
return Target{}, &ResolveError{
|
||||
Kind: ErrMethod,
|
||||
Subject: svc.Name + "." + resPath + "." + methodName,
|
||||
Candidates: methodNames(res),
|
||||
}
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but trailing segments don't resolve — reject so a typo
|
||||
// doesn't silently return this method's schema.
|
||||
return Target{}, &ResolveError{
|
||||
Kind: ErrPath,
|
||||
Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."),
|
||||
Method: methodName,
|
||||
Trailing: strings.Join(remaining[1:], "."),
|
||||
}
|
||||
}
|
||||
return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil
|
||||
}
|
||||
|
||||
// MethodRefs returns the method refs selected by a resolved Target, filtered:
|
||||
// TargetAll -> every method, TargetService / TargetResource -> that subtree,
|
||||
// TargetMethod -> the single method if it passes the filter (else empty). It
|
||||
// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a
|
||||
// Target to refs in one call instead of re-deciding the walker per Kind.
|
||||
func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef {
|
||||
switch target.Kind {
|
||||
case TargetService:
|
||||
return ServiceMethods(target.Service, filter)
|
||||
case TargetResource:
|
||||
return ResourceMethods(*target.Resource, filter)
|
||||
case TargetMethod:
|
||||
if filter != nil && !filter(target.Method.Method) {
|
||||
return nil
|
||||
}
|
||||
return []MethodRef{*target.Method}
|
||||
case TargetAll:
|
||||
return c.WalkMethods(filter)
|
||||
default:
|
||||
// Unknown / zero-value Kind: return nothing rather than silently
|
||||
// dumping every method (the safe direction for an invalid Target).
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WalkMethods returns one MethodRef per method across all services (optionally
|
||||
// filtered), recursing nested resources, in a deterministic order: services by
|
||||
// name, resources by name, methods by name.
|
||||
func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef {
|
||||
var out []MethodRef
|
||||
for _, svc := range c.services {
|
||||
out = append(out, ServiceMethods(svc, filter)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ServiceMethods returns the method refs of one service (filtered), recursing
|
||||
// nested resources, in deterministic resource/method name order.
|
||||
func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef {
|
||||
var out []MethodRef
|
||||
walkResources(svc, svc.ResourceList(), nil, filter, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ResourceMethods returns the method refs under one resource (filtered), using
|
||||
// the resource's resolved path as the base and recursing nested resources.
|
||||
func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef {
|
||||
var out []MethodRef
|
||||
for _, m := range r.Resource.MethodList() {
|
||||
if filter == nil || filter(m) {
|
||||
out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m})
|
||||
}
|
||||
}
|
||||
walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) {
|
||||
for _, res := range resources {
|
||||
path := append(append([]string(nil), parentPath...), res.Name)
|
||||
for _, m := range res.MethodList() {
|
||||
if filter == nil || filter(m) {
|
||||
*out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m})
|
||||
}
|
||||
}
|
||||
walkResources(svc, res.SubResources(), path, filter, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Complete returns shell-completion candidates for the schema path argument,
|
||||
// supporting both the legacy single dotted arg ("im.reac") and the
|
||||
// space-separated form ("im reactions"). noSpace mirrors cobra's
|
||||
// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for
|
||||
// the next segment). Filtering uses the caller's MethodFilter so strict-mode
|
||||
// unavailable methods are hidden.
|
||||
func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) {
|
||||
// Case 1: legacy single dotted arg — no resolved args yet.
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
for _, name := range c.serviceNames() {
|
||||
if strings.HasPrefix(name, toComplete) {
|
||||
completions = append(completions, name+".")
|
||||
}
|
||||
}
|
||||
return completions, true
|
||||
}
|
||||
svc, ok := c.byName[parts[0]]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, comp := range completions {
|
||||
if !strings.HasSuffix(comp, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, allTrailingDot
|
||||
}
|
||||
|
||||
// Case 2: space-separated form — args holds resolved segments.
|
||||
svc, ok := c.byName[args[0]]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
resource, _, _, ok := findResource(svc, args[1:])
|
||||
if !ok {
|
||||
// No resource matched yet — suggest top-level resources reachable in the
|
||||
// current identity mode.
|
||||
return completeChildren(svc.ResourceList(), nil, toComplete, filter), false
|
||||
}
|
||||
// Positioned in a resource — offer its methods and its sub-resources, so the
|
||||
// next segment can drill deeper, symmetric to findResource's descent.
|
||||
return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false
|
||||
}
|
||||
|
||||
// completeDotted suggests dotted completions for the text after the service
|
||||
// segment. It descends fully-typed "resource." segments (longest match per
|
||||
// level, so flat dotted keys like "chat.members" and genuinely nested resources
|
||||
// both resolve), then offers the reachable sub-resources (as "…name.") and the
|
||||
// methods (as "…name") of the level it lands in whose names extend the trailing
|
||||
// partial token. This descent is symmetric to findResource, so completion can
|
||||
// reach every method Resolve can.
|
||||
func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string {
|
||||
subs := svc.ResourceList()
|
||||
base := svc.Name
|
||||
rest := afterService
|
||||
var here *meta.Resource // resource we're positioned in; nil at the service root
|
||||
for {
|
||||
matched, n, ok := longestResourceFollowedByDot(subs, rest)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
base += "." + matched.Name
|
||||
rest = rest[n:]
|
||||
r := matched
|
||||
here = &r
|
||||
subs = matched.SubResources()
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, sub := range subs {
|
||||
if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) {
|
||||
out = append(out, base+"."+sub.Name+".")
|
||||
}
|
||||
}
|
||||
if here != nil {
|
||||
for _, m := range here.MethodList() {
|
||||
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) {
|
||||
out = append(out, base+"."+m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// completeChildren returns the sorted next-segment candidates at one level: the
|
||||
// (filtered) methods and the reachable sub-resources whose names extend prefix.
|
||||
// Methods are terminal; sub-resources are bare names the caller drills into on
|
||||
// the next segment.
|
||||
func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string {
|
||||
var out []string
|
||||
for _, m := range methods {
|
||||
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
}
|
||||
for _, sub := range subResources {
|
||||
if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) {
|
||||
out = append(out, sub.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// longestResourceFollowedByDot finds the longest resource in resources whose
|
||||
// name is a fully-typed segment of text (text begins with "name."), returning
|
||||
// it, the byte length consumed (incl. the dot), and whether one matched.
|
||||
func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) {
|
||||
best := meta.Resource{}
|
||||
bestLen := -1
|
||||
for _, r := range resources {
|
||||
if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") {
|
||||
best = r
|
||||
bestLen = len(r.Name)
|
||||
}
|
||||
}
|
||||
if bestLen < 0 {
|
||||
return meta.Resource{}, 0, false
|
||||
}
|
||||
return best, len(best.Name) + 1, true
|
||||
}
|
||||
|
||||
// findResource resolves a resource path against a service, descending nested
|
||||
// resources. At each level it consumes the longest leading run of parts that
|
||||
// names a resource at that level, so both flat dotted keys ("chat.members")
|
||||
// and genuinely nested resources ("spaces" > "items") resolve. This descent is
|
||||
// symmetric to walkResources, which guarantees every path WalkMethods emits
|
||||
// resolves back (the round-trip contract). Returns the deepest matched resource
|
||||
// (Name injected), its path segments, the unconsumed remainder, and whether
|
||||
// anything matched.
|
||||
//
|
||||
// Descent is greedy and resource-first: the one ambiguous case is a resource
|
||||
// that has BOTH a method and a sub-resource of the same name — the sub-resource
|
||||
// wins and shadows the method, so Resolve can never reach that method. Real
|
||||
// metadata never collides the two, so this is theoretical.
|
||||
func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) {
|
||||
level := svc.Resources
|
||||
remaining = parts
|
||||
for len(remaining) > 0 {
|
||||
matched, name, n := longestResourcePrefix(level, remaining)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
matched.Name = name
|
||||
res = matched
|
||||
path = append(path, name)
|
||||
remaining = remaining[n:]
|
||||
level = matched.Resources
|
||||
ok = true
|
||||
}
|
||||
return res, path, remaining, ok
|
||||
}
|
||||
|
||||
// longestResourcePrefix finds the longest leading run of segs (joined by ".")
|
||||
// that names a resource in level, returning the resource, its dotted name, and
|
||||
// the number of segments consumed (0 if none match). Longest-first lets a flat
|
||||
// dotted key win over its single leading segment when present.
|
||||
func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) {
|
||||
for i := len(segs); i >= 1; i-- {
|
||||
name := strings.Join(segs[:i], ".")
|
||||
if r, ok := level[name]; ok {
|
||||
return r, name, i
|
||||
}
|
||||
}
|
||||
return meta.Resource{}, "", 0
|
||||
}
|
||||
|
||||
// resourceReachable reports whether a resource exposes a method reachable under
|
||||
// the filter — directly or in any nested sub-resource (a nil filter accepts any
|
||||
// method). A resource whose methods are all filtered out but which contains a
|
||||
// reachable nested method is still offerable, so completion can drill into it.
|
||||
func resourceReachable(res meta.Resource, filter MethodFilter) bool {
|
||||
for _, m := range res.MethodList() {
|
||||
if filter == nil || filter(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, sub := range res.SubResources() {
|
||||
if resourceReachable(sub, filter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Catalog) serviceNames() []string {
|
||||
names := make([]string, len(c.services))
|
||||
for i, s := range c.services {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return names // c.services is already name-sorted
|
||||
}
|
||||
|
||||
func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) }
|
||||
func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) }
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// testCatalog builds a small embedded catalog: services drive (no resources)
|
||||
// and im with a dotted resource (chat.members), a multi-method resource
|
||||
// (reactions, where list is user-only), and images.
|
||||
func testCatalog() apicatalog.Catalog {
|
||||
im := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "im",
|
||||
"resources": map[string]interface{}{
|
||||
"chat.members": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
},
|
||||
"reactions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
"images": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
},
|
||||
},
|
||||
})
|
||||
drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"})
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted
|
||||
}
|
||||
|
||||
func TestNew_PreservesOrderAndLookup(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if c.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("source = %q", c.Source())
|
||||
}
|
||||
names := []string{}
|
||||
for _, s := range c.Services() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"drive", "im"}) {
|
||||
t.Errorf("Services order = %v, want [drive im]", names)
|
||||
}
|
||||
if _, ok := c.Service("im"); !ok {
|
||||
t.Error("Service(im) not found")
|
||||
}
|
||||
if _, ok := c.Service("nope"); ok {
|
||||
t.Error("Service(nope) should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts
|
||||
// arbitrary input by service name and shallow-copies the slice so later caller
|
||||
// mutation can't reorder the Catalog.
|
||||
func TestNew_SortsAndIsolatesInput(t *testing.T) {
|
||||
in := []meta.Service{
|
||||
meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}),
|
||||
meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}),
|
||||
}
|
||||
c := apicatalog.New(apicatalog.SourceEmbedded, in)
|
||||
|
||||
names := func() []string {
|
||||
var out []string
|
||||
for _, s := range c.Services() {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
|
||||
t.Errorf("New did not sort unsorted input: %v", got)
|
||||
}
|
||||
|
||||
// Mutating the caller's slice afterward must not reorder the Catalog.
|
||||
in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"})
|
||||
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
|
||||
t.Errorf("Catalog order changed after caller mutated its input slice: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkMethods_AllAndFiltered(t *testing.T) {
|
||||
c := testCatalog()
|
||||
|
||||
all := c.WalkMethods(nil)
|
||||
got := map[string]bool{}
|
||||
for _, r := range all {
|
||||
got[r.SchemaPath()] = true
|
||||
}
|
||||
want := []string{
|
||||
"im.chat.members.create",
|
||||
"im.images.create",
|
||||
"im.reactions.create",
|
||||
"im.reactions.list",
|
||||
}
|
||||
if len(all) != len(want) {
|
||||
t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got)
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Errorf("WalkMethods(nil) missing %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic order: services by name, resources by name, methods by name.
|
||||
var order []string
|
||||
for _, r := range all {
|
||||
order = append(order, r.SchemaPath())
|
||||
}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Errorf("WalkMethods order = %v, want %v", order, want)
|
||||
}
|
||||
|
||||
// Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods
|
||||
// with no accessTokens are permissive and stay.
|
||||
botOnly := func(m meta.Method) bool {
|
||||
if m.AccessTokens == nil {
|
||||
return true
|
||||
}
|
||||
for _, tok := range m.AccessTokens {
|
||||
if tok == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
filtered := c.WalkMethods(botOnly)
|
||||
for _, r := range filtered {
|
||||
if r.SchemaPath() == "im.reactions.list" {
|
||||
t.Error("filtered walk should drop user-only im.reactions.list")
|
||||
}
|
||||
}
|
||||
if len(filtered) != len(all)-1 {
|
||||
t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) {
|
||||
c := testCatalog()
|
||||
target, err := c.Resolve([]string{"im", "chat.members", "create"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if target.Kind != apicatalog.TargetMethod {
|
||||
t.Fatalf("kind = %v", target.Kind)
|
||||
}
|
||||
m := target.Method
|
||||
if m.SchemaPath() != "im.chat.members.create" {
|
||||
t.Errorf("SchemaPath = %q", m.SchemaPath())
|
||||
}
|
||||
if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) {
|
||||
t.Errorf("CommandPath = %v", m.CommandPath())
|
||||
}
|
||||
if m.ResourceName() != "chat.members" {
|
||||
t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName())
|
||||
}
|
||||
if m.Method.Name != "create" {
|
||||
t.Errorf("Method.Name not injected: %q", m.Method.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) {
|
||||
c := testCatalog()
|
||||
// schema.ParsePath splits both "im.chat.members.create" and
|
||||
// "im chat.members create" into segments; findResource's longest-prefix
|
||||
// must resolve the dotted resource either way.
|
||||
a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split
|
||||
b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("errA=%v errB=%v", errA, errB)
|
||||
}
|
||||
if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" {
|
||||
t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Targets(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll {
|
||||
t.Errorf("empty -> %v, want all", tg.Kind)
|
||||
}
|
||||
if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" {
|
||||
t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name)
|
||||
}
|
||||
if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" {
|
||||
t.Errorf("[im reactions] -> %v", tg.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Errors(t *testing.T) {
|
||||
c := testCatalog()
|
||||
cases := []struct {
|
||||
parts []string
|
||||
kind apicatalog.ResolveErrorKind
|
||||
}{
|
||||
{[]string{"nope"}, apicatalog.ErrService},
|
||||
{[]string{"im", "nope"}, apicatalog.ErrResource},
|
||||
{[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod},
|
||||
{[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := c.Resolve(tc.parts)
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err)
|
||||
continue
|
||||
}
|
||||
if re.Kind != tc.kind {
|
||||
t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind)
|
||||
}
|
||||
if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 {
|
||||
t.Errorf("%v -> expected candidates", tc.parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a
|
||||
// flat dotted resource (chat.members), so the round-trip contract is exercised
|
||||
// for real nesting — not just flat dotted keys.
|
||||
func nestedCatalog() apicatalog.Catalog {
|
||||
im := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "im",
|
||||
"resources": map[string]interface{}{
|
||||
"chat.members": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
},
|
||||
"spaces": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
"resources": map[string]interface{}{
|
||||
"items": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"get": map[string]interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im})
|
||||
}
|
||||
|
||||
// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method
|
||||
// WalkMethods emits must Resolve back to the same method — both from its dotted
|
||||
// SchemaPath (fully split) and from its CommandPath (resource as one segment).
|
||||
// This pins findResource's nested-resource descent symmetric to walkResources,
|
||||
// so "traversable" implies "resolvable".
|
||||
func TestResolve_WalkMethodsRoundTrip(t *testing.T) {
|
||||
for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} {
|
||||
for _, ref := range c.WalkMethods(nil) {
|
||||
want := ref.SchemaPath()
|
||||
for _, parts := range [][]string{
|
||||
strings.Split(want, "."), // fully-split dotted form
|
||||
ref.CommandPath(), // command form (resource stays one segment)
|
||||
} {
|
||||
tg, err := c.Resolve(parts)
|
||||
if err != nil {
|
||||
t.Errorf("round-trip %v: %v", parts, err)
|
||||
continue
|
||||
}
|
||||
if tg.Kind != apicatalog.TargetMethod {
|
||||
t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind)
|
||||
continue
|
||||
}
|
||||
if tg.Method.SchemaPath() != want {
|
||||
t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplete_Nested pins completion closure for genuinely nested resources:
|
||||
// both the dotted and space forms must reach a nested method, symmetric to
|
||||
// Resolve (findResource descends, so completion must too).
|
||||
func TestComplete_Nested(t *testing.T) {
|
||||
c := nestedCatalog()
|
||||
|
||||
// dotted: under a resource, offer its methods AND its sub-resources
|
||||
if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns {
|
||||
t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns)
|
||||
}
|
||||
// dotted: drill into the nested sub-resource's method
|
||||
if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns {
|
||||
t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns)
|
||||
}
|
||||
// dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type)
|
||||
if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns {
|
||||
t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns)
|
||||
}
|
||||
// space form: under a resource, offer methods AND sub-resources
|
||||
if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) {
|
||||
t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps)
|
||||
}
|
||||
// space form: drill into the nested sub-resource's methods
|
||||
if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) {
|
||||
t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplete(t *testing.T) {
|
||||
c := testCatalog()
|
||||
|
||||
// dotted: service prefix -> "im." (NoSpace)
|
||||
if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns {
|
||||
t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns)
|
||||
}
|
||||
// dotted: resource prefix -> "im.reactions." (NoSpace)
|
||||
if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) {
|
||||
t.Errorf("Complete([], im.rea) = %v", comps)
|
||||
}
|
||||
// space form: resource candidates under im (deterministic order)
|
||||
comps, ns := c.Complete([]string{"im"}, "", nil)
|
||||
if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns {
|
||||
t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns)
|
||||
}
|
||||
// space form: method candidates under reactions
|
||||
if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) {
|
||||
t.Errorf("Complete([im reactions], '') = %v", comps)
|
||||
}
|
||||
// filter applied: bot-only hides user-only list
|
||||
botOnly := func(m meta.Method) bool {
|
||||
if m.AccessTokens == nil {
|
||||
return true
|
||||
}
|
||||
for _, tok := range m.AccessTokens {
|
||||
if tok == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) {
|
||||
t.Errorf("Complete with bot filter = %v, want [create]", comps)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// TargetKind classifies what a schema/command path resolves to.
|
||||
type TargetKind string
|
||||
|
||||
const (
|
||||
TargetAll TargetKind = "all" // empty path: every method
|
||||
TargetService TargetKind = "service" // <service>
|
||||
TargetResource TargetKind = "resource" // <service> <resource...>
|
||||
TargetMethod TargetKind = "method" // <service> <resource...> <method>
|
||||
)
|
||||
|
||||
// Target is the result of Catalog.Resolve. Resource and Method are populated
|
||||
// only for TargetResource and TargetMethod respectively.
|
||||
type Target struct {
|
||||
Kind TargetKind
|
||||
Service meta.Service
|
||||
Resource *ResourceRef
|
||||
Method *MethodRef
|
||||
}
|
||||
|
||||
// ResourceRef identifies one resource within a service. Path holds the resource
|
||||
// path segments (one element for the common flat dotted resource like
|
||||
// "chat.members"; multiple for genuinely nested resources).
|
||||
type ResourceRef struct {
|
||||
Service meta.Service
|
||||
Resource meta.Resource
|
||||
Path []string
|
||||
}
|
||||
|
||||
// MethodRef identifies one method, carrying the full navigation context so the
|
||||
// command path and schema path can be derived without re-walking the catalog.
|
||||
type MethodRef struct {
|
||||
Service meta.Service
|
||||
Resource meta.Resource
|
||||
ResourcePath []string
|
||||
Method meta.Method
|
||||
}
|
||||
|
||||
// SchemaPath is the dotted "service.resource" identifier.
|
||||
func (r ResourceRef) SchemaPath() string {
|
||||
return r.Service.Name + "." + strings.Join(r.Path, ".")
|
||||
}
|
||||
|
||||
// ServiceName returns the owning service name.
|
||||
func (r MethodRef) ServiceName() string { return r.Service.Name }
|
||||
|
||||
// ResourceName is the dotted resource path, e.g. "chat.members".
|
||||
func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") }
|
||||
|
||||
// MethodName returns the method's own name.
|
||||
func (r MethodRef) MethodName() string { return r.Method.Name }
|
||||
|
||||
// SchemaPath is the dotted "service.resource.method" identifier, e.g.
|
||||
// "im.chat.members.create".
|
||||
func (r MethodRef) SchemaPath() string {
|
||||
return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name
|
||||
}
|
||||
|
||||
// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"].
|
||||
func (r MethodRef) CommandPath() []string {
|
||||
out := make([]string, 0, len(r.ResourcePath)+2)
|
||||
out = append(out, r.Service.Name)
|
||||
out = append(out, r.ResourcePath...)
|
||||
return append(out, r.Method.Name)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParsePath normalizes positional command arguments into the path segments
|
||||
// Resolve consumes. It accepts two equivalent forms:
|
||||
//
|
||||
// im.messages.reply -> single arg, split on "."
|
||||
// im messages reply -> multiple args, used as-is
|
||||
//
|
||||
// "im chat.members bots" as a single quoted arg is NOT supported; quote
|
||||
// arguments individually if your shell needs it. A resource keeps its internal
|
||||
// dots when passed as one segment (e.g. "chat.members"); findResource's
|
||||
// longest-prefix descent resolves both the split and the one-segment forms to
|
||||
// the same target. Returns nil for zero args (bare invocation -> TargetAll).
|
||||
func ParsePath(args []string) []string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if strings.Contains(args[0], ".") {
|
||||
return strings.Split(args[0], ".")
|
||||
}
|
||||
return []string{args[0]}
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog
|
||||
|
||||
// ResolveErrorKind classifies a Resolve failure so the command layer can render
|
||||
// the right hint without re-deriving what was being looked up.
|
||||
type ResolveErrorKind string
|
||||
|
||||
const (
|
||||
ErrService ResolveErrorKind = "service"
|
||||
ErrResource ResolveErrorKind = "resource"
|
||||
ErrMethod ResolveErrorKind = "method"
|
||||
ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve
|
||||
)
|
||||
|
||||
// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that
|
||||
// failed to resolve; Candidates lists the available names at that level (nil for
|
||||
// ErrPath, which instead carries the matched Method and the unresolved Trailing).
|
||||
type ResolveError struct {
|
||||
Kind ResolveErrorKind
|
||||
Subject string
|
||||
Candidates []string
|
||||
Method string
|
||||
Trailing string
|
||||
}
|
||||
|
||||
func (e *ResolveError) Error() string {
|
||||
return "unknown " + string(e.Kind) + ": " + e.Subject
|
||||
}
|
||||
@@ -47,7 +47,6 @@ type DeviceFlowResult struct {
|
||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||
type OAuthEndpoints struct {
|
||||
DeviceAuthorization string
|
||||
Revoke string
|
||||
Token string
|
||||
}
|
||||
|
||||
@@ -56,7 +55,6 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
@@ -45,9 +42,6 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ package auth
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// RevokeToken revokes a previously issued OAuth token.
|
||||
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
form.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
form.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := getInt(data, "code", 0); code != 0 {
|
||||
msg := getStr(data, "msg")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||
WithCode(code).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
if errStr := getStr(data, "error"); errStr != "" {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = errStr
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeHTTPStatusError(status int, body []byte) error {
|
||||
msg := formatOAuthErrorBody(body)
|
||||
cause := errors.New(strings.TrimSpace(string(body)))
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
cause = errors.New(msg)
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
func formatOAuthErrorBody(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "empty response"
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if msg := getStr(data, "error_description"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "message"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "error"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_a" &&
|
||||
values.Get("client_secret") == "secret_b" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeToken() error = %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||
sentinel := errors.New("transport down")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, sentinel
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %#v, want network/transport", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"error": "invalid_token"},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Fatalf("expected invalid_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"code": 12345,
|
||||
"msg": "invalid revoke state",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||
t.Fatalf("expected oauth error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": "token already expired",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("problem = %#v, want api error", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token already expired") {
|
||||
t.Fatalf("expected oauth error_description, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||
sentinel := errors.New("read failed")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: errReadCloser{err: sentinel},
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||
t.Fatalf("expected read error message, got %v", err)
|
||||
}
|
||||
if _, ok := err.(*errs.InternalError); !ok {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
}
|
||||
@@ -100,19 +100,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
|
||||
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
|
||||
// needed because the real function is guarded by an internal sync.Once, so
|
||||
// calling it directly would only fire on the first test (see
|
||||
// factory_proxy_warn_test.go). The terminal check is the IOStreams
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
@@ -139,9 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// installProxyWarnSpy replaces warnIfProxied with a counter for one test and
|
||||
// restores it on cleanup. Returns a pointer to the call count so the caller can
|
||||
// assert how many times the warning fired. The terminal state is controlled via
|
||||
// the IOStreams.StderrIsTerminal field, not a seam.
|
||||
func installProxyWarnSpy(t *testing.T) *int {
|
||||
t.Helper()
|
||||
prevWarn := warnIfProxied
|
||||
t.Cleanup(func() { warnIfProxied = prevWarn })
|
||||
calls := 0
|
||||
warnIfProxied = func(io.Writer) { calls++ }
|
||||
return &calls
|
||||
}
|
||||
|
||||
var proxyWarnGateCases = []struct {
|
||||
name string
|
||||
terminal bool
|
||||
want int
|
||||
}{
|
||||
{"terminal stderr warns once", true, 1},
|
||||
{"non-terminal stderr stays silent", false, 0},
|
||||
}
|
||||
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
|
||||
if *calls != tc.want {
|
||||
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachedLarkClientFunc_ProxyWarnGate verifies the lark-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal. The gate
|
||||
// runs after ResolveAccount, so an env-backed credential is wired up to let
|
||||
// account resolution succeed without network or config files.
|
||||
func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
// normalizeStreams copies the struct (out := *s), so the
|
||||
// StderrIsTerminal field survives into f.IOStreams.
|
||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
||||
if _, err := cachedLarkClientFunc(f)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
if *calls != tc.want {
|
||||
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,23 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// DetectFileFields returns field names with type "file" in the method's requestBody.
|
||||
func DetectFileFields(method map[string]interface{}) []string {
|
||||
rb, _ := method["requestBody"].(map[string]interface{})
|
||||
var fields []string
|
||||
for name, field := range rb {
|
||||
f, _ := field.(map[string]interface{})
|
||||
if registry.GetStrFromMap(f, "type") == "file" {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ParseFileFlag parses a --file flag value into its components.
|
||||
// The format is either "path" or "field=path". When no explicit "field="
|
||||
// prefix is present, defaultField is used as the field name.
|
||||
|
||||
@@ -10,6 +10,22 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"])
|
||||
// to CLI identity names (e.g. ["bot", "user"]).
|
||||
func AccessTokensToIdentities(tokens []interface{}) []string {
|
||||
var identities []string
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok {
|
||||
if ts == "tenant" {
|
||||
identities = append(identities, "bot")
|
||||
} else {
|
||||
identities = append(identities, ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// PrintIdentity outputs the current identity to stderr so callers (including AI agents)
|
||||
// can see which identity is being used for the API call.
|
||||
func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) {
|
||||
|
||||
@@ -11,6 +11,54 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAccessTokensToIdentities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokens []interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "tenant becomes bot",
|
||||
tokens: []interface{}{"tenant"},
|
||||
want: []string{"bot"},
|
||||
},
|
||||
{
|
||||
name: "user stays user",
|
||||
tokens: []interface{}{"user"},
|
||||
want: []string{"user"},
|
||||
},
|
||||
{
|
||||
name: "tenant and user",
|
||||
tokens: []interface{}{"tenant", "user"},
|
||||
want: []string{"bot", "user"},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
tokens: []interface{}{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "non-string values skipped",
|
||||
tokens: []interface{}{"tenant", 42, "user"},
|
||||
want: []string{"bot", "user"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AccessTokensToIdentities(tt.tokens)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIdentity_BotExplicit(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
PrintIdentity(&buf, core.AsBot, nil, false)
|
||||
|
||||
@@ -18,28 +18,17 @@ type IOStreams struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
||||
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
||||
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
||||
// Computed once in NewIOStreams, mirroring IsTerminal; tests assign it
|
||||
// directly like cmd/config/bind_test.go does for IsTerminal.
|
||||
StderrIsTerminal bool
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
|
||||
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
|
||||
// false.
|
||||
// IsTerminal is derived from in's underlying *os.File, if any; non-file
|
||||
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
stderrIsTerminal := false
|
||||
if f, ok := errOut.(*os.File); ok {
|
||||
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
|
||||
@@ -34,9 +34,7 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty (never nil) map
|
||||
// for empty input or the JSON literal null, so callers can always overlay onto
|
||||
// the result without a nil-map panic.
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin, fileIO)
|
||||
@@ -50,10 +48,5 @@ func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (m
|
||||
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
|
||||
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
|
||||
}
|
||||
if result == nil {
|
||||
// `null` unmarshals into a nil map without error; normalize it so the
|
||||
// returned map is always writable, matching the empty-input case.
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ func TestParseJSONMap(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty input", "", "--params", 0, false},
|
||||
{"json null", "null", "--params", 0, false},
|
||||
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
|
||||
{"invalid json", `{bad}`, "--params", 0, true},
|
||||
{"json array", `[1,2]`, "--data", 0, true},
|
||||
@@ -62,12 +61,6 @@ func TestParseJSONMap(t *testing.T) {
|
||||
if !tt.wantErr && len(got) != tt.wantLen {
|
||||
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
|
||||
}
|
||||
// A successful parse must yield a non-nil, writable map: callers
|
||||
// overlay onto it (params[k]=v), so `null` — which unmarshals to a
|
||||
// nil map without error — must normalize to {} like empty input.
|
||||
if !tt.wantErr && got == nil {
|
||||
t.Error("ParseJSONMap() = nil map on success, want non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,17 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// Risk level constants — aliases of the canonical core.Risk* values, re-exported
|
||||
// here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers
|
||||
// from one package. core is the single source of truth.
|
||||
// Risk level constants — the three-tier convention used across the CLI.
|
||||
// Use these in place of string literals so the typo radius is one place,
|
||||
// not every call site.
|
||||
const (
|
||||
RiskRead = core.RiskRead
|
||||
RiskWrite = core.RiskWrite
|
||||
RiskHighRiskWrite = core.RiskHighRiskWrite
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
// Risk levels — the three-tier convention used across the CLI. They live here,
|
||||
// at the leaf, so the envelope renderer (internal/schema) and the command
|
||||
// toolkit (internal/cmdutil) share one vocabulary without a renderer depending
|
||||
// on command utilities. Framework confirmation gating acts only on
|
||||
// RiskHighRiskWrite.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
@@ -22,12 +22,6 @@ func ParseBrand(value string) LarkBrand {
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
// domain. It serves every grant type (client_credentials for TAT,
|
||||
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
@@ -42,11 +42,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||
}
|
||||
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
|
||||
@@ -19,44 +19,33 @@ import (
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
|
||||
// reports failures using the OAuth 2.0 model — an `error` string plus an
|
||||
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
|
||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
||||
// with two distinct codes:
|
||||
//
|
||||
// invalid_client / unauthorized_client mean the configured app_id/app_secret
|
||||
// cannot mint a token; from the user's perspective that is the same actionable
|
||||
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
|
||||
// Every other deterministic error falls through to BuildAPIError, which still
|
||||
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
|
||||
// swallowing it. Transient/server-side failures (5xx / server_error) are
|
||||
// filtered out by FetchTAT before this is called, so they stay untyped.
|
||||
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
|
||||
msg := errDesc
|
||||
if msg == "" {
|
||||
msg = oauthErr
|
||||
}
|
||||
switch oauthErr {
|
||||
case "invalid_client", "unauthorized_client":
|
||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
||||
// - 10014: invalid app_secret ("app secret invalid")
|
||||
//
|
||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
||||
// the override stays local to this TAT call site instead of leaking into the
|
||||
// shared codemeta table.
|
||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
||||
if code == 10003 {
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
WithCode(code).
|
||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||
}
|
||||
if err := errclass.BuildAPIError(map[string]any{
|
||||
return errclass.BuildAPIError(map[string]any{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
}, errclass.ClassifyContext{
|
||||
Brand: brand,
|
||||
AppID: appID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
|
||||
// function is only reached once FetchTAT has ruled out success — a non-credential
|
||||
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
|
||||
// deterministic rejection. Back it with a typed APIError so callers never receive
|
||||
// the ("", nil) "empty token, no error" pair.
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
@@ -157,8 +146,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
|
||||
@@ -19,16 +19,18 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
|
||||
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
|
||||
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
|
||||
// tenant access token, the same actionable failure the legacy 10003/10014 codes
|
||||
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
|
||||
// return invalid_client with no Lark code (code defaults to 0).
|
||||
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
||||
// which from the user's perspective is the same actionable failure as 10014
|
||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
||||
// tenant access token. The global codemeta intentionally does not map 10003
|
||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
||||
// API uses it for permission denied), so the override is local to this site.
|
||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for invalid_client")
|
||||
t.Fatal("expected non-nil error for code=10003")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -40,16 +42,22 @@ func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T)
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
|
||||
// unauthorized_client is treated as the same credential failure as
|
||||
// invalid_client.
|
||||
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
||||
// for 10003 does not regress the existing mapping.
|
||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10014")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
@@ -57,38 +65,21 @@ func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testi
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
|
||||
// outside the credential set fall through to the generic BuildAPIError fallback
|
||||
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for invalid_scope")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
|
||||
if cfgErr.Code != 10014 {
|
||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
|
||||
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
|
||||
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
|
||||
// returns nil for code 0 (Feishu's success convention); without the backstop,
|
||||
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
|
||||
// with no error.
|
||||
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
||||
// the credential set fall through to the generic BuildAPIError fallback
|
||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
t.Fatal("expected non-nil error for unmapped code")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,47 +4,46 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
||||
// client_credentials grant with client_secret_post authentication. It does not
|
||||
// read configuration or keychain, so callers that already hold plaintext
|
||||
// credentials (e.g. the post-`config init` probe) can validate them without a
|
||||
// second keychain round-trip.
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
||||
// canonical typed error from classifyTATResponseCode — the SAME classification
|
||||
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
||||
// see one consistent envelope. Transport failures, unreadable/unparseable
|
||||
// bodies, and transient server-side failures (5xx / server_error) are returned
|
||||
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
||||
// deterministic credential rejection apart from upstream/transport noise.
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", appID)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -52,51 +51,20 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
// An unparseable body is ambiguous (covers non-JSON error pages and
|
||||
// truncated payloads); stay untyped so probe callers treat it as noise.
|
||||
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code == 0 && result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
|
||||
// Transient/server-side failures stay untyped so probe callers stay silent and
|
||||
// retryers can back off; only deterministic client rejections are typed. Covers
|
||||
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
||||
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
||||
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
||||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
||||
result.Error == "slow_down" {
|
||||
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
||||
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
||||
}
|
||||
|
||||
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
||||
if result.Code == 0 && result.Error == "" {
|
||||
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
||||
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
||||
// non-empty typed message instead of a bare "API error: [code]".
|
||||
desc := result.ErrorDescription
|
||||
if desc == "" {
|
||||
desc = result.Msg
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
@@ -55,33 +55,24 @@ func TestFetchTAT_Success(t *testing.T) {
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
// client_secret_post: grant_type + client_id + client_secret in the form body.
|
||||
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
|
||||
if !strings.Contains(rt.gotBody, want) {
|
||||
t.Errorf("request body missing %q: %s", want, rt.gotBody)
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
|
||||
// deterministic client-side rejection that FetchTAT routes to
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
|
||||
// secret → code 20002, unknown app → code 20048).
|
||||
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid_client")
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
@@ -96,115 +87,52 @@ func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
|
||||
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
|
||||
// surfaces it rather than silently swallowing it — but is NOT classified as a
|
||||
// credential (invalid_client) problem.
|
||||
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid_scope")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
|
||||
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
|
||||
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
|
||||
// for code 0, which would otherwise swallow this rejection into an empty-token success.
|
||||
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
|
||||
}
|
||||
if tok != "" {
|
||||
t.Errorf("token = %q, want empty", tok)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
|
||||
// must still surface its msg on the typed error, not degrade to a generic
|
||||
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
|
||||
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for {code, msg} response")
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app ticket invalid") {
|
||||
t.Errorf("typed error must carry the Lark msg, got: %v", err)
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Transient server-side failures (5xx / server_error) are NOT deterministic
|
||||
// credential rejections — they must stay UNTYPED so a probe caller treats them
|
||||
// as upstream noise and stays silent (and retryers can back off).
|
||||
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server_error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
|
||||
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
|
||||
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
|
||||
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
|
||||
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rate-limit")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
|
||||
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
|
||||
// noise and stays silent.
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
@@ -254,12 +182,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
|
||||
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
|
||||
@@ -262,23 +262,19 @@ func (b *Bus) handleConn(conn net.Conn) {
|
||||
|
||||
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
|
||||
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
|
||||
subID := hello.SubscriptionID
|
||||
if subID == "" {
|
||||
subID = hello.EventKey
|
||||
}
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
bc.SetCheckLastForKey(func(eventKey string) bool {
|
||||
return b.hub.AcquireCleanupLock(eventKey)
|
||||
})
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
|
||||
b.hub.ReleaseCleanupLock(c.SubscriptionID())
|
||||
b.hub.ReleaseCleanupLock(c.EventKey())
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
remaining := len(b.conns)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
pipes = append(pipes, server, client)
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
|
||||
bc.SetLogger(logger)
|
||||
hub.RegisterAndIsFirst(bc)
|
||||
|
||||
|
||||
@@ -29,10 +29,9 @@ type Conn struct {
|
||||
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
subID string
|
||||
pid int
|
||||
onClose func(*Conn)
|
||||
checkLastForKey func(scope string) bool
|
||||
checkLastForKey func(eventKey string) bool
|
||||
logger *log.Logger
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
@@ -42,7 +41,7 @@ type Conn struct {
|
||||
}
|
||||
|
||||
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
|
||||
if reader == nil {
|
||||
reader = bufio.NewReader(conn)
|
||||
}
|
||||
@@ -53,20 +52,10 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
pid: pid,
|
||||
subID: subID,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionID returns the subscription identity. Falls back to EventKey
|
||||
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
|
||||
func (c *Conn) SubscriptionID() string {
|
||||
if c.subID == "" {
|
||||
return c.eventKey
|
||||
}
|
||||
return c.subID
|
||||
}
|
||||
|
||||
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
|
||||
|
||||
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
|
||||
@@ -143,19 +132,13 @@ func (c *Conn) ReaderLoop() {
|
||||
}
|
||||
|
||||
func (c *Conn) handleControlMessage(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
switch m := msg.(type) {
|
||||
case *protocol.Bye:
|
||||
c.shutdown()
|
||||
case *protocol.PreShutdownCheck:
|
||||
// Use the connection's own authoritative subscription identity rather
|
||||
// than recomputing from the incoming message: a stale or mismatched
|
||||
// PreShutdownCheck must not ask about the wrong scope (which would
|
||||
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
|
||||
// already falls back to EventKey when its stored subID is empty.
|
||||
scope := c.SubscriptionID()
|
||||
lastForKey := true
|
||||
if c.checkLastForKey != nil {
|
||||
lastForKey = c.checkLastForKey(scope)
|
||||
lastForKey = c.checkLastForKey(m.EventKey)
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(lastForKey)
|
||||
if err := c.writeFrame(ack); err != nil && c.logger != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
|
||||
go bc.SenderLoop()
|
||||
|
||||
bc.SendCh() <- &protocol.Event{
|
||||
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
|
||||
defer client.Close()
|
||||
|
||||
det := &serializingDetector{Conn: server}
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
|
||||
go func() { _, _ = io.Copy(io.Discard, client) }()
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
|
||||
for i := 0; i < sendChCap; i++ {
|
||||
if !bc.TrySend(i) {
|
||||
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -142,23 +142,3 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
t.Fatal("ReaderLoop did not exit on EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
|
||||
if got := conn.SubscriptionID(); got != "mail.x:abc" {
|
||||
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
|
||||
if got := conn.SubscriptionID(); got != "mail.x" {
|
||||
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,134 +63,3 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
|
||||
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
|
||||
// subscription_id registers under EventKey (today's behavior preserved).
|
||||
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Legacy client: no subscription_id field (empty string).
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.message",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
SubscriptionID: "", // legacy: empty, should fallback to EventKey
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under EventKey (not a qualified subscription ID).
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message:something"); got != 0 {
|
||||
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
|
||||
// non-empty subscription_id registers under that ID, not EventKey.
|
||||
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Modern client: subscription_id explicitly set.
|
||||
subscriptionID := "mail.message:alice@example.com"
|
||||
hello := &protocol.Hello{
|
||||
PID: 8888,
|
||||
EventKey: "mail.message",
|
||||
EventTypes: []string{"mail.message.receive_v1"},
|
||||
SubscriptionID: subscriptionID, // modern: per-resource subscription
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under the subscription_id, not bare EventKey.
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("mail.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount(subscriptionID); got != 1 {
|
||||
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
|
||||
}
|
||||
if got := hub.SubCount("mail.message"); got != 0 {
|
||||
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ import (
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
// SubscriptionID identifies the per-resource subscription for dedup purposes.
|
||||
// When no resource qualifier is needed it equals EventKey.
|
||||
SubscriptionID() string
|
||||
EventTypes() []string
|
||||
SendCh() chan interface{}
|
||||
PID() int
|
||||
@@ -37,11 +34,8 @@ type Subscriber interface {
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[Subscriber]struct{}
|
||||
// subCounts is keyed by SubscriptionID (not EventKey) so that different
|
||||
// per-resource subscriptions sharing the same EventKey are deduped independently.
|
||||
subCounts map[string]int
|
||||
// cleanupInProgress[subscriptionID] holds a channel closed on release;
|
||||
// presence means a cleanup lock is held for that subscription.
|
||||
keyCounts map[string]int
|
||||
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
|
||||
cleanupInProgress map[string]chan struct{}
|
||||
logger atomic.Pointer[log.Logger]
|
||||
}
|
||||
@@ -49,7 +43,7 @@ type Hub struct {
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[Subscriber]struct{}),
|
||||
subCounts: make(map[string]int),
|
||||
keyCounts: make(map[string]int),
|
||||
cleanupInProgress: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -57,7 +51,7 @@ func NewHub() *Hub {
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
|
||||
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
|
||||
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -65,35 +59,34 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
return false
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
sid := s.SubscriptionID()
|
||||
h.subCounts[sid]--
|
||||
isLast := h.subCounts[sid] == 0
|
||||
h.keyCounts[s.EventKey()]--
|
||||
isLast := h.keyCounts[s.EventKey()] == 0
|
||||
if isLast {
|
||||
delete(h.subCounts, sid)
|
||||
delete(h.keyCounts, s.EventKey())
|
||||
}
|
||||
return isLast
|
||||
}
|
||||
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
|
||||
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
|
||||
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
|
||||
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.subCounts[subscriptionID] != 1 {
|
||||
if h.keyCounts[eventKey] != 1 {
|
||||
return false
|
||||
}
|
||||
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
|
||||
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
|
||||
return false
|
||||
}
|
||||
h.cleanupInProgress[subscriptionID] = make(chan struct{})
|
||||
h.cleanupInProgress[eventKey] = make(chan struct{})
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
|
||||
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
|
||||
func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
h.mu.Lock()
|
||||
ch := h.cleanupInProgress[subscriptionID]
|
||||
delete(h.cleanupInProgress, subscriptionID)
|
||||
ch := h.cleanupInProgress[eventKey]
|
||||
delete(h.cleanupInProgress, eventKey)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
@@ -101,24 +94,23 @@ func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
|
||||
}
|
||||
|
||||
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
|
||||
// subscriber for its SubscriptionID. If a cleanup is in progress for
|
||||
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
|
||||
// subscriber for its EventKey. If a cleanup is in progress for
|
||||
// s.EventKey() (another conn holds the cleanup lock), this waits until
|
||||
// cleanup releases before registering — closing the PreShutdownCheck ×
|
||||
// Hello TOCTOU race. The wait releases h.mu before blocking on the
|
||||
// channel, so concurrent operations on other subscriptions aren't stalled.
|
||||
// channel, so concurrent operations on other keys aren't stalled.
|
||||
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
sid := s.SubscriptionID()
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
ch, locked := h.cleanupInProgress[s.EventKey()]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
<-ch // wait for release, then re-check (defensive against races)
|
||||
continue
|
||||
}
|
||||
isFirst := h.subCounts[sid] == 0
|
||||
isFirst := h.keyCounts[s.EventKey()] == 0
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.subCounts[sid]++
|
||||
h.keyCounts[s.EventKey()]++
|
||||
h.mu.Unlock()
|
||||
return isFirst
|
||||
}
|
||||
@@ -184,25 +176,11 @@ func (h *Hub) ConnCount() int {
|
||||
return len(h.subscribers)
|
||||
}
|
||||
|
||||
// EventKeyCount returns total subscribers for the given EventKey, aggregating
|
||||
// across all SubscriptionIDs. For per-subscription counts use SubCount.
|
||||
// EventKeyCount returns the number of subscribers registered for eventKey.
|
||||
func (h *Hub) EventKeyCount(eventKey string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
count := 0
|
||||
for s := range h.subscribers {
|
||||
if s.EventKey() == eventKey {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// SubCount returns the count of subscribers for the given SubscriptionID.
|
||||
func (h *Hub) SubCount(subscriptionID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.subCounts[subscriptionID]
|
||||
return h.keyCounts[eventKey]
|
||||
}
|
||||
|
||||
// BroadcastSourceStatus fans out a source-level status change to every
|
||||
@@ -227,11 +205,10 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
|
||||
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
result = append(result, protocol.ConsumerInfo{
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
SubscriptionID: s.SubscriptionID(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 10)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ type alwaysFailSubscriber struct {
|
||||
}
|
||||
|
||||
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *alwaysFailSubscriber) PID() int { return 0 }
|
||||
@@ -154,7 +153,6 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *raceSubscriber) PID() int { return s.pid }
|
||||
|
||||
@@ -5,7 +5,6 @@ package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -236,10 +235,7 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
|
||||
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
|
||||
func (c *testConn) SubscriptionID() string { return c.eventKey }
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
func (c *testConn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *testConn) PID() int { return c.pid }
|
||||
@@ -279,79 +275,3 @@ func (c *testConn) TrySend(msg interface{}) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscriptionID_Isolation(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 should be first for its subscription")
|
||||
}
|
||||
if !h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 should ALSO be first (different SubscriptionID)")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s1) {
|
||||
t.Error("s1 should be last for mail.x:alice")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s2) {
|
||||
t.Error("s2 should be last for mail.x:bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 first")
|
||||
}
|
||||
if h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 same SubscriptionID should NOT be first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
h.RegisterAndIsFirst(s2)
|
||||
if got := h.EventKeyCount("mail.x"); got != 2 {
|
||||
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:alice"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:bob"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
consumers := h.Consumers()
|
||||
if len(consumers) != 1 {
|
||||
t.Fatalf("got %d consumers, want 1", len(consumers))
|
||||
}
|
||||
if consumers[0].SubscriptionID != "mail.x:alice" {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,22 +61,6 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
|
||||
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
|
||||
// SubscriptionID we send to bus reflects canonical values.
|
||||
if keyDef.NormalizeParams != nil {
|
||||
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute subscription identity from normalized params + SubscriptionKey flags.
|
||||
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
|
||||
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
@@ -97,13 +81,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func() error
|
||||
var cleanup func()
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
|
||||
@@ -129,22 +113,14 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if cleanup != nil {
|
||||
switch {
|
||||
case r != nil:
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
|
||||
opts.EventKey)
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
|
||||
cleanup()
|
||||
case lastForKey:
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running cleanup...\n")
|
||||
}
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
|
||||
cleanupErr)
|
||||
} else if !opts.Quiet {
|
||||
cleanup()
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] cleanup done.\n")
|
||||
}
|
||||
}
|
||||
@@ -168,7 +144,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
writeReadyMarker(errOut, opts)
|
||||
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// fakeRT is a minimal event.APIClient mock.
|
||||
type fakeRT struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
|
||||
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
|
||||
// bus is contacted, yet the production error-wrapping is exercised — if Run()
|
||||
// ever stops wrapping, this test fails.
|
||||
const key = "test.evt_normalize_fail"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
|
||||
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
|
||||
return errors.New("simulated normalize failure")
|
||||
},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
err := Run(context.Background(), transport.New(), "app", "", "", Options{
|
||||
EventKey: key,
|
||||
Runtime: &fakeRT{},
|
||||
Quiet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail when NormalizeParams errors")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
|
||||
t.Errorf("error not wrapped with EventKey prefix: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "simulated normalize failure") {
|
||||
t.Errorf("underlying error not propagated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
// Server-side: read Hello, decode, assert SubscriptionID, send ack
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
if hello, ok := msg.(*protocol.Hello); ok {
|
||||
done <- hello.SubscriptionID
|
||||
// send ack so client can return
|
||||
ack := protocol.NewHelloAck("v1", true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
} else {
|
||||
done <- "WRONG_TYPE"
|
||||
}
|
||||
}()
|
||||
|
||||
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("doHello error: %v", err)
|
||||
}
|
||||
if ack == nil {
|
||||
t.Fatal("got nil ack")
|
||||
}
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
|
||||
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
|
||||
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
|
||||
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
|
||||
//
|
||||
// Stability contract: same EventKey + same normalized param values -> same ID
|
||||
// across CLI versions; changing the encoding requires a wire-format bump.
|
||||
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
|
||||
type kv struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
var subParams []kv
|
||||
for _, p := range def.Params {
|
||||
if !p.SubscriptionKey {
|
||||
continue
|
||||
}
|
||||
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
|
||||
}
|
||||
if len(subParams) == 0 {
|
||||
return def.Key
|
||||
}
|
||||
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
|
||||
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
|
||||
sum := sha256.Sum256(raw)
|
||||
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestComputeSubscriptionID(t *testing.T) {
|
||||
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
|
||||
def := &event.KeyDefinition{Key: "test.evt"}
|
||||
marked := make(map[string]bool, len(subKeyNames))
|
||||
for _, n := range subKeyNames {
|
||||
marked[n] = true
|
||||
}
|
||||
for _, n := range []string{"alpha", "beta", "gamma"} {
|
||||
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
|
||||
def := makeDef()
|
||||
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
|
||||
if got != "test.evt" {
|
||||
t.Errorf("got %q, want %q", got, "test.evt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
|
||||
if id1 == id2 {
|
||||
t.Errorf("different values produced same ID: %q", id1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Stability(t *testing.T) {
|
||||
// Param order in the ParamDef list must not affect the result (sorted by name internally).
|
||||
def1 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
def2 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
|
||||
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Format(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "mail.user_mailbox.event.message_received_v1",
|
||||
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
}
|
||||
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
|
||||
prefix := "mail.user_mailbox.event.message_received_v1:"
|
||||
if !strings.HasPrefix(id, prefix) {
|
||||
t.Fatalf("missing prefix: %q", id)
|
||||
}
|
||||
suffix := strings.TrimPrefix(id, prefix)
|
||||
if len(suffix) != 16 {
|
||||
t.Errorf("fingerprint length = %d, want 16", len(suffix))
|
||||
}
|
||||
for _, c := range suffix {
|
||||
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
|
||||
if !isValid {
|
||||
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
|
||||
}
|
||||
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
|
||||
id := ComputeSubscriptionID(def, map[string]string{"value": val})
|
||||
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
|
||||
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
|
||||
if id1 != id2 {
|
||||
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
|
||||
}
|
||||
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
|
||||
if id1 == id3 {
|
||||
t.Errorf("empty and nonempty produced same ID: %q", id1)
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
|
||||
|
||||
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
|
||||
// buffered with the ack in one TCP segment aren't dropped.
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
|
||||
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
close(stopReader)
|
||||
<-readerDone
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey)
|
||||
conn.Close()
|
||||
case <-allDone:
|
||||
// bus-side close; can't query, assume last
|
||||
@@ -199,19 +199,13 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
|
||||
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
|
||||
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
|
||||
// Synchronous Match filter runs before any work (Process / sink write).
|
||||
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
|
||||
if keyDef.Process != nil {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
var err error
|
||||
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
|
||||
if err != nil {
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
if err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 1 {
|
||||
@@ -196,96 +196,12 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
if err == nil {
|
||||
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
|
||||
type captureSink struct {
|
||||
written []json.RawMessage
|
||||
}
|
||||
|
||||
func (s *captureSink) Write(data json.RawMessage) error {
|
||||
s.written = append(s.written, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
|
||||
calledProcess := false
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
return false
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
calledProcess = true
|
||||
return json.RawMessage(`{}`), nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("Match returned false but event was written")
|
||||
}
|
||||
if calledProcess {
|
||||
t.Error("Process was called even though Match returned false")
|
||||
}
|
||||
if len(sink.written) != 0 {
|
||||
t.Errorf("sink received %d events, want 0", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
|
||||
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil || !wrote {
|
||||
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
|
||||
}
|
||||
if len(sink.written) != 1 {
|
||||
t.Errorf("sink received %d events, want 1", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
|
||||
// Record the actual call sequence — a bare call-count check would still
|
||||
// pass if Process ran before Match.
|
||||
var order []string
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
order = append(order, "match")
|
||||
return true
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
order = append(order, "process")
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Error("expected wrote=true")
|
||||
}
|
||||
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
|
||||
t.Errorf("call order = %v, want [match process]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalSinkError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
|
||||
|
||||
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
|
||||
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
|
||||
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
|
||||
func checkLastForKey(conn net.Conn, eventKey string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey)
|
||||
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
@@ -40,7 +38,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
if got != false {
|
||||
t.Errorf("checkLastForKey = %v, want false", got)
|
||||
}
|
||||
@@ -64,7 +62,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
|
||||
_ = protocol.Encode(server, ack)
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true", got)
|
||||
}
|
||||
@@ -85,7 +83,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if got != true {
|
||||
@@ -95,39 +93,3 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR"
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR"
|
||||
return
|
||||
}
|
||||
check, ok := msg.(*protocol.PreShutdownCheck)
|
||||
if !ok {
|
||||
done <- "WRONG_TYPE"
|
||||
return
|
||||
}
|
||||
done <- check.SubscriptionID
|
||||
// Reply with ack so client returns
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
}()
|
||||
|
||||
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d remote event connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("remote event connection detected; `lark-cli event status` and `lark-cli event stop` only inspect local buses; stop the owner host/process, wait for the platform connection timeout, or use a separate app/profile")
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -50,16 +50,8 @@ func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantHints := []string{
|
||||
"remote event connection",
|
||||
"`lark-cli event status` and `lark-cli event stop` only inspect local buses",
|
||||
"stop the owner host/process",
|
||||
"wait for the platform connection timeout",
|
||||
}
|
||||
for _, want := range wantHints {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %q", want, ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user