diff --git a/.golangci.yml b/.golangci.yml index 7caac0c68..b198d6abe 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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/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/|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/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/) 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/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/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (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/) 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/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/|cmd/event/|events/|shortcuts/event/) + - path-except: (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/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index 1d5336f58..723fc6570 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -33,6 +33,7 @@ var migratedCommonHelperPaths = []string{ "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", + "shortcuts/wiki/", } const commonImportPath = "github.com/larksuite/cli/shortcuts/common" diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index 03be7c3af..61787452f 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -34,6 +34,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", + "shortcuts/wiki/", "shortcuts/im/", } diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index d736a6a36..67ef64088 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -960,6 +960,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "shortcuts/slides/slides_create.go", "shortcuts/task/task_update.go", "shortcuts/whiteboard/whiteboard_query.go", + "shortcuts/wiki/wiki_node_get.go", } for _, path := range paths { for _, helper := range helpers { @@ -1076,6 +1077,23 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/shortcuts/wiki/wiki_async_task.go b/shortcuts/wiki/wiki_async_task.go index 4404f6c1d..e54b615b5 100644 --- a/shortcuts/wiki/wiki_async_task.go +++ b/shortcuts/wiki/wiki_async_task.go @@ -5,12 +5,11 @@ package wiki import ( "context" - "errors" "fmt" "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -95,7 +94,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string { } // wikiAsyncTaskFetcher returns the latest status for taskID. Implementations -// translate from runtime.CallAPI responses or test fakes. +// translate from runtime.CallAPITyped responses or test fakes. type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) // parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload. @@ -103,7 +102,7 @@ type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTas // "simple_task_result" for delete-node). func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) { if task == nil { - return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } result := common.GetMap(task, resultKey) @@ -167,7 +166,7 @@ func pollWikiAsyncTask( return status, true, nil } if status.Failed() { - return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel()) + return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel()) } fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel()) @@ -178,29 +177,18 @@ func pollWikiAsyncTask( "the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s", label, taskID, nextCommand, ) - var exitErr *output.ExitError - if errors.As(lastErr, &exitErr) && exitErr.Detail != nil { - if strings.TrimSpace(exitErr.Detail.Hint) != "" { - hint = exitErr.Detail.Hint + "\n" + hint - } - // ErrWithHint rebuilds the error and drops the upstream Lark - // Detail.Code / ConsoleURL / Risk / nested Detail. Build the - // ExitError by hand so the original API code survives a fully - // failed poll, matching wrapWikiNodeDeleteAPIError. - return lastStatus, false, &output.ExitError{ - Code: exitErr.Code, - Detail: &output.ErrDetail{ - Type: exitErr.Detail.Type, - Code: exitErr.Detail.Code, - Message: exitErr.Detail.Message, - Hint: hint, - ConsoleURL: exitErr.Detail.ConsoleURL, - Risk: exitErr.Detail.Risk, - Detail: exitErr.Detail.Detail, - }, + // The poll error comes from a typed CallAPITyped path; append the resume + // hint in place so the original category / subtype / code / log_id + // survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed + // errors unchanged"), matching wrapWikiNodeDeleteAPIError. + if p, ok := errs.ProblemOf(lastErr); ok { + if strings.TrimSpace(p.Hint) != "" { + hint = p.Hint + "\n" + hint } + p.Hint = hint + return lastStatus, false, lastErr } - return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint) + return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr) } return lastStatus, false, nil diff --git a/shortcuts/wiki/wiki_async_task_test.go b/shortcuts/wiki/wiki_async_task_test.go index 5b8fd5afe..57a56203a 100644 --- a/shortcuts/wiki/wiki_async_task_test.go +++ b/shortcuts/wiki/wiki_async_task_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" ) // pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut, @@ -88,45 +88,54 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) { t.Parallel() runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser) + transportErr := errors.New("transport boom") _, ready, err := pollWikiAsyncTask( context.Background(), runtime, "task_lost", "delete-node", 2, 0, func(context.Context, string) (wikiAsyncTaskStatus, error) { - return wikiAsyncTaskStatus{}, errors.New("transport boom") + return wikiAsyncTaskStatus{}, transportErr }, "lark-cli drive +task_result --task-id task_lost", ) if ready { t.Fatalf("ready = true, want false when every poll failed") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %T %v, want a typed errs.* error", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code) + if p.Subtype != errs.SubtypeUnknown { + t.Fatalf("subtype = %q, want unknown for an untyped poll failure", p.Subtype) } - if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") || - !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") { - t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint) + if !errors.Is(err, transportErr) { + t.Fatalf("err does not preserve the transport cause: %v", err) + } + if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") || + !strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") { + t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint) } if !strings.Contains(stderr.String(), "attempt 2/2 failed") { t.Fatalf("stderr = %q, want per-attempt progress", stderr.String()) } } +func TestParseWikiAsyncTaskStatusRejectsNilTask(t *testing.T) { + t.Parallel() + + _, err := parseWikiAsyncTaskStatus("task_x", nil, "delete_space_result") + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("expected internal/invalid_response, got %v", err) + } +} + func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) { t.Parallel() runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser) - upstream := &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "permission", - Code: 99991663, - Message: "permission denied", - Hint: "grant the wiki:node:retrieve scope", - }, - } + // The upstream poll error is a typed error carrying its own hint, mirroring + // what runtime.CallAPITyped produces for a permission failure. + upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied"). + WithHint("grant the wiki:node:retrieve scope") _, _, err := pollWikiAsyncTask( context.Background(), runtime, "task_perm", "delete-node", 1, 0, func(context.Context, string) (wikiAsyncTaskStatus, error) { @@ -134,23 +143,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) { }, "resume-cmd", ) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("err = %T %v, want *output.ExitError", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %T %v, want a typed errs.* error", err, err) } // The upstream hint must lead so the actionable cause is read first, with - // the resume guidance appended. Type and exit code propagate from upstream. - if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") { - t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint) + // the resume guidance appended. The original typed error propagates in place. + if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") { + t.Fatalf("hint = %q, want upstream hint prepended", p.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") { - t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "resume-cmd") { + t.Fatalf("hint = %q, want resume command appended", p.Hint) } - if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI { - t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr) + if p.Subtype != errs.SubtypePermissionDenied { + t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype) } - if exitErr.Detail.Message != "permission denied" { - t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message) + if p.Message != "permission denied" { + t.Fatalf("message = %q, want upstream message preserved", p.Message) } } diff --git a/shortcuts/wiki/wiki_delete.go b/shortcuts/wiki/wiki_delete.go index 484d7d468..3c3eb8358 100644 --- a/shortcuts/wiki/wiki_delete.go +++ b/shortcuts/wiki/wiki_delete.go @@ -9,8 +9,8 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct { } func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "DELETE", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)), nil, @@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) ( } func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "delete_space"}, @@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error { if spec.SpaceID == "" { - return output.ErrValidation("--space-id is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id") } return validateOptionalResourceName(spec.SpaceID, "--space-id") } diff --git a/shortcuts/wiki/wiki_delete_test.go b/shortcuts/wiki/wiki_delete_test.go index e2abe608d..b2772884e 100644 --- a/shortcuts/wiki/wiki_delete_test.go +++ b/shortcuts/wiki/wiki_delete_test.go @@ -6,7 +6,6 @@ package wiki import ( "bytes" "context" - "errors" "reflect" "strings" "sync" @@ -14,11 +13,12 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "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/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -266,19 +266,18 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) { withSingleWikiDeleteSpacePoll(t) runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "") - // Seed an error that carries an upstream Lark Detail.Code so the test + // Seed a typed error that carries an upstream Lark code and hint so the test // pins that structured fields survive a fully failed poll (not just the - // hint). ErrWithHint drops Detail.Code, which is exactly what we fixed. + // hint): the poll-exhaustion path must propagate the typed error in place. + seeded := errclass.BuildAPIError( + map[string]any{"code": float64(131006), "msg": "poll failed"}, + errclass.ClassifyContext{}, + ) + if p, ok := errs.ProblemOf(seeded); ok { + p.Hint = "retry original" + } client := &fakeWikiDeleteSpaceClient{ - taskErrs: []error{&output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "api_error", - Code: 131006, - Message: "poll failed", - Hint: "retry original", - }, - }}, + taskErrs: []error{seeded}, } status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123") @@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) { if status.TaskID != "task_123" { t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %T %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) } - if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) { - t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) { + t.Fatalf("hint = %q, want original hint and resume command", p.Hint) } - if exitErr.Detail.Code != 131006 { - t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code) + if p.Code != 131006 { + t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code) } if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") { t.Fatalf("stderr = %q, want poll failure log", stderr.String()) diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index e5bbf31f5..924a9129d 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -351,6 +351,7 @@ func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") { t.Fatalf("expected target validation error, got %v", err) } + requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token") } func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) { @@ -365,6 +366,7 @@ func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { t.Fatalf("expected mutually exclusive error, got %v", err) } + requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token") } // TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write diff --git a/shortcuts/wiki/wiki_member_add.go b/shortcuts/wiki/wiki_member_add.go index 5337a32ac..a7fc86394 100644 --- a/shortcuts/wiki/wiki_member_add.go +++ b/shortcuts/wiki/wiki_member_add.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -65,7 +65,7 @@ var WikiMemberAdd = common.Shortcut{ common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID)) path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID)) - data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody()) + data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody()) if err != nil { return err } @@ -131,16 +131,16 @@ func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, e return wikiMemberAddSpec{}, err } if spec.MemberID == "" { - return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank") + return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id") } // The space-member API rejects opendepartmentid grants under a // tenant_access_token; surface that as a CLI validation error so callers do // not waste a network round-trip on a server-side 403. The escape hatch is // --as user, which is the only identity the API accepts for departments. if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" { - return wikiMemberAddSpec{}, output.ErrValidation( + return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--as bot does not support --member-type opendepartmentid; rerun with --as user", - ) + ).WithParam("--member-type") } // --member-type / --member-role enum membership is enforced by the // framework's validateEnumFlags (runner.go) before Validate runs, so no diff --git a/shortcuts/wiki/wiki_member_helpers.go b/shortcuts/wiki/wiki_member_helpers.go index 8eac0da2e..8fd1e4128 100644 --- a/shortcuts/wiki/wiki_member_helpers.go +++ b/shortcuts/wiki/wiki_member_helpers.go @@ -6,7 +6,7 @@ package wiki import ( "fmt" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -27,10 +27,10 @@ var wikiMemberRoles = []string{"admin", "member"} // tenant_access_token; same contract as +node-list / +node-create) func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error { if spaceID == "" { - return output.ErrValidation("--space-id is required and cannot be blank") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id") } if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id") } return validateOptionalResourceName(spaceID, "--space-id") } diff --git a/shortcuts/wiki/wiki_member_list.go b/shortcuts/wiki/wiki_member_list.go index 0aead4cda..8dbe84db9 100644 --- a/shortcuts/wiki/wiki_member_list.go +++ b/shortcuts/wiki/wiki_member_list.go @@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str if pageToken != "" { params["page_token"] = pageToken } - data, err := runtime.CallAPI("GET", apiPath, params, nil) + data, err := runtime.CallAPITyped("GET", apiPath, params, nil) if err != nil { return nil, false, "", err } diff --git a/shortcuts/wiki/wiki_member_remove.go b/shortcuts/wiki/wiki_member_remove.go index 6a8bab867..1a0a36447 100644 --- a/shortcuts/wiki/wiki_member_remove.go +++ b/shortcuts/wiki/wiki_member_remove.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -68,7 +68,7 @@ var WikiMemberRemove = common.Shortcut{ validate.EncodePathSegment(spaceID), validate.EncodePathSegment(spec.MemberID), ) - data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody()) + data, err := runtime.CallAPITyped("DELETE", path, nil, spec.RequestBody()) if err != nil { return err } @@ -122,7 +122,7 @@ func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveS return wikiMemberRemoveSpec{}, err } if spec.MemberID == "" { - return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank") + return wikiMemberRemoveSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id") } // Enum membership for --member-type / --member-role is enforced by the // framework's validateEnumFlags (runner.go) before Validate runs. diff --git a/shortcuts/wiki/wiki_move.go b/shortcuts/wiki/wiki_move.go index 7931c2549..8ee798bcc 100644 --- a/shortcuts/wiki/wiki_move.go +++ b/shortcuts/wiki/wiki_move.go @@ -5,13 +5,12 @@ package wiki import ( "context" - "errors" "fmt" "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -65,7 +64,7 @@ var WikiMove = common.Shortcut{ // for a tenant_access_token (--as bot), so reject early with a clear // hint instead of letting the API return a confusing error. if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`").WithParam("--target-space-id") } return validateWikiMoveSpec(spec) }, @@ -230,7 +229,7 @@ type wikiMoveAPI struct { } func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, @@ -243,7 +242,7 @@ func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeReco } func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "POST", fmt.Sprintf( "/open-apis/wiki/v2/spaces/%s/nodes/%s/move", @@ -260,7 +259,7 @@ func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec } func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "POST", fmt.Sprintf( "/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki", @@ -281,7 +280,7 @@ func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, } func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "move"}, @@ -324,28 +323,42 @@ func validateWikiMoveSpec(spec wikiMoveSpec) error { if spec.NodeToken != "" { if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply { - return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token cannot be combined with --obj-type, --obj-token, or --apply"). + WithParams( + errs.InvalidParam{Name: "--obj-type", Reason: "cannot be combined with --node-token"}, + errs.InvalidParam{Name: "--obj-token", Reason: "cannot be combined with --node-token"}, + errs.InvalidParam{Name: "--apply", Reason: "cannot be combined with --node-token"}, + ) } if spec.TargetParentToken == "" && spec.TargetSpaceID == "" { - return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-parent-token and --target-space-id cannot both be empty for wiki node move"). + WithParams( + errs.InvalidParam{Name: "--target-parent-token", Reason: "provide --target-parent-token or --target-space-id"}, + errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-parent-token or --target-space-id"}, + ) } return nil } if spec.SourceSpaceID != "" { - return output.ErrValidation("--source-space-id can only be used with --node-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--source-space-id can only be used with --node-token").WithParam("--source-space-id") } if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply { - return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move"). + WithParams( + errs.InvalidParam{Name: "--node-token", Reason: "provide --node-token, or --obj-type and --obj-token"}, + errs.InvalidParam{Name: "--obj-type", Reason: "provide --node-token, or --obj-type and --obj-token"}, + errs.InvalidParam{Name: "--obj-token", Reason: "provide --node-token, or --obj-type and --obj-token"}, + ) } if spec.ObjType == "" { - return output.ErrValidation("--obj-type is required for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for docs-to-wiki move").WithParam("--obj-type") } if spec.ObjToken == "" { - return output.ErrValidation("--obj-token is required for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-token is required for docs-to-wiki move").WithParam("--obj-token") } if spec.TargetSpaceID == "" { - return output.ErrValidation("--target-space-id is required for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id is required for docs-to-wiki move").WithParam("--target-space-id") } return nil @@ -426,7 +439,7 @@ func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.Run case wikiMoveModeDocsToWiki: return runWikiDocsToWikiMove(ctx, client, runtime, spec) default: - return nil, output.ErrValidation("unknown wiki move mode") + return nil, errs.NewInternalError(errs.SubtypeUnknown, "unknown wiki move mode") } } @@ -479,11 +492,11 @@ func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec if targetSpaceID == "" { targetSpaceID = parentSpaceID } else if targetSpaceID != parentSpaceID { - return "", "", output.ErrValidation( + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id %q does not match target parent node space %q", spec.TargetSpaceID, parentSpaceID, - ) + ).WithParam("--target-space-id") } } @@ -549,7 +562,7 @@ func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime * } return out, nil default: - return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result") } } @@ -592,7 +605,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo return status, true, nil } if status.Failed() { - return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel()) + return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki move task failed: %s", status.PrimaryStatusLabel()) } fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel()) @@ -605,14 +618,18 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo taskID, nextCommand, ) - var exitErr *output.ExitError - if errors.As(lastErr, &exitErr) && exitErr.Detail != nil { - if strings.TrimSpace(exitErr.Detail.Hint) != "" { - hint = exitErr.Detail.Hint + "\n" + hint + // The poll error comes from a typed CallAPITyped path; append the resume + // hint in place so the original category / subtype / code / log_id + // survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed + // errors unchanged"). + if p, ok := errs.ProblemOf(lastErr); ok { + if strings.TrimSpace(p.Hint) != "" { + hint = p.Hint + "\n" + hint } - return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) + p.Hint = hint + return lastStatus, false, lastErr } - return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint) + return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr) } return lastStatus, false, nil @@ -620,7 +637,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) { if task == nil { - return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return wikiMoveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } status := wikiMoveTaskStatus{ diff --git a/shortcuts/wiki/wiki_move_test.go b/shortcuts/wiki/wiki_move_test.go index bfae5b212..6d9a7e64f 100644 --- a/shortcuts/wiki/wiki_move_test.go +++ b/shortcuts/wiki/wiki_move_test.go @@ -7,7 +7,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "reflect" "strings" "sync" @@ -15,11 +14,11 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -181,39 +180,52 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) { t.Parallel() tests := []struct { - name string - spec wikiMoveSpec - wantErr string + name string + spec wikiMoveSpec + wantErr string + wantParams []string }{ { - name: "node move rejects docs flags", - spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"}, - wantErr: "cannot be combined", + name: "node move rejects docs flags", + spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"}, + wantErr: "cannot be combined", + wantParams: []string{"--obj-type", "--obj-token", "--apply"}, }, { - name: "node move requires target", - spec: wikiMoveSpec{NodeToken: "wik_node"}, - wantErr: "cannot both be empty", + name: "node move requires target", + spec: wikiMoveSpec{NodeToken: "wik_node"}, + wantErr: "cannot both be empty", + wantParams: []string{"--target-parent-token", "--target-space-id"}, }, { - name: "source space requires node token", - spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, - wantErr: "can only be used with --node-token", + name: "requires a move mode", + spec: wikiMoveSpec{TargetSpaceID: "space_dst"}, + wantErr: "provide --node-token for wiki node move", + wantParams: []string{"--node-token", "--obj-type", "--obj-token"}, }, { - name: "docs to wiki requires obj type", - spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, - wantErr: "--obj-type is required", + name: "source space requires node token", + spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, + wantErr: "can only be used with --node-token", + wantParams: []string{"--source-space-id"}, }, { - name: "docs to wiki requires obj token", - spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"}, - wantErr: "--obj-token is required", + name: "docs to wiki requires obj type", + spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, + wantErr: "--obj-type is required", + wantParams: []string{"--obj-type"}, }, { - name: "docs to wiki requires target space", - spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"}, - wantErr: "--target-space-id is required", + name: "docs to wiki requires obj token", + spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"}, + wantErr: "--obj-token is required", + wantParams: []string{"--obj-token"}, + }, + { + name: "docs to wiki requires target space", + spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"}, + wantErr: "--target-space-id is required", + wantParams: []string{"--target-space-id"}, }, } @@ -225,6 +237,9 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } + if len(tt.wantParams) > 0 { + requireWikiValidationParams(t, err, tt.wantParams...) + } }) } } @@ -837,7 +852,7 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) { runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") client := &fakeWikiMoveClient{ - taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")}, + taskErrs: []error{errs.NewAPIError(errs.SubtypeServerError, "poll failed").WithHint("retry original")}, } status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123") @@ -850,12 +865,12 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) { if status.TaskID != "task_123" { t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %T %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) } - if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) { - t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) { + t.Fatalf("hint = %q, want original hint and resume command", p.Hint) } if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") { t.Fatalf("stderr = %q, want poll failure log", stderr.String()) diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index c1bea07cc..67a5d2712 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -9,7 +9,7 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,10 +44,18 @@ var WikiNodeCopy = common.Shortcut{ targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id")) targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token")) if targetSpaceID == "" && targetParent == "" { - return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --target-space-id or --target-parent-node-token is required"). + WithParams( + errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-space-id or --target-parent-node-token"}, + errs.InvalidParam{Name: "--target-parent-node-token", Reason: "provide --target-space-id or --target-parent-node-token"}, + ) } if targetSpaceID != "" && targetParent != "" { - return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id and --target-parent-node-token are mutually exclusive; provide only one"). + WithParams( + errs.InvalidParam{Name: "--target-space-id", Reason: "mutually exclusive with --target-parent-node-token"}, + errs.InvalidParam{Name: "--target-parent-node-token", Reason: "mutually exclusive with --target-space-id"}, + ) } if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil { return err @@ -72,7 +80,7 @@ var WikiNodeCopy = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n", common.MaskToken(nodeToken), common.MaskToken(spaceID)) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", validate.EncodePathSegment(spaceID), validate.EncodePathSegment(nodeToken)), diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index d9e6a8689..6356ede84 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -5,12 +5,12 @@ package wiki import ( "context" - "errors" "fmt" "io" "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -170,7 +170,7 @@ type wikiNodeCreateAPI struct { } func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, @@ -183,7 +183,7 @@ func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNo } func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)), nil, @@ -196,7 +196,7 @@ func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wik } func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "POST", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)), nil, @@ -231,22 +231,26 @@ func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity) } if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" { - return output.ErrValidation("--origin-node-token is required when --node-type=shortcut") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token is required when --node-type=shortcut").WithParam("--origin-node-token") } if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" { - return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token can only be used when --node-type=shortcut").WithParam("--origin-node-token") } // Bot identity has no meaningful "personal document library" target, so // my_library must be rejected explicitly instead of deferring to API-time // resolution errors. if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token").WithParam("--space-id") } // Bot identity also cannot fall back implicitly, so it requires an explicit // target or a parent it can resolve from. if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" { - return output.ErrValidation("bot identity requires --space-id or --parent-node-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token"). + WithParams( + errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"}, + errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"}, + ) } return nil @@ -334,7 +338,7 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit return nil, wrapWikiNodeCreateRetryError(lastErr) } if node == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node create returned no node") } return &wikiNodeCreateExecution{ @@ -346,45 +350,32 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit // isWikiNodeLockContention returns true if the error is a Lark API error with // code 131009 (wiki node lock contention), which is retryable with backoff. func isWikiNodeLockContention(err error) bool { - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok { return false } - return exitErr.Detail.Code == output.LarkErrWikiLockContention + return p.Code == output.LarkErrWikiLockContention } // wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original -// API error. It builds the ExitError by hand (instead of using ErrWithHint) so -// the original Lark error code survives in the envelope. +// API error in place, preserving its typed category / subtype / code / log_id. func wrapWikiNodeCreateRetryError(err error) error { if err == nil { return nil } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok { return err } hint := fmt.Sprintf( "wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent", wikiNodeCreateMaxRetries, ) - if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" { + if existing := strings.TrimSpace(p.Hint); existing != "" { hint = existing + "\n" + hint } - return &output.ExitError{ - Code: exitErr.Code, - Detail: &output.ErrDetail{ - Type: exitErr.Detail.Type, - Code: exitErr.Detail.Code, - Message: exitErr.Detail.Message, - Hint: hint, - ConsoleURL: exitErr.Detail.ConsoleURL, - Risk: exitErr.Detail.Risk, - Detail: exitErr.Detail.Detail, - }, - Err: exitErr.Err, - Raw: exitErr.Raw, - } + p.Hint = hint + return err } // resolveWikiNodeCreateSpace applies the shortcut's precedence rules: @@ -397,7 +388,11 @@ func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken) } if identity.IsBot() { - return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token") + return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token"). + WithParams( + errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"}, + errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"}, + ) } return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client) } @@ -434,12 +429,12 @@ func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wik return wikiResolvedSpace{}, err } if parentSpaceID != resolved.SpaceID { - return wikiResolvedSpace{}, output.ErrValidation( + return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id %q does not match parent node space %q (resolved space: %q)", spec.SpaceID, parentSpaceID, resolved.SpaceID, - ) + ).WithParam("--space-id") } resolved.ParentNode = parent @@ -483,21 +478,22 @@ func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) { if node != nil && node.SpaceID != "" { return node.SpaceID, nil } - return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node lookup returned no space_id") } func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) { if space != nil && space.SpaceID != "" { return space.SpaceID, nil } - return "", output.ErrValidation("personal document library was not found, please specify --space-id") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "personal document library lookup returned no space_id"). + WithHint("specify --space-id explicitly to target a space directly") } // resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns // the per-user real space_id. Shared by shortcuts that accept the my_library // alias (e.g. +node-create, +node-list) so the behavior stays consistent. func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) { - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)), nil, nil, @@ -517,14 +513,14 @@ func validateOptionalResourceName(value, flagName string) error { return nil } if err := validate.ResourceName(value, flagName); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName).WithCause(err) } return nil } func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) { if node == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response missing node") } return &wikiNodeRecord{ SpaceID: common.GetString(node, "space_id"), @@ -542,7 +538,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) { func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) { if space == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space response missing space") } return &wikiSpaceRecord{ SpaceID: common.GetString(space, "space_id"), diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 889648718..8d183bcdc 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -16,13 +16,26 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) +// wikiTestLockContentionErr builds the typed API error that runtime.CallAPITyped +// produces for a wiki write-lock-contention response (code 131009), so the +// retry path's errs.ProblemOf(...).Code check sees the same shape in tests as +// in production. +func wikiTestLockContentionErr() error { + return errclass.BuildAPIError( + map[string]any{"code": float64(output.LarkErrWikiLockContention), "msg": "lock contention"}, + errclass.ClassifyContext{}, + ) +} + type fakeWikiNodeCreateCall struct { SpaceID string Spec wikiNodeCreateSpec @@ -116,6 +129,44 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact return parent.Execute() } +// requireWikiValidationParams asserts err carries a *errs.ValidationError of +// category validation / subtype invalid_argument and that its named flags +// (single Param or structured Params) cover every wanted flag, locking the +// typed contract and input-recovery fields so callers and agents can re-fill +// the right flags without parsing the prose message. +func requireWikiValidationParams(t *testing.T, err error, wantNames ...string) { + t.Helper() + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) + } + if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype) + } + have := make(map[string]bool, len(ve.Params)+1) + if ve.Param != "" { + have[ve.Param] = true + } + for _, p := range ve.Params { + have[p.Name] = true + } + for _, name := range wantNames { + if !have[name] { + t.Fatalf("flag %q not found; Param=%q Params=%+v", name, ve.Param, ve.Params) + } + } +} + +func TestRequireWikiSpaceIDTreatsEmptyAsInvalidResponse(t *testing.T) { + t.Parallel() + + _, err := requireWikiSpaceID(&wikiSpaceRecord{}) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("expected internal/invalid_response, got %v", err) + } +} + func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) { t.Parallel() @@ -151,6 +202,7 @@ func TestValidateWikiNodeCreateSpecRejectsBotWithoutLocation(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") { t.Fatalf("expected bot location validation error, got %v", err) } + requireWikiValidationParams(t, err, "--space-id", "--parent-node-token") } func TestValidateWikiNodeCreateSpecRejectsBotMyLibrarySpaceID(t *testing.T) { @@ -236,6 +288,19 @@ func TestResolveWikiNodeCreateSpaceUsesMyLibraryFallback(t *testing.T) { } } +func TestResolveWikiNodeCreateSpaceRejectsBotWithoutLocation(t *testing.T) { + t.Parallel() + + _, err := resolveWikiNodeCreateSpace(context.Background(), &fakeWikiNodeCreateClient{}, core.AsBot, wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + }) + if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") { + t.Fatalf("expected bot location validation error, got %v", err) + } + requireWikiValidationParams(t, err, "--space-id", "--parent-node-token") +} + func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) { t.Parallel() @@ -785,7 +850,7 @@ func TestWikiNodeURL(t *testing.T) { func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -831,7 +896,7 @@ func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) { func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -853,25 +918,30 @@ func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) { if len(client.createInvoked) != 3 { t.Fatalf("create invoked %d times, want 3", len(client.createInvoked)) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError, got %T: %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) } - if exitErr.Detail.Code != output.LarkErrWikiLockContention { - t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention) + if p.Code != output.LarkErrWikiLockContention { + t.Fatalf("error code = %d, want %d", p.Code, output.LarkErrWikiLockContention) } - if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") { - t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "failed after 2 retries") { + t.Fatalf("hint = %q, want retry exhaustion message", p.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "lock contention") { - t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "lock contention") { + t.Fatalf("hint = %q, want original classification hint preserved", p.Hint) } } func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) { t.Parallel() - otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention + // A typed API error for a different code (rate limit, not lock contention), + // mirroring what runtime.CallAPITyped produces. + otherErr := errclass.BuildAPIError( + map[string]any{"code": float64(output.LarkErrRateLimit), "msg": "rate limit"}, + errclass.ClassifyContext{}, + ) client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -901,7 +971,7 @@ func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) { func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -944,7 +1014,7 @@ func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) { func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ diff --git a/shortcuts/wiki/wiki_node_delete.go b/shortcuts/wiki/wiki_node_delete.go index 800581f0c..fc33c507d 100644 --- a/shortcuts/wiki/wiki_node_delete.go +++ b/shortcuts/wiki/wiki_node_delete.go @@ -5,14 +5,13 @@ package wiki import ( "context" - "errors" "fmt" "net/url" "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -135,7 +134,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str if objType != "" && objType != "wiki" { params["obj_type"] = objType } - data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil) + data, err := api.runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil) if err != nil { return nil, err } @@ -143,7 +142,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str } func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "DELETE", fmt.Sprintf( "/open-apis/wiki/v2/spaces/%s/nodes/%s", @@ -160,7 +159,7 @@ func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spe } func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode}, @@ -188,7 +187,7 @@ func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) { tokenInput := strings.TrimSpace(rawToken) if tokenInput == "" { - return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required") + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token") } spec := wikiNodeDeleteSpec{ @@ -200,14 +199,14 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi if strings.Contains(tokenInput, "://") { u, err := url.Parse(tokenInput) if err != nil || u.Path == "" { - return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput) + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token") } token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path) if !ok { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token", u.Path, - ) + ).WithParam("--node-token") } spec.NodeToken = token spec.SourceKind = "url" @@ -222,32 +221,32 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi case spec.ObjType == "": spec.ObjType = inferred case spec.ObjType != inferred: - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type %q does not match the obj_type %q implied by the URL path; pass only one", spec.ObjType, inferred, - ) + ).WithParam("--obj-type") } } else if strings.ContainsAny(tokenInput, "/?#") { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token must be a raw token or a full URL; partial paths are not accepted: %q", tokenInput, - ) + ).WithParam("--node-token") } else { spec.NodeToken = tokenInput spec.SourceKind = "raw" } if spec.ObjType == "" { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required (one of: %s)", strings.Join(wikiNodeDeleteObjTypes, ", "), - ) + ).WithParam("--obj-type") } if !isValidWikiDeleteObjType(spec.ObjType) { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type %q is not valid; pick one of: %s", spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "), - ) + ).WithParam("--obj-type") } if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil { return wikiNodeDeleteSpec{}, err @@ -405,12 +404,12 @@ func wrapWikiNodeDeleteAPIError(err error) error { if err == nil { return nil } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok { return err } var hint string - switch exitErr.Detail.Code { + switch p.Code { case wikiDeleteNodeErrCodeApprovalRequired: hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)" case wikiDeleteNodeErrCodeSubtreeTooLarge: @@ -419,22 +418,11 @@ func wrapWikiNodeDeleteAPIError(err error) error { if hint == "" { return err } - if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" { + // Append the hint in place so the typed error keeps its category / subtype / + // code / log_id (per ERROR_CONTRACT.md "propagate typed errors unchanged"). + if existing := strings.TrimSpace(p.Hint); existing != "" { hint = existing + "\n" + hint } - // ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build - // the ExitError by hand so the Lark error code stays available to logs and - // downstream pivots. - return &output.ExitError{ - Code: exitErr.Code, - Detail: &output.ErrDetail{ - Type: exitErr.Detail.Type, - Code: exitErr.Detail.Code, - Message: exitErr.Detail.Message, - Hint: hint, - ConsoleURL: exitErr.Detail.ConsoleURL, - Risk: exitErr.Detail.Risk, - Detail: exitErr.Detail.Detail, - }, - } + p.Hint = hint + return err } diff --git a/shortcuts/wiki/wiki_node_delete_test.go b/shortcuts/wiki/wiki_node_delete_test.go index a580e2f9a..5e4190ddc 100644 --- a/shortcuts/wiki/wiki_node_delete_test.go +++ b/shortcuts/wiki/wiki_node_delete_test.go @@ -9,17 +9,17 @@ import ( "encoding/json" "errors" "net/http" - "reflect" "strings" "sync" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -357,63 +357,55 @@ func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) { func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) { t.Parallel() - in := &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "api_error", - Code: wikiDeleteNodeErrCodeApprovalRequired, - Message: "node requires delete approval", - }, - } + in := errclass.BuildAPIError( + map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"}, + errclass.ClassifyContext{}, + ) got := wrapWikiNodeDeleteAPIError(in) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError, got %T %v", got, got) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T %v", got, got) } - if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") { - t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") { + t.Fatalf("hint = %q, want approval guidance", p.Hint) } // Original code/message must be preserved so logs and dashboards still // pivot on the upstream error code. - if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired { - t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code) + if p.Code != wikiDeleteNodeErrCodeApprovalRequired { + t.Fatalf("hint wrapper lost the original code: %d", p.Code) } - if exitErr.Detail.Message != "node requires delete approval" { - t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message) + if p.Message != "node requires delete approval" { + t.Fatalf("message changed unexpectedly: %q", p.Message) } } func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) { t.Parallel() - in := &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "api_error", - Code: wikiDeleteNodeErrCodeSubtreeTooLarge, - Message: "subtree too large", - }, - } + in := errclass.BuildAPIError( + map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"}, + errclass.ClassifyContext{}, + ) got := wrapWikiNodeDeleteAPIError(in) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T %v", got, got) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T %v", got, got) } - if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") { - t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "--include-children=false") { + t.Fatalf("hint = %q, want subtree-too-large guidance", p.Hint) } } func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) { t.Parallel() - in := &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"}, - } + in := errclass.BuildAPIError( + map[string]any{"code": float64(131005), "msg": "node not found"}, + errclass.ClassifyContext{}, + ) got := wrapWikiNodeDeleteAPIError(in) - if !reflect.DeepEqual(got, in) { - t.Fatalf("unknown code should pass through; got %#v", got) + if got != in { + t.Fatalf("unknown code should pass through unchanged; got %#v", got) } } diff --git a/shortcuts/wiki/wiki_node_get.go b/shortcuts/wiki/wiki_node_get.go index b7c2d6b97..8597fb3f5 100644 --- a/shortcuts/wiki/wiki_node_get.go +++ b/shortcuts/wiki/wiki_node_get.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -98,7 +98,7 @@ var WikiNodeGet = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token)) - data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil) + data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil) if err != nil { return err } @@ -109,10 +109,10 @@ var WikiNodeGet = common.Shortcut{ } if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID { - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id %q does not match the resolved node space %q (node_token=%s)", spec.SpaceID, node.SpaceID, node.NodeToken, - ) + ).WithParam("--space-id") } if spec.SpaceID != "" && node.SpaceID == "" { // The cross-check was requested but get_node returned no space_id, @@ -178,8 +178,8 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) { legacy := strings.TrimSpace(legacyToken) switch { case canonical != "" && legacy != "" && canonical != legacy: - return "", output.ErrValidation( - "--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)").WithParam("--token") case canonical != "": return nodeToken, nil default: @@ -193,7 +193,7 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) { func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) { tokenInput := strings.TrimSpace(rawToken) if tokenInput == "" { - return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required") + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token") } spec := wikiNodeGetSpec{ @@ -204,14 +204,14 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS if strings.Contains(tokenInput, "://") { u, err := url.Parse(tokenInput) if err != nil || u.Path == "" { - return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput) + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token") } token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path) if !ok { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token", u.Path, - ) + ).WithParam("--node-token") } spec.Token = token if urlObjType == "" { @@ -223,16 +223,16 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS case spec.ObjType == "" && urlObjType != "": spec.ObjType = urlObjType case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType: - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type %q does not match the obj_type %q implied by the URL path; pass only one", spec.ObjType, urlObjType, - ) + ).WithParam("--obj-type") } } else if strings.ContainsAny(tokenInput, "/?#") { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token must be a raw token or a full URL; partial paths are not accepted: %q", tokenInput, - ) + ).WithParam("--node-token") } else { spec.Token = tokenInput if looksLikeWikiNodeToken(spec.Token) { @@ -241,10 +241,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS // than silently passing it (the API would just ignore it, but the // mismatch signals caller confusion). if spec.ObjType != "" { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is only valid for obj_tokens; %q looks like a node_token", spec.Token, - ) + ).WithParam("--obj-type") } } else { spec.SourceKind = "raw-obj" @@ -253,10 +253,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS // sheet / bitable / ... Fail fast with the same upfront contract // as +node-delete instead of deferring to an opaque API error. if spec.ObjType == "" { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/) so it can be inferred", spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "), - ) + ).WithParam("--obj-type") } } } diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go index c743f2320..35032df5c 100644 --- a/shortcuts/wiki/wiki_node_list.go +++ b/shortcuts/wiki/wiki_node_list.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -53,7 +54,7 @@ var WikiNodeList = common.Shortcut{ // hint instead of deferring to API-time errors. Matches the contract // used by +node-create and +move. if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id") } if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil { return err @@ -150,7 +151,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin if pageToken != "" { params["page_token"] = pageToken } - data, err := runtime.CallAPI("GET", apiPath, params, nil) + data, err := runtime.CallAPITyped("GET", apiPath, params, nil) if err != nil { return nil, false, "", err } diff --git a/shortcuts/wiki/wiki_space_create.go b/shortcuts/wiki/wiki_space_create.go index 0b963b02c..30e62ab76 100644 --- a/shortcuts/wiki/wiki_space_create.go +++ b/shortcuts/wiki/wiki_space_create.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -60,14 +60,14 @@ var WikiSpaceCreate = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name) - data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody()) + data, err := runtime.CallAPITyped("POST", wikiSpacesAPIPath, nil, spec.RequestBody()) if err != nil { return err } raw := common.GetMap(data, "space") if raw == nil { - return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space create returned no space") } out := wikiSpaceCreateOutput(raw) @@ -100,7 +100,7 @@ func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpe Description: strings.TrimSpace(runtime.Str("description")), } if spec.Name == "" { - return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank") + return wikiSpaceCreateSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required and cannot be blank").WithParam("--name") } return spec, nil } diff --git a/shortcuts/wiki/wiki_space_list.go b/shortcuts/wiki/wiki_space_list.go index 77b724599..df752d253 100644 --- a/shortcuts/wiki/wiki_space_list.go +++ b/shortcuts/wiki/wiki_space_list.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -103,7 +104,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{}, if pageToken != "" { params["page_token"] = pageToken } - data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil) + data, err := runtime.CallAPITyped("GET", wikiSpacesAPIPath, params, nil) if err != nil { return nil, false, "", err } @@ -181,10 +182,10 @@ func valueOrDash(v interface{}) string { // +space-list and +node-list. func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error { if n := runtime.Int("page-size"); n < 1 || n > maxPageSize { - return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and %d", maxPageSize).WithParam("--page-size") } if n := runtime.Int("page-limit"); n < 0 { - return common.FlagErrorf("--page-limit must be a non-negative integer") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit") } return nil }