Skip to content

Commit 9945981

Browse files
Merge pull request #292 from dropbox/codex/json-error-detail-producers
Add structured error details to all invalid_arguments errors
2 parents ed5fa89 + e0f827f commit 9945981

25 files changed

Lines changed: 171 additions & 76 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ JSON error responses use stable `error.code` values:
191191
| `unknown_flag` | Cobra could not resolve a flag. |
192192
| `command_failed` | Fallback for failures without a more specific stable code. |
193193

194-
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`.
194+
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`.
195195

196196
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:
197197

cmd/account.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error {
104104

105105
func account(cmd *cobra.Command, args []string) error {
106106
if len(args) > 1 {
107-
return invalidArgumentsError("`account` accepts an optional `id` argument")
107+
return invalidArgumentsErrorWithDetails("`account` accepts an optional `id` argument", argumentErrorDetails("id"))
108108
}
109109

110110
dbx := usersNewFunc(config)

cmd/add-member.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424

2525
func addMember(cmd *cobra.Command, args []string) (err error) {
2626
if len(args) != 3 {
27-
return invalidArgumentsError("`add-member` requires `email`, `first`, and `last` arguments")
27+
return invalidArgumentsErrorWithDetails("`add-member` requires `email`, `first`, and `last` arguments", argumentsErrorDetails("email", "first", "last"))
2828
}
2929
dbx := teamNewFunc(config)
3030

cmd/cp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func cp(cmd *cobra.Command, args []string) error {
3434
destination = args[1]
3535
argsToCopy = append(argsToCopy, args[0])
3636
} else {
37-
return invalidArgumentsError("cp requires a source and a destination")
37+
return invalidArgumentsErrorWithDetails("cp requires a source and a destination", argumentsErrorDetails("source", "destination"))
3838
}
3939

4040
var cpErrors []error

cmd/get.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ type getResult struct {
6565

6666
func get(cmd *cobra.Command, args []string) (err error) {
6767
if len(args) == 0 || len(args) > 2 {
68-
return invalidArgumentsError("`get` requires `src` and/or `dst` arguments")
68+
return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
6969
}
7070

7171
src, err := validatePath(args[0])
@@ -83,7 +83,7 @@ func get(cmd *cobra.Command, args []string) (err error) {
8383

8484
if dst == "-" {
8585
if commandOutputFormat(cmd) == output.FormatJSON {
86-
return invalidArgumentsError("`get --output=json` cannot be used with stdout target `-`")
86+
return invalidArgumentsErrorWithDetails("`get --output=json` cannot be used with stdout target `-`", mergeJSONErrorDetails(argumentErrorDetails("dst"), flagErrorDetails("output")))
8787
}
8888
return getStdout(cmd, src, recursive)
8989
}
@@ -113,7 +113,7 @@ func get(cmd *cobra.Command, args []string) (err error) {
113113

114114
if _, ok := meta.(*files.FolderMetadata); ok {
115115
if !recursive {
116-
return invalidArgumentsErrorf("%s is a folder (use --recursive to download folders)", src)
116+
return invalidArgumentsErrorfWithDetails("%s is a folder (use --recursive to download folders)", pathErrorDetails(src), src)
117117
}
118118
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
119119
dst = filepath.Join(dst, path.Base(src))
@@ -199,15 +199,15 @@ func getOperationResults(results []getResult) []jsonOperationResult {
199199

200200
func getStdout(cmd *cobra.Command, src string, recursive bool) error {
201201
if recursive {
202-
return invalidArgumentsError("`get -` cannot be used with --recursive")
202+
return invalidArgumentsErrorWithDetails("`get -` cannot be used with --recursive", flagErrorDetails("recursive"))
203203
}
204204

205205
dbx := filesNewFunc(config)
206206

207207
meta, err := dbx.GetMetadata(files.NewGetMetadataArg(src))
208208
if err == nil {
209209
if _, ok := meta.(*files.FolderMetadata); ok {
210-
return invalidArgumentsErrorf("%s is a folder; cannot download folder to stdout", src)
210+
return invalidArgumentsErrorfWithDetails("%s is a folder; cannot download folder to stdout", pathErrorDetails(src), src)
211211
}
212212
}
213213

@@ -216,7 +216,7 @@ func getStdout(cmd *cobra.Command, src string, recursive bool) error {
216216

217217
func getWithClient(dbx files.Client, args []string) (err error) {
218218
if len(args) == 0 || len(args) > 2 {
219-
return invalidArgumentsError("`get` requires `src` and/or `dst` arguments")
219+
return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
220220
}
221221

222222
src, err := validatePath(args[0])

cmd/mkdir.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ type mkdirResult struct {
4343

4444
func mkdir(cmd *cobra.Command, args []string) (err error) {
4545
if len(args) != 1 {
46-
return invalidArgumentsError("`mkdir` requires a `directory` argument")
46+
return invalidArgumentsErrorWithDetails("`mkdir` requires a `directory` argument", argumentErrorDetails("directory"))
4747
}
4848

4949
dst, err := validatePath(args[0])

cmd/mv.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func mv(cmd *cobra.Command, args []string) error {
3434
destination = args[1]
3535
argsToMove = append(argsToMove, args[0])
3636
} else {
37-
return invalidArgumentsError("mv command requires a source and a destination")
37+
return invalidArgumentsErrorWithDetails("mv command requires a source and a destination", argumentsErrorDetails("source", "destination"))
3838
}
3939

4040
var mvErrors []error

cmd/output.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,16 @@ func newCodedError(code string, err error, details ...map[string]any) error {
7575
}
7676
}
7777

78-
func invalidArgumentsError(message string) error {
79-
return newCodedError(jsonErrorCodeInvalidArguments, errors.New(message))
78+
func invalidArgumentsErrorWithDetails(message string, details map[string]any) error {
79+
return newCodedError(jsonErrorCodeInvalidArguments, errors.New(message), details)
8080
}
8181

82-
func invalidArgumentsErrorf(format string, args ...any) error {
83-
return newCodedError(jsonErrorCodeInvalidArguments, fmt.Errorf(format, args...))
82+
func invalidArgumentsErrorfWithDetails(format string, details map[string]any, args ...any) error {
83+
return newCodedError(jsonErrorCodeInvalidArguments, fmt.Errorf(format, args...), details)
8484
}
8585

8686
func pathConflictErrorWithPath(path string, format string, args ...any) error {
87-
return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...), map[string]any{
88-
"path": path,
89-
})
87+
return newCodedError(jsonErrorCodePathConflict, fmt.Errorf(format, args...), pathErrorDetails(path))
9088
}
9189

9290
func authRequiredErrorf(format string, args ...any) error {
@@ -129,6 +127,33 @@ func authRefreshFailedErrorfWithDetails(format string, details map[string]any, a
129127
return newCodedError(jsonErrorCodeAuthRefreshFailed, fmt.Errorf(format, args...), details)
130128
}
131129

130+
func argumentErrorDetails(argument string) map[string]any {
131+
return map[string]any{"argument": argument}
132+
}
133+
134+
func argumentsErrorDetails(arguments ...string) map[string]any {
135+
return map[string]any{"arguments": arguments}
136+
}
137+
138+
func flagErrorDetails(flag string) map[string]any {
139+
return map[string]any{"flag": flag}
140+
}
141+
142+
func flagsErrorDetails(flags ...string) map[string]any {
143+
return map[string]any{"flags": flags}
144+
}
145+
146+
func flagValueErrorDetails(flag, value string) map[string]any {
147+
return map[string]any{
148+
"flag": flag,
149+
"value": value,
150+
}
151+
}
152+
153+
func pathErrorDetails(path string) map[string]any {
154+
return map[string]any{"path": path}
155+
}
156+
132157
func commandOutput(cmd *cobra.Command) *output.Renderer {
133158
if cmd == nil {
134159
return output.New(nil, nil, output.FormatText)
@@ -319,6 +344,9 @@ func jsonErrorDetails(err error) map[string]any {
319344
} else if summary, ok := dropboxAPISummaryFromMessage(err.Error()); ok {
320345
details["api_summary"] = summary
321346
}
347+
if endpoint, ok := dropboxAPIEndpointFromMessage(err.Error()); ok {
348+
details["api_endpoint"] = endpoint
349+
}
322350

323351
if len(details) == 0 {
324352
return nil
@@ -471,6 +499,24 @@ func dropboxAPISummaryFromMessage(message string) (string, bool) {
471499
return "", false
472500
}
473501

502+
func dropboxAPIEndpointFromMessage(message string) (string, bool) {
503+
const prefix = `Error in call to API function "`
504+
idx := strings.Index(message, prefix)
505+
if idx < 0 {
506+
return "", false
507+
}
508+
start := idx + len(prefix)
509+
end := strings.Index(message[start:], `"`)
510+
if end < 0 {
511+
return "", false
512+
}
513+
end += start
514+
if start == end {
515+
return "", false
516+
}
517+
return message[start:end], true
518+
}
519+
474520
func isDropboxAPISummary(message string) bool {
475521
if message == "" || strings.ContainsAny(message, " \t\r\n\"") || !strings.Contains(message, "/") {
476522
return false

cmd/output_test.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,28 @@ func TestRenderCommandErrorIncludesCodedDetails(t *testing.T) {
341341
}
342342
}
343343

344+
func TestRenderCommandErrorIncludesArgumentAndFlagDetails(t *testing.T) {
345+
var stdout bytes.Buffer
346+
var stderr bytes.Buffer
347+
cmd := &cobra.Command{Use: "put"}
348+
cmd.SetOut(&stdout)
349+
cmd.SetErr(&stderr)
350+
cmd.Flags().String(outputFlag, "json", "")
351+
352+
renderCommandError(cmd, invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, or fail)", flagValueErrorDetails("if-exists", "replace"), "replace"))
353+
354+
if got := stderr.String(); got != "" {
355+
t.Fatalf("stderr = %q, want empty", got)
356+
}
357+
got := decodeJSONErrorResponse(t, stdout.String())
358+
if got.Error.Code != jsonErrorCodeInvalidArguments {
359+
t.Fatalf("code = %q, want %q", got.Error.Code, jsonErrorCodeInvalidArguments)
360+
}
361+
if got.Error.Details["flag"] != "if-exists" || got.Error.Details["value"] != "replace" {
362+
t.Fatalf("details = %+v, want flag/value", got.Error.Details)
363+
}
364+
}
365+
344366
func TestRenderCommandErrorIncludesDropboxAPISummaryDetails(t *testing.T) {
345367
var stdout bytes.Buffer
346368
var stderr bytes.Buffer
@@ -364,6 +386,32 @@ func TestRenderCommandErrorIncludesDropboxAPISummaryDetails(t *testing.T) {
364386
}
365387
}
366388

389+
func TestRenderCommandErrorIncludesDropboxAPIEndpointDetails(t *testing.T) {
390+
var stdout bytes.Buffer
391+
var stderr bytes.Buffer
392+
cmd := &cobra.Command{Use: "ls"}
393+
cmd.SetOut(&stdout)
394+
cmd.SetErr(&stderr)
395+
cmd.Flags().String(outputFlag, "json", "")
396+
397+
err := errors.New(`Error in call to API function "files/list_folder": path/not_found/.`)
398+
renderCommandError(cmd, err)
399+
400+
if got := stderr.String(); got != "" {
401+
t.Fatalf("stderr = %q, want empty", got)
402+
}
403+
got := decodeJSONErrorResponse(t, stdout.String())
404+
if got.Error.Code != jsonErrorCodeNotFound {
405+
t.Fatalf("code = %q, want %q", got.Error.Code, jsonErrorCodeNotFound)
406+
}
407+
if got.Error.Details["api_endpoint"] != "files/list_folder" {
408+
t.Fatalf("details = %+v, want api_endpoint", got.Error.Details)
409+
}
410+
if got.Error.Details["api_summary"] != `Error in call to API function "files/list_folder": path/not_found/.` {
411+
t.Fatalf("details = %+v, want api_summary", got.Error.Details)
412+
}
413+
}
414+
367415
func TestJSONErrorDetailsIncludesAuthRemediation(t *testing.T) {
368416
got := newJSONErrorResponse(&cobra.Command{Use: "account"}, missingAccessTokenError(tokenPersonal))
369417

@@ -694,12 +742,12 @@ func TestJSONErrorCodeUsesCodedErrors(t *testing.T) {
694742
},
695743
{
696744
name: "optional argument validation",
697-
err: invalidArgumentsError("`account` accepts an optional `id` argument"),
745+
err: invalidArgumentsErrorWithDetails("`account` accepts an optional `id` argument", argumentErrorDetails("id")),
698746
want: jsonErrorCodeInvalidArguments,
699747
},
700748
{
701749
name: "required argument validation",
702-
err: invalidArgumentsError("`add-member` requires `email`, `first`, and `last` arguments"),
750+
err: invalidArgumentsErrorWithDetails("`add-member` requires `email`, `first`, and `last` arguments", argumentsErrorDetails("email", "first", "last")),
703751
want: jsonErrorCodeInvalidArguments,
704752
},
705753
{

cmd/put.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ func putOperationResults(results []putResult) []jsonOperationResult {
284284

285285
func put(cmd *cobra.Command, args []string) (err error) {
286286
if len(args) == 0 || len(args) > 2 {
287-
return invalidArgumentsError("`put` requires `src` and/or `dst` arguments")
287+
return invalidArgumentsErrorWithDetails("`put` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
288288
}
289289

290290
opts, err := parsePutOptions(cmd)
@@ -306,7 +306,7 @@ func put(cmd *cobra.Command, args []string) (err error) {
306306
}
307307

308308
if srcInfo.IsDir() && !recursive {
309-
return invalidArgumentsErrorf("%s is a directory (use --recursive to upload directories)", src)
309+
return invalidArgumentsErrorfWithDetails("%s is a directory (use --recursive to upload directories)", pathErrorDetails(src), src)
310310
}
311311

312312
// 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) {
356356

357357
func putStdin(cmd *cobra.Command, args []string, opts putOptions, recursive bool) error {
358358
if len(args) < 2 {
359-
return invalidArgumentsError("`put -` requires an explicit target path")
359+
return invalidArgumentsErrorWithDetails("`put -` requires an explicit target path", argumentErrorDetails("dst"))
360360
}
361361
if recursive {
362-
return invalidArgumentsError("`put -` cannot be used with --recursive")
362+
return invalidArgumentsErrorWithDetails("`put -` cannot be used with --recursive", flagErrorDetails("recursive"))
363363
}
364364

365365
dst := args[1]
366366
if strings.HasSuffix(dst, "/") {
367-
return invalidArgumentsErrorf("cannot upload stdin to directory target %q; provide a full Dropbox file path", dst)
367+
return invalidArgumentsErrorfWithDetails("cannot upload stdin to directory target %q; provide a full Dropbox file path", pathErrorDetails(dst), dst)
368368
}
369369

370370
dstPath, err := validatePath(dst)
@@ -446,7 +446,7 @@ func parsePutOptions(cmd *cobra.Command) (putOptions, error) {
446446
return putOptions{}, err
447447
}
448448
if chunkSize%(1<<22) != 0 {
449-
return putOptions{}, invalidArgumentsError("`put` requires chunk size to be multiple of 4MiB")
449+
return putOptions{}, invalidArgumentsErrorWithDetails("`put` requires chunk size to be multiple of 4MiB", flagErrorDetails("chunksize"))
450450
}
451451
workers, err := cmd.Flags().GetInt("workers")
452452
if err != nil {
@@ -486,7 +486,7 @@ func normalizePutIfExists(ifExists string) (string, error) {
486486
case putIfExistsOverwrite, putIfExistsSkip, putIfExistsFail:
487487
return ifExists, nil
488488
default:
489-
return "", invalidArgumentsErrorf("invalid --if-exists %q (use overwrite, skip, or fail)", ifExists)
489+
return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, or fail)", flagValueErrorDetails("if-exists", ifExists), ifExists)
490490
}
491491
}
492492

@@ -578,7 +578,7 @@ func checkPutStdinDestination(dbx files.Client, dst string, ifExists string) (pu
578578
return putDestinationUpload, nil, nil
579579
}
580580
if _, ok := meta.(*files.FolderMetadata); ok {
581-
return putDestinationUpload, nil, invalidArgumentsErrorf("cannot upload stdin to folder %q; provide a full Dropbox file path", dst)
581+
return putDestinationUpload, nil, invalidArgumentsErrorfWithDetails("cannot upload stdin to folder %q; provide a full Dropbox file path", pathErrorDetails(dst), dst)
582582
}
583583
return actionForExistingDestination(dst, ifExists, meta)
584584
}

0 commit comments

Comments
 (0)