From e0f827f026067084b83accfcc1adbc65fb168436 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 25 Jun 2026 17:28:21 -0700 Subject: [PATCH] Add structured error details to all invalid_arguments errors Every invalidArgumentsError now carries argument/flag/value metadata so JSON consumers can programmatically identify which input was rejected. Adds detail helper factories (argumentErrorDetails, flagErrorDetails, flagValueErrorDetails, pathErrorDetails) and extracts api_endpoint from Dropbox SDK error messages. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- cmd/account.go | 2 +- cmd/add-member.go | 2 +- cmd/cp.go | 2 +- cmd/get.go | 12 +++--- cmd/mkdir.go | 2 +- cmd/mv.go | 2 +- cmd/output.go | 60 +++++++++++++++++++++++---- cmd/output_test.go | 52 ++++++++++++++++++++++- cmd/put.go | 16 +++---- cmd/remove-member.go | 2 +- cmd/restore.go | 2 +- cmd/revs.go | 2 +- cmd/rm.go | 4 +- cmd/search.go | 4 +- cmd/share-list-links.go | 2 +- cmd/share_link_create.go | 16 +++---- cmd/share_link_download.go | 20 ++++----- cmd/share_link_info.go | 6 +-- cmd/share_link_password.go | 4 +- cmd/share_link_revoke.go | 10 ++--- cmd/share_link_update.go | 14 +++---- cmd/team.go | 2 +- docs/json-schema/v1/README.md | 5 ++- docs/json-schema/v1/error.schema.json | 2 +- 25 files changed, 171 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 88e3a1f..b910864 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ JSON error responses use stable `error.code` values: | `unknown_flag` | Cobra could not resolve a flag. | | `command_failed` | Fallback for failures without a more specific stable code. | -JSON errors may also include an optional `error.details` object when dbxcli has reliable machine-readable context. Current detail keys include `path` for known path conflicts; `token_type`, `login_command`, and `env_var` for auth remediation; and `api_summary` for Dropbox API errors. Generic local failures omit `error.details`. +JSON errors may also include an optional `error.details` object when dbxcli has reliable machine-readable context. Current detail keys include `argument`, `arguments`, `flag`, `flags`, and `value` for dbxcli-owned validation errors; `path` for known path conflicts; `token_type`, `login_command`, and `env_var` for auth remediation; and `api_summary` and `api_endpoint` for Dropbox API errors. Generic local failures omit `error.details`. Successful JSON responses for migrated commands return `ok: true`, `schema_version: "1"`, `command`, an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. Public top-level schemas and the command contract catalog live under [docs/json-schema/v1](docs/json-schema/v1/). If a multi-target or recursive command fails after some side effects have already happened, dbxcli returns a JSON error envelope and does not include partial success results. For commands such as `mkdir`, each result reports what happened to the requested path: diff --git a/cmd/account.go b/cmd/account.go index c5e23ed..b345189 100644 --- a/cmd/account.go +++ b/cmd/account.go @@ -104,7 +104,7 @@ func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error { func account(cmd *cobra.Command, args []string) error { if len(args) > 1 { - return invalidArgumentsError("`account` accepts an optional `id` argument") + return invalidArgumentsErrorWithDetails("`account` accepts an optional `id` argument", argumentErrorDetails("id")) } dbx := usersNewFunc(config) diff --git a/cmd/add-member.go b/cmd/add-member.go index fdd6693..a4b404d 100644 --- a/cmd/add-member.go +++ b/cmd/add-member.go @@ -24,7 +24,7 @@ import ( func addMember(cmd *cobra.Command, args []string) (err error) { if len(args) != 3 { - return invalidArgumentsError("`add-member` requires `email`, `first`, and `last` arguments") + return invalidArgumentsErrorWithDetails("`add-member` requires `email`, `first`, and `last` arguments", argumentsErrorDetails("email", "first", "last")) } dbx := teamNewFunc(config) diff --git a/cmd/cp.go b/cmd/cp.go index c2c4441..c92ede9 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -34,7 +34,7 @@ func cp(cmd *cobra.Command, args []string) error { destination = args[1] argsToCopy = append(argsToCopy, args[0]) } else { - return invalidArgumentsError("cp requires a source and a destination") + return invalidArgumentsErrorWithDetails("cp requires a source and a destination", argumentsErrorDetails("source", "destination")) } var cpErrors []error diff --git a/cmd/get.go b/cmd/get.go index 9bb1ab6..b8c5081 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -65,7 +65,7 @@ type getResult struct { func get(cmd *cobra.Command, args []string) (err error) { if len(args) == 0 || len(args) > 2 { - return invalidArgumentsError("`get` requires `src` and/or `dst` arguments") + return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst")) } src, err := validatePath(args[0]) @@ -83,7 +83,7 @@ func get(cmd *cobra.Command, args []string) (err error) { if dst == "-" { if commandOutputFormat(cmd) == output.FormatJSON { - return invalidArgumentsError("`get --output=json` cannot be used with stdout target `-`") + return invalidArgumentsErrorWithDetails("`get --output=json` cannot be used with stdout target `-`", mergeJSONErrorDetails(argumentErrorDetails("dst"), flagErrorDetails("output"))) } return getStdout(cmd, src, recursive) } @@ -113,7 +113,7 @@ func get(cmd *cobra.Command, args []string) (err error) { if _, ok := meta.(*files.FolderMetadata); ok { if !recursive { - return invalidArgumentsErrorf("%s is a folder (use --recursive to download folders)", src) + return invalidArgumentsErrorfWithDetails("%s is a folder (use --recursive to download folders)", pathErrorDetails(src), src) } if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() { dst = filepath.Join(dst, path.Base(src)) @@ -199,7 +199,7 @@ func getOperationResults(results []getResult) []jsonOperationResult { func getStdout(cmd *cobra.Command, src string, recursive bool) error { if recursive { - return invalidArgumentsError("`get -` cannot be used with --recursive") + return invalidArgumentsErrorWithDetails("`get -` cannot be used with --recursive", flagErrorDetails("recursive")) } dbx := filesNewFunc(config) @@ -207,7 +207,7 @@ func getStdout(cmd *cobra.Command, src string, recursive bool) error { meta, err := dbx.GetMetadata(files.NewGetMetadataArg(src)) if err == nil { if _, ok := meta.(*files.FolderMetadata); ok { - return invalidArgumentsErrorf("%s is a folder; cannot download folder to stdout", src) + return invalidArgumentsErrorfWithDetails("%s is a folder; cannot download folder to stdout", pathErrorDetails(src), src) } } @@ -216,7 +216,7 @@ func getStdout(cmd *cobra.Command, src string, recursive bool) error { func getWithClient(dbx files.Client, args []string) (err error) { if len(args) == 0 || len(args) > 2 { - return invalidArgumentsError("`get` requires `src` and/or `dst` arguments") + return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst")) } src, err := validatePath(args[0]) diff --git a/cmd/mkdir.go b/cmd/mkdir.go index be21b08..3466b11 100644 --- a/cmd/mkdir.go +++ b/cmd/mkdir.go @@ -43,7 +43,7 @@ type mkdirResult struct { func mkdir(cmd *cobra.Command, args []string) (err error) { if len(args) != 1 { - return invalidArgumentsError("`mkdir` requires a `directory` argument") + return invalidArgumentsErrorWithDetails("`mkdir` requires a `directory` argument", argumentErrorDetails("directory")) } dst, err := validatePath(args[0]) diff --git a/cmd/mv.go b/cmd/mv.go index 6ae65a3..0a7fa52 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -34,7 +34,7 @@ func mv(cmd *cobra.Command, args []string) error { destination = args[1] argsToMove = append(argsToMove, args[0]) } else { - return invalidArgumentsError("mv command requires a source and a destination") + return invalidArgumentsErrorWithDetails("mv command requires a source and a destination", argumentsErrorDetails("source", "destination")) } var mvErrors []error diff --git a/cmd/output.go b/cmd/output.go index d231fc5..08700d3 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -75,18 +75,16 @@ func newCodedError(code string, err error, details ...map[string]any) error { } } -func invalidArgumentsError(message string) error { - return newCodedError(jsonErrorCodeInvalidArguments, errors.New(message)) +func invalidArgumentsErrorWithDetails(message string, details map[string]any) error { + return newCodedError(jsonErrorCodeInvalidArguments, errors.New(message), details) } -func invalidArgumentsErrorf(format string, args ...any) error { - return newCodedError(jsonErrorCodeInvalidArguments, fmt.Errorf(format, args...)) +func invalidArgumentsErrorfWithDetails(format string, details map[string]any, args ...any) error { + return newCodedError(jsonErrorCodeInvalidArguments, fmt.Errorf(format, args...), details) } func pathConflictErrorWithPath(path string, format string, args ...any) error { - return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...), map[string]any{ - "path": path, - }) + return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...), pathErrorDetails(path)) } func authRequiredErrorf(format string, args ...any) error { @@ -129,6 +127,33 @@ func authRefreshFailedErrorfWithDetails(format string, details map[string]any, a return newCodedError(jsonErrorCodeAuthRefreshFailed, fmt.Errorf(format, args...), details) } +func argumentErrorDetails(argument string) map[string]any { + return map[string]any{"argument": argument} +} + +func argumentsErrorDetails(arguments ...string) map[string]any { + return map[string]any{"arguments": arguments} +} + +func flagErrorDetails(flag string) map[string]any { + return map[string]any{"flag": flag} +} + +func flagsErrorDetails(flags ...string) map[string]any { + return map[string]any{"flags": flags} +} + +func flagValueErrorDetails(flag, value string) map[string]any { + return map[string]any{ + "flag": flag, + "value": value, + } +} + +func pathErrorDetails(path string) map[string]any { + return map[string]any{"path": path} +} + func commandOutput(cmd *cobra.Command) *output.Renderer { if cmd == nil { return output.New(nil, nil, output.FormatText) @@ -319,6 +344,9 @@ func jsonErrorDetails(err error) map[string]any { } else if summary, ok := dropboxAPISummaryFromMessage(err.Error()); ok { details["api_summary"] = summary } + if endpoint, ok := dropboxAPIEndpointFromMessage(err.Error()); ok { + details["api_endpoint"] = endpoint + } if len(details) == 0 { return nil @@ -471,6 +499,24 @@ func dropboxAPISummaryFromMessage(message string) (string, bool) { return "", false } +func dropboxAPIEndpointFromMessage(message string) (string, bool) { + const prefix = `Error in call to API function "` + idx := strings.Index(message, prefix) + if idx < 0 { + return "", false + } + start := idx + len(prefix) + end := strings.Index(message[start:], `"`) + if end < 0 { + return "", false + } + end += start + if start == end { + return "", false + } + return message[start:end], true +} + func isDropboxAPISummary(message string) bool { if message == "" || strings.ContainsAny(message, " \t\r\n\"") || !strings.Contains(message, "/") { return false diff --git a/cmd/output_test.go b/cmd/output_test.go index 8b24e44..930578e 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -341,6 +341,28 @@ func TestRenderCommandErrorIncludesCodedDetails(t *testing.T) { } } +func TestRenderCommandErrorIncludesArgumentAndFlagDetails(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{Use: "put"} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "json", "") + + renderCommandError(cmd, invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, or fail)", flagValueErrorDetails("if-exists", "replace"), "replace")) + + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + got := decodeJSONErrorResponse(t, stdout.String()) + if got.Error.Code != jsonErrorCodeInvalidArguments { + t.Fatalf("code = %q, want %q", got.Error.Code, jsonErrorCodeInvalidArguments) + } + if got.Error.Details["flag"] != "if-exists" || got.Error.Details["value"] != "replace" { + t.Fatalf("details = %+v, want flag/value", got.Error.Details) + } +} + func TestRenderCommandErrorIncludesDropboxAPISummaryDetails(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -364,6 +386,32 @@ func TestRenderCommandErrorIncludesDropboxAPISummaryDetails(t *testing.T) { } } +func TestRenderCommandErrorIncludesDropboxAPIEndpointDetails(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{Use: "ls"} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "json", "") + + err := errors.New(`Error in call to API function "files/list_folder": path/not_found/.`) + renderCommandError(cmd, err) + + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + got := decodeJSONErrorResponse(t, stdout.String()) + if got.Error.Code != jsonErrorCodeNotFound { + t.Fatalf("code = %q, want %q", got.Error.Code, jsonErrorCodeNotFound) + } + if got.Error.Details["api_endpoint"] != "files/list_folder" { + t.Fatalf("details = %+v, want api_endpoint", got.Error.Details) + } + if got.Error.Details["api_summary"] != `Error in call to API function "files/list_folder": path/not_found/.` { + t.Fatalf("details = %+v, want api_summary", got.Error.Details) + } +} + func TestJSONErrorDetailsIncludesAuthRemediation(t *testing.T) { got := newJSONErrorResponse(&cobra.Command{Use: "account"}, missingAccessTokenError(tokenPersonal)) @@ -694,12 +742,12 @@ func TestJSONErrorCodeUsesCodedErrors(t *testing.T) { }, { name: "optional argument validation", - err: invalidArgumentsError("`account` accepts an optional `id` argument"), + err: invalidArgumentsErrorWithDetails("`account` accepts an optional `id` argument", argumentErrorDetails("id")), want: jsonErrorCodeInvalidArguments, }, { name: "required argument validation", - err: invalidArgumentsError("`add-member` requires `email`, `first`, and `last` arguments"), + err: invalidArgumentsErrorWithDetails("`add-member` requires `email`, `first`, and `last` arguments", argumentsErrorDetails("email", "first", "last")), want: jsonErrorCodeInvalidArguments, }, { diff --git a/cmd/put.go b/cmd/put.go index d45801a..b52fc46 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -284,7 +284,7 @@ func putOperationResults(results []putResult) []jsonOperationResult { func put(cmd *cobra.Command, args []string) (err error) { if len(args) == 0 || len(args) > 2 { - return invalidArgumentsError("`put` requires `src` and/or `dst` arguments") + return invalidArgumentsErrorWithDetails("`put` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst")) } opts, err := parsePutOptions(cmd) @@ -306,7 +306,7 @@ func put(cmd *cobra.Command, args []string) (err error) { } if srcInfo.IsDir() && !recursive { - return invalidArgumentsErrorf("%s is a directory (use --recursive to upload directories)", src) + return invalidArgumentsErrorfWithDetails("%s is a directory (use --recursive to upload directories)", pathErrorDetails(src), src) } // Default `dst` to the base segment of the source path; use the second argument if provided. @@ -356,15 +356,15 @@ func put(cmd *cobra.Command, args []string) (err error) { func putStdin(cmd *cobra.Command, args []string, opts putOptions, recursive bool) error { if len(args) < 2 { - return invalidArgumentsError("`put -` requires an explicit target path") + return invalidArgumentsErrorWithDetails("`put -` requires an explicit target path", argumentErrorDetails("dst")) } if recursive { - return invalidArgumentsError("`put -` cannot be used with --recursive") + return invalidArgumentsErrorWithDetails("`put -` cannot be used with --recursive", flagErrorDetails("recursive")) } dst := args[1] if strings.HasSuffix(dst, "/") { - return invalidArgumentsErrorf("cannot upload stdin to directory target %q; provide a full Dropbox file path", dst) + return invalidArgumentsErrorfWithDetails("cannot upload stdin to directory target %q; provide a full Dropbox file path", pathErrorDetails(dst), dst) } dstPath, err := validatePath(dst) @@ -446,7 +446,7 @@ func parsePutOptions(cmd *cobra.Command) (putOptions, error) { return putOptions{}, err } if chunkSize%(1<<22) != 0 { - return putOptions{}, invalidArgumentsError("`put` requires chunk size to be multiple of 4MiB") + return putOptions{}, invalidArgumentsErrorWithDetails("`put` requires chunk size to be multiple of 4MiB", flagErrorDetails("chunksize")) } workers, err := cmd.Flags().GetInt("workers") if err != nil { @@ -486,7 +486,7 @@ func normalizePutIfExists(ifExists string) (string, error) { case putIfExistsOverwrite, putIfExistsSkip, putIfExistsFail: return ifExists, nil default: - return "", invalidArgumentsErrorf("invalid --if-exists %q (use overwrite, skip, or fail)", ifExists) + return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, or fail)", flagValueErrorDetails("if-exists", ifExists), ifExists) } } @@ -578,7 +578,7 @@ func checkPutStdinDestination(dbx files.Client, dst string, ifExists string) (pu return putDestinationUpload, nil, nil } if _, ok := meta.(*files.FolderMetadata); ok { - return putDestinationUpload, nil, invalidArgumentsErrorf("cannot upload stdin to folder %q; provide a full Dropbox file path", dst) + return putDestinationUpload, nil, invalidArgumentsErrorfWithDetails("cannot upload stdin to folder %q; provide a full Dropbox file path", pathErrorDetails(dst), dst) } return actionForExistingDestination(dst, ifExists, meta) } diff --git a/cmd/remove-member.go b/cmd/remove-member.go index 98c2db7..21a84eb 100644 --- a/cmd/remove-member.go +++ b/cmd/remove-member.go @@ -25,7 +25,7 @@ import ( func removeMember(cmd *cobra.Command, args []string) (err error) { if len(args) != 1 { - return invalidArgumentsError("`remove-member` requires an `email` argument") + return invalidArgumentsErrorWithDetails("`remove-member` requires an `email` argument", argumentErrorDetails("email")) } dbx := teamNewFunc(config) diff --git a/cmd/restore.go b/cmd/restore.go index c34ecbd..6bdfd6c 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -42,7 +42,7 @@ type restoreResult struct { func restore(cmd *cobra.Command, args []string) (err error) { if len(args) != 2 { - return invalidArgumentsError("`restore` requires `target-path` and `revision` arguments") + return invalidArgumentsErrorWithDetails("`restore` requires `target-path` and `revision` arguments", argumentsErrorDetails("target-path", "revision")) } path, err := validatePath(args[0]) diff --git a/cmd/revs.go b/cmd/revs.go index 4583b61..519f4e0 100644 --- a/cmd/revs.go +++ b/cmd/revs.go @@ -35,7 +35,7 @@ const revsJSONStatusRevision = "revision" func revs(cmd *cobra.Command, args []string) (err error) { if len(args) != 1 { - return invalidArgumentsError("`revs` requires a `file` argument") + return invalidArgumentsErrorWithDetails("`revs` requires a `file` argument", argumentErrorDetails("file")) } path, err := validatePath(args[0]) diff --git a/cmd/rm.go b/cmd/rm.go index 6850110..1450da4 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -54,7 +54,7 @@ const ( func rm(cmd *cobra.Command, args []string) error { if len(args) < 1 { - return invalidArgumentsError("rm: missing operand") + return invalidArgumentsErrorWithDetails("rm: missing operand", argumentErrorDetails("path")) } opts, err := parseRemoveOptions(cmd) @@ -145,7 +145,7 @@ func validateRemoveTargets(dbx files.Client, args []string, opts removeOptions) return nil, err } if len(res.Entries) != 0 { - return nil, invalidArgumentsErrorf("rm: cannot remove ā€˜%s’: Directory not empty, use `--force`/`-f` or `--recursive`/`-r` to proceed", path) + return nil, invalidArgumentsErrorfWithDetails("rm: cannot remove ā€˜%s’: Directory not empty, use `--force`/`-f` or `--recursive`/`-r` to proceed", pathErrorDetails(path), path) } } targets = append(targets, removeTarget{path: path, metadata: pathMetaData}) diff --git a/cmd/search.go b/cmd/search.go index 576d90d..919bf6e 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -39,14 +39,14 @@ const searchJSONStatusFound = "found" func search(cmd *cobra.Command, args []string) (err error) { if len(args) == 0 { - return invalidArgumentsError("`search` requires a `query` argument") + return invalidArgumentsErrorWithDetails("`search` requires a `query` argument", argumentErrorDetails("query")) } var scope string if len(args) == 2 { scope = args[1] if !strings.HasPrefix(scope, "/") { - return invalidArgumentsError("`search` `path-scope` must begin with \"/\"") + return invalidArgumentsErrorWithDetails("`search` `path-scope` must begin with \"/\"", argumentErrorDetails("path-scope")) } } diff --git a/cmd/share-list-links.go b/cmd/share-list-links.go index 063e53d..33122c8 100644 --- a/cmd/share-list-links.go +++ b/cmd/share-list-links.go @@ -41,7 +41,7 @@ func shareLinkList(cmd *cobra.Command, args []string) error { func shareLinkListWithWarnings(cmd *cobra.Command, args []string, warnings []jsonWarning) error { if len(args) > 1 { - return invalidArgumentsError("`share-link list` accepts at most one `path` argument") + return invalidArgumentsErrorWithDetails("`share-link list` accepts at most one `path` argument", argumentErrorDetails("path")) } arg := sharing.NewListSharedLinksArg() diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 938aa9d..8a8007c 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -49,7 +49,7 @@ type shareLinkCreateInput struct { func shareLinkCreate(cmd *cobra.Command, args []string) error { if len(args) != 1 { - return invalidArgumentsError("`share-link create` requires a `path` argument") + return invalidArgumentsErrorWithDetails("`share-link create` requires a `path` argument", argumentErrorDetails("path")) } path, err := validatePath(args[0]) @@ -57,7 +57,7 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { return err } if path == "" { - return invalidArgumentsError("cannot create a shared link for Dropbox root") + return invalidArgumentsErrorWithDetails("cannot create a shared link for Dropbox root", pathErrorDetails("/")) } opts, err := parseShareLinkCreateOptions(cmd) @@ -204,10 +204,10 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er opts.password = password if opts.expires != nil && opts.removeExpiration { - return opts, invalidArgumentsError("`--expires` and `--remove-expiration` cannot be used together") + return opts, invalidArgumentsErrorWithDetails("`--expires` and `--remove-expiration` cannot be used together", flagsErrorDetails("expires", "remove-expiration")) } if opts.allowDownload && opts.disallowDownload { - return opts, invalidArgumentsError("`--allow-download` and `--disallow-download` cannot be used together") + return opts, invalidArgumentsErrorWithDetails("`--allow-download` and `--disallow-download` cannot be used together", flagsErrorDetails("allow-download", "disallow-download")) } return opts, nil @@ -215,7 +215,7 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsSharedLinkMetadata, opts shareLinkCreateOptions) (sharing.IsSharedLinkMetadata, error) { if opts.access != nil { - return nil, invalidArgumentsError("cannot apply `--access` because the shared link already exists") + return nil, invalidArgumentsErrorWithDetails("cannot apply `--access` because the shared link already exists", flagErrorDetails("access")) } if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload && !opts.disallowDownload && opts.audience == nil && !opts.password.set { return link, nil @@ -410,7 +410,7 @@ func shareLinkExpiresFlag(cmd *cobra.Command) (*time.Time, error) { } parsed, err := time.Parse(time.RFC3339, value) if err != nil { - return nil, invalidArgumentsErrorf("invalid --expires %q: use RFC3339 timestamp", value) + return nil, invalidArgumentsErrorfWithDetails("invalid --expires %q: use RFC3339 timestamp", flagValueErrorDetails("expires", value), value) } return &parsed, nil } @@ -428,7 +428,7 @@ func shareLinkAccessFlag(cmd *cobra.Command) (*sharing.RequestedLinkAccessLevel, case sharing.RequestedLinkAccessLevelMax: return requestedLinkAccessLevel(sharing.RequestedLinkAccessLevelMax), nil default: - return nil, invalidArgumentsErrorf("invalid --access %q: use viewer, editor, or max", value) + return nil, invalidArgumentsErrorfWithDetails("invalid --access %q: use viewer, editor, or max", flagValueErrorDetails("access", value), value) } } @@ -451,7 +451,7 @@ func shareLinkAudienceFlag(cmd *cobra.Command) (*sharing.LinkAudience, error) { case "no-one": return linkAudience(sharing.LinkAudienceNoOne), nil default: - return nil, invalidArgumentsErrorf("invalid --audience %q: use public, team, members, or no-one", value) + return nil, invalidArgumentsErrorfWithDetails("invalid --audience %q: use public, team, members, or no-one", flagValueErrorDetails("audience", value), value) } } diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index 2d0a007..6a75cb3 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -52,19 +52,19 @@ type shareLinkDownloadResult struct { func shareLinkDownload(cmd *cobra.Command, args []string) error { if len(args) == 0 || len(args) > 2 { - return invalidArgumentsError("`share-link download` requires a `url` and optional `target` argument") + return invalidArgumentsErrorWithDetails("`share-link download` requires a `url` and optional `target` argument", argumentsErrorDetails("url", "target")) } url := args[0] if url == "" { - return invalidArgumentsError("`share-link download` requires a non-empty URL") + return invalidArgumentsErrorWithDetails("`share-link download` requires a non-empty URL", argumentErrorDetails("url")) } target := "" if len(args) == 2 { target = args[1] if target == "" { - return invalidArgumentsError("`share-link download` requires a non-empty target") + return invalidArgumentsErrorWithDetails("`share-link download` requires a non-empty target", argumentErrorDetails("target")) } } @@ -77,7 +77,7 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { arg.LinkPassword = opts.password.password } if target == "-" && commandOutputFormat(cmd) == output.FormatJSON { - return invalidArgumentsError("`share-link download -` cannot be used with --output=json") + return invalidArgumentsErrorWithDetails("`share-link download -` cannot be used with --output=json", mergeJSONErrorDetails(argumentErrorDetails("target"), flagErrorDetails("output"))) } dbx := newSharedLinkClient(config) @@ -93,10 +93,10 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { if folder, ok := link.(*sharing.FolderLinkMetadata); ok { if !opts.recursive { - return invalidArgumentsError("shared link is a folder (use --recursive to download folders)") + return invalidArgumentsErrorWithDetails("shared link is a folder (use --recursive to download folders)", flagErrorDetails("recursive")) } if target == "-" { - return invalidArgumentsError("cannot download shared-link folder to stdout") + return invalidArgumentsErrorWithDetails("cannot download shared-link folder to stdout", argumentErrorDetails("target")) } dst, err := sharedLinkFolderDownloadTarget(target, folder) @@ -112,7 +112,7 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { if target == "-" { if opts.recursive { - return invalidArgumentsError("`share-link download -` cannot be used with --recursive") + return invalidArgumentsErrorWithDetails("`share-link download -` cannot be used with --recursive", flagErrorDetails("recursive")) } if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil { return err @@ -150,20 +150,20 @@ func parseShareLinkDownloadOptions(cmd *cobra.Command) (shareLinkDownloadOptions return opts, err } if pathArg == "" { - return opts, invalidArgumentsError("`--path` requires a non-empty path") + return opts, invalidArgumentsErrorWithDetails("`--path` requires a non-empty path", flagErrorDetails("path")) } path, err := validatePath(pathArg) if err != nil { return opts, err } if path == "" { - return opts, invalidArgumentsError("cannot download shared-link root with `--path`") + return opts, invalidArgumentsErrorWithDetails("cannot download shared-link root with `--path`", pathErrorDetails("/")) } opts.path = path } if opts.path != "" && opts.recursive { - return opts, invalidArgumentsError("`--path` cannot be used with --recursive") + return opts, invalidArgumentsErrorWithDetails("`--path` cannot be used with --recursive", flagsErrorDetails("path", "recursive")) } return opts, nil diff --git a/cmd/share_link_info.go b/cmd/share_link_info.go index 453cef8..06d2615 100644 --- a/cmd/share_link_info.go +++ b/cmd/share_link_info.go @@ -39,12 +39,12 @@ type shareLinkInfoInput struct { func shareLinkInfo(cmd *cobra.Command, args []string) error { if len(args) != 1 { - return invalidArgumentsError("`share-link info` requires a `url` argument") + return invalidArgumentsErrorWithDetails("`share-link info` requires a `url` argument", argumentErrorDetails("url")) } url := args[0] if url == "" { - return invalidArgumentsError("`share-link info` requires a non-empty URL") + return invalidArgumentsErrorWithDetails("`share-link info` requires a non-empty URL", argumentErrorDetails("url")) } opts, err := parseShareLinkInfoOptions(cmd) @@ -94,7 +94,7 @@ func parseShareLinkInfoOptions(cmd *cobra.Command) (shareLinkInfoOptions, error) return opts, err } if path == "" { - return opts, invalidArgumentsError("`--path` requires a non-empty path") + return opts, invalidArgumentsErrorWithDetails("`--path` requires a non-empty path", flagErrorDetails("path")) } opts.path = path } diff --git a/cmd/share_link_password.go b/cmd/share_link_password.go index 32a86b0..d59e8ed 100644 --- a/cmd/share_link_password.go +++ b/cmd/share_link_password.go @@ -65,7 +65,7 @@ func sharedLinkPasswordFromFlags(cmd *cobra.Command) (sharedLinkPasswordOptions, return sharedLinkPasswordOptions{}, nil } if sourceCount > 1 { - return sharedLinkPasswordOptions{}, invalidArgumentsError("use only one of `--password`, `--password-prompt`, or `--password-file`") + return sharedLinkPasswordOptions{}, invalidArgumentsErrorWithDetails("use only one of `--password`, `--password-prompt`, or `--password-file`", flagsErrorDetails("password", "password-prompt", "password-file")) } var password string @@ -81,7 +81,7 @@ func sharedLinkPasswordFromFlags(cmd *cobra.Command) (sharedLinkPasswordOptions, return sharedLinkPasswordOptions{}, err } if password == "" { - return sharedLinkPasswordOptions{}, invalidArgumentsError("shared link password cannot be empty") + return sharedLinkPasswordOptions{}, invalidArgumentsErrorWithDetails("shared link password cannot be empty", flagsErrorDetails("password", "password-prompt", "password-file")) } return sharedLinkPasswordOptions{ diff --git a/cmd/share_link_revoke.go b/cmd/share_link_revoke.go index 65b0994..db91383 100644 --- a/cmd/share_link_revoke.go +++ b/cmd/share_link_revoke.go @@ -51,12 +51,12 @@ func shareLinkRevoke(cmd *cobra.Command, args []string) error { } if len(args) != 1 { - return invalidArgumentsError("`share-link revoke` requires a `url` argument") + return invalidArgumentsErrorWithDetails("`share-link revoke` requires a `url` argument", argumentErrorDetails("url")) } url := args[0] if url == "" { - return invalidArgumentsError("`share-link revoke` requires a non-empty URL") + return invalidArgumentsErrorWithDetails("`share-link revoke` requires a non-empty URL", argumentErrorDetails("url")) } dbx := newSharedLinkClient(config) @@ -92,7 +92,7 @@ func parseShareLinkRevokeOptions(cmd *cobra.Command, args []string) (shareLinkRe return opts, nil } if len(args) != 0 { - return opts, invalidArgumentsError("`--path` cannot be used with a shared link URL") + return opts, invalidArgumentsErrorWithDetails("`--path` cannot be used with a shared link URL", mergeJSONErrorDetails(flagErrorDetails("path"), argumentErrorDetails("url"))) } pathArg, err := localStringFlag(cmd, "path") @@ -100,7 +100,7 @@ func parseShareLinkRevokeOptions(cmd *cobra.Command, args []string) (shareLinkRe return opts, err } if pathArg == "" { - return opts, invalidArgumentsError("`--path` requires a non-empty path") + return opts, invalidArgumentsErrorWithDetails("`--path` requires a non-empty path", flagErrorDetails("path")) } path, err := validatePath(pathArg) @@ -108,7 +108,7 @@ func parseShareLinkRevokeOptions(cmd *cobra.Command, args []string) (shareLinkRe return opts, err } if path == "" { - return opts, invalidArgumentsError("cannot revoke shared links for Dropbox root") + return opts, invalidArgumentsErrorWithDetails("cannot revoke shared links for Dropbox root", pathErrorDetails("/")) } opts.path = path diff --git a/cmd/share_link_update.go b/cmd/share_link_update.go index b71e3af..5d28836 100644 --- a/cmd/share_link_update.go +++ b/cmd/share_link_update.go @@ -46,12 +46,12 @@ type shareLinkUpdateInput struct { func shareLinkUpdate(cmd *cobra.Command, args []string) error { if len(args) != 1 { - return invalidArgumentsError("`share-link update` requires a `url` argument") + return invalidArgumentsErrorWithDetails("`share-link update` requires a `url` argument", argumentErrorDetails("url")) } url := args[0] if url == "" { - return invalidArgumentsError("`share-link update` requires a non-empty URL") + return invalidArgumentsErrorWithDetails("`share-link update` requires a non-empty URL", argumentErrorDetails("url")) } opts, err := parseShareLinkUpdateOptions(cmd) @@ -170,16 +170,16 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er } if expiresChanged && removeExpiration { - return shareLinkUpdateOptions{}, invalidArgumentsError("`--expires` and `--remove-expiration` cannot be used together") + return shareLinkUpdateOptions{}, invalidArgumentsErrorWithDetails("`--expires` and `--remove-expiration` cannot be used together", flagsErrorDetails("expires", "remove-expiration")) } if allowDownload && disallowDownload { - return shareLinkUpdateOptions{}, invalidArgumentsError("`--allow-download` and `--disallow-download` cannot be used together") + return shareLinkUpdateOptions{}, invalidArgumentsErrorWithDetails("`--allow-download` and `--disallow-download` cannot be used together", flagsErrorDetails("allow-download", "disallow-download")) } if password.set && removePassword { - return shareLinkUpdateOptions{}, invalidArgumentsError("password-setting flags and `--remove-password` cannot be used together") + return shareLinkUpdateOptions{}, invalidArgumentsErrorWithDetails("password-setting flags and `--remove-password` cannot be used together", flagsErrorDetails("password", "password-prompt", "password-file", "remove-password")) } if !expiresChanged && !removeExpiration && !allowDownload && !disallowDownload && !audienceChanged && !password.set && !removePassword { - return shareLinkUpdateOptions{}, invalidArgumentsError("at least one shared link setting flag is required") + return shareLinkUpdateOptions{}, invalidArgumentsErrorWithDetails("at least one shared link setting flag is required", flagsErrorDetails("expires", "remove-expiration", "allow-download", "disallow-download", "audience", "password", "password-prompt", "password-file", "remove-password")) } var expires *time.Time @@ -190,7 +190,7 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er } parsed, err := time.Parse(time.RFC3339, value) if err != nil { - return shareLinkUpdateOptions{}, invalidArgumentsErrorf("invalid --expires %q: use RFC3339 timestamp", value) + return shareLinkUpdateOptions{}, invalidArgumentsErrorfWithDetails("invalid --expires %q: use RFC3339 timestamp", flagValueErrorDetails("expires", value), value) } expires = &parsed } diff --git a/cmd/team.go b/cmd/team.go index 52eda6d..6811e15 100644 --- a/cmd/team.go +++ b/cmd/team.go @@ -25,7 +25,7 @@ var teamCmd = &cobra.Command{ return err } if member, _ := cmd.Flags().GetString("as-member"); member != "" { - return invalidArgumentsError("Flag `as-member` is invalid for team sub-commands") + return invalidArgumentsErrorWithDetails("Flag `as-member` is invalid for team sub-commands", flagErrorDetails("as-member")) } return initDbx(cmd, args) }, diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index d795783..fbc2d6d 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -27,8 +27,9 @@ Error responses always include: - `error.message`: human-readable error text - `error.code`: stable machine-readable error code - `error.details`: optional machine-readable context, included only when - dbxcli has reliable structured details such as `path`, `token_type`, - `login_command`, `env_var`, or Dropbox `api_summary` + dbxcli has reliable structured details such as `argument`, `arguments`, + `flag`, `flags`, `value`, `path`, `token_type`, `login_command`, `env_var`, + Dropbox `api_summary`, or Dropbox `api_endpoint` - `warnings`: machine-actionable warnings, or `[]` Command-specific `input` and `result` payload contracts are listed in diff --git a/docs/json-schema/v1/error.schema.json b/docs/json-schema/v1/error.schema.json index f6b6eb3..947ea52 100644 --- a/docs/json-schema/v1/error.schema.json +++ b/docs/json-schema/v1/error.schema.json @@ -54,7 +54,7 @@ }, "details": { "type": "object", - "description": "Optional machine-readable context for stable error handling. Present only when dbxcli has reliable structured details.", + "description": "Optional machine-readable context for stable error handling. Common keys include argument, arguments, flag, flags, value, path, token_type, login_command, env_var, api_summary, and api_endpoint. Present only when dbxcli has reliable structured details.", "additionalProperties": true } }