Skip to content

Commit 247e8d6

Browse files
Merge pull request #287 from dropbox/json-coded-errors-warnings
Add coded errors, JSON warnings, and contract tests
2 parents 8285450 + 17799f5 commit 247e8d6

30 files changed

Lines changed: 529 additions & 133 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ $ dbxcli restore --output=json /Reports/old.pdf 015f...
168168
Structured success output is rolling out command by command. Currently migrated commands are `version`, `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `share list folder`, `team info`, `team list-members`, `team list-groups`, `team add-member`, `team remove-member`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`.
169169

170170
Command results and JSON errors are written to stdout. Status, progress, human-facing warnings, diagnostics, and verbose logs are written to stderr. JSON errors include a `warnings` array for machine-actionable warnings; it is `[]` when no warnings are present. Successful JSON payloads use the same `warnings` field.
171+
Current warning codes include `deprecated_command` for deprecated command paths and `skipped_symlink` for symlinks skipped by recursive upload.
171172

172173
Successful JSON responses for migrated commands return an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. For commands such as `mkdir`, each result reports what happened to the requested path:
173174

cmd/account.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package cmd
1616

1717
import (
18-
"errors"
1918
"fmt"
2019
"io"
2120
"text/tabwriter"
@@ -102,7 +101,7 @@ func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error {
102101

103102
func account(cmd *cobra.Command, args []string) error {
104103
if len(args) > 1 {
105-
return errors.New("`account` accepts an optional `id` argument")
104+
return invalidArgumentsError("`account` accepts an optional `id` argument")
106105
}
107106

108107
dbx := usersNewFunc(config)

cmd/add-member.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package cmd
1616

1717
import (
18-
"errors"
1918
"fmt"
2019
"io"
2120

@@ -25,7 +24,7 @@ import (
2524

2625
func addMember(cmd *cobra.Command, args []string) (err error) {
2726
if len(args) != 3 {
28-
return errors.New("`add-member` requires `email`, `first`, and `last` arguments")
27+
return invalidArgumentsError("`add-member` requires `email`, `first`, and `last` arguments")
2928
}
3029
dbx := teamNewFunc(config)
3130

cmd/cp.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package cmd
1616

1717
import (
18-
"errors"
1918
"fmt"
2019
"strings"
2120

@@ -34,7 +33,7 @@ func cp(cmd *cobra.Command, args []string) error {
3433
destination = args[1]
3534
argsToCopy = append(argsToCopy, args[0])
3635
} else {
37-
return errors.New("cp requires a source and a destination")
36+
return invalidArgumentsError("cp requires a source and a destination")
3837
}
3938

4039
var cpErrors []error

cmd/get.go

Lines changed: 7 additions & 7 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 errors.New("`get` requires `src` and/or `dst` arguments")
68+
return invalidArgumentsError("`get` requires `src` and/or `dst` arguments")
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 errors.New("`get --output=json` cannot be used with stdout target `-`")
86+
return invalidArgumentsError("`get --output=json` cannot be used with stdout target `-`")
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 fmt.Errorf("%s is a folder (use --recursive to download folders)", src)
116+
return invalidArgumentsErrorf("%s is a folder (use --recursive to download folders)", src)
117117
}
118118
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
119119
dst = filepath.Join(dst, path.Base(src))
@@ -196,15 +196,15 @@ func getOperationResults(results []getResult) []jsonOperationResult {
196196

197197
func getStdout(cmd *cobra.Command, src string, recursive bool) error {
198198
if recursive {
199-
return errors.New("`get -` cannot be used with --recursive")
199+
return invalidArgumentsError("`get -` cannot be used with --recursive")
200200
}
201201

202202
dbx := filesNewFunc(config)
203203

204204
meta, err := dbx.GetMetadata(files.NewGetMetadataArg(src))
205205
if err == nil {
206206
if _, ok := meta.(*files.FolderMetadata); ok {
207-
return fmt.Errorf("%s is a folder; cannot download folder to stdout", src)
207+
return invalidArgumentsErrorf("%s is a folder; cannot download folder to stdout", src)
208208
}
209209
}
210210

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

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

219219
src, err := validatePath(args[0])
@@ -342,7 +342,7 @@ func ensureLocalDirectoryResult(source, target string, metadata files.IsMetadata
342342
status := getStatusCreated
343343
if info, err := os.Stat(target); err == nil {
344344
if !info.IsDir() {
345-
return getResult{}, fmt.Errorf("path exists and is not a folder: %s", target)
345+
return getResult{}, pathConflictErrorf("path exists and is not a folder: %s", target)
346346
}
347347
status = getStatusExisting
348348
} else if !os.IsNotExist(err) {

cmd/get_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,9 @@ func TestGetFolderWithoutRecursiveFlag(t *testing.T) {
509509
if !strings.Contains(err.Error(), "--recursive") {
510510
t.Errorf("error = %q, want mention of --recursive", err.Error())
511511
}
512+
if got, want := jsonErrorCode(err), jsonErrorCodeInvalidArguments; got != want {
513+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
514+
}
512515
}
513516

514517
func TestGetRecursiveCommandGetsMetadataThenListsFolder(t *testing.T) {
@@ -718,11 +721,34 @@ func TestGetJSONRejectsStdoutTarget(t *testing.T) {
718721
if !strings.Contains(err.Error(), "stdout target") {
719722
t.Fatalf("error = %q, want stdout target", err.Error())
720723
}
724+
if got, want := jsonErrorCode(err), jsonErrorCodeInvalidArguments; got != want {
725+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
726+
}
721727
if stdout.Len() != 0 {
722728
t.Fatalf("stdout = %q, want empty", stdout.String())
723729
}
724730
}
725731

732+
func TestGetStdoutFolderErrorHasInvalidArgumentsCode(t *testing.T) {
733+
mock := &mockFilesClient{
734+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
735+
return getTestFolderMetadata(arg.Path), nil
736+
},
737+
}
738+
stubFilesClient(t, mock)
739+
740+
err := getStdout(&cobra.Command{}, "/remote-folder", false)
741+
if err == nil {
742+
t.Fatal("expected folder stdout error")
743+
}
744+
if !strings.Contains(err.Error(), "cannot download folder to stdout") {
745+
t.Fatalf("error = %q, want stdout folder error", err.Error())
746+
}
747+
if got, want := jsonErrorCode(err), jsonErrorCodeInvalidArguments; got != want {
748+
t.Fatalf("jsonErrorCode = %q, want %q", got, want)
749+
}
750+
}
751+
726752
func TestGetTextStdoutTargetWritesOnlyFileBytes(t *testing.T) {
727753
mock := &mockFilesClient{
728754
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {

cmd/json_contract_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"sort"
8+
"strings"
9+
"testing"
10+
11+
"github.com/dropbox/dbxcli/internal/output"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
func TestStructuredOutputCommandAudit(t *testing.T) {
16+
got := structuredOutputCommandPaths(RootCmd)
17+
got = append(got, NewVersionCommand("test").Name())
18+
sort.Strings(got)
19+
20+
want := []string{
21+
"account",
22+
"cp",
23+
"du",
24+
"get",
25+
"ls",
26+
"mkdir",
27+
"mv",
28+
"put",
29+
"restore",
30+
"revs",
31+
"rm",
32+
"search",
33+
"share list folder",
34+
"share list link",
35+
"share-link create",
36+
"share-link download",
37+
"share-link info",
38+
"share-link list",
39+
"share-link revoke",
40+
"share-link update",
41+
"team add-member",
42+
"team info",
43+
"team list-groups",
44+
"team list-members",
45+
"team remove-member",
46+
"version",
47+
}
48+
sort.Strings(want)
49+
50+
if len(got) != len(want) {
51+
t.Fatalf("structured commands = %v, want %v", got, want)
52+
}
53+
for i := range want {
54+
if got[i] != want[i] {
55+
t.Fatalf("structured commands = %v, want %v", got, want)
56+
}
57+
}
58+
}
59+
60+
func structuredOutputCommandPaths(root *cobra.Command) []string {
61+
var paths []string
62+
var walk func(*cobra.Command, []string)
63+
walk = func(cmd *cobra.Command, parents []string) {
64+
parts := parents
65+
if cmd != root {
66+
parts = append(append([]string{}, parents...), cmd.Name())
67+
if commandSupportsStructuredOutput(cmd) {
68+
paths = append(paths, strings.Join(parts, " "))
69+
}
70+
}
71+
for _, child := range cmd.Commands() {
72+
walk(child, parts)
73+
}
74+
}
75+
walk(root, nil)
76+
return paths
77+
}
78+
79+
func TestJSONOperationOutputContractShape(t *testing.T) {
80+
encoded, err := json.Marshal(newJSONOperationOutput(nil, nil, nil))
81+
if err != nil {
82+
t.Fatalf("marshal operation output: %v", err)
83+
}
84+
85+
var raw map[string]json.RawMessage
86+
if err := json.Unmarshal(encoded, &raw); err != nil {
87+
t.Fatalf("decode operation output: %v", err)
88+
}
89+
for _, key := range []string{"input", "results", "warnings"} {
90+
if _, ok := raw[key]; !ok {
91+
t.Fatalf("operation output = %s, missing %q", encoded, key)
92+
}
93+
}
94+
if len(raw) != 3 {
95+
t.Fatalf("operation output = %s, want only input/results/warnings", encoded)
96+
}
97+
}
98+
99+
func TestUnsupportedCommandsReturnJSONErrorEnvelope(t *testing.T) {
100+
for _, name := range []string{"login", "logout", "completion"} {
101+
t.Run(name, func(t *testing.T) {
102+
var stdout, stderr bytes.Buffer
103+
cmd := &cobra.Command{Use: name}
104+
cmd.SetOut(&stdout)
105+
cmd.SetErr(&stderr)
106+
cmd.Flags().String(outputFlag, "json", "")
107+
108+
err := validateOutputFormat(cmd)
109+
if !errors.Is(err, output.ErrStructuredOutputUnsupported) {
110+
t.Fatalf("validateOutputFormat error = %v, want structured output unsupported", err)
111+
}
112+
113+
renderCommandError(cmd, err)
114+
if stderr.Len() != 0 {
115+
t.Fatalf("stderr = %q, want empty", stderr.String())
116+
}
117+
118+
got := decodeJSONErrorResponse(t, stdout.String())
119+
if got.OK {
120+
t.Fatalf("ok = true, want false")
121+
}
122+
if got.Error.Code != jsonErrorCodeStructuredOutputUnsupported {
123+
t.Fatalf("code = %q, want %q", got.Error.Code, jsonErrorCodeStructuredOutputUnsupported)
124+
}
125+
if got.Warnings == nil || len(got.Warnings) != 0 {
126+
t.Fatalf("warnings = %+v, want empty array", got.Warnings)
127+
}
128+
})
129+
}
130+
}

cmd/json_output.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ type jsonWarning struct {
1919
Path string `json:"path,omitempty"`
2020
}
2121

22+
const (
23+
jsonWarningCodeDeprecatedCommand = "deprecated_command"
24+
jsonWarningCodeSkippedSymlink = "skipped_symlink"
25+
)
26+
2227
type jsonOperationOutput struct {
2328
Input any `json:"input"`
2429
Results []jsonOperationResult `json:"results"`
@@ -52,7 +57,11 @@ func newJSONOperationOutput(input any, results []jsonOperationResult, warnings [
5257
}
5358

5459
func renderJSONOperationOutput(cmd *cobra.Command, input any, results []jsonOperationResult) error {
55-
return commandOutput(cmd).Render(nil, newJSONOperationOutput(input, results, nil))
60+
return renderJSONOperationOutputWithWarnings(cmd, input, results, nil)
61+
}
62+
63+
func renderJSONOperationOutputWithWarnings(cmd *cobra.Command, input any, results []jsonOperationResult, warnings []jsonWarning) error {
64+
return commandOutput(cmd).Render(nil, newJSONOperationOutput(input, results, warnings))
5665
}
5766

5867
func newJSONOperationResult(status, kind string, input any, result any) jsonOperationResult {

cmd/mkdir.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ package cmd
1616

1717
import (
1818
"errors"
19-
"fmt"
2019
"strings"
2120

2221
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
@@ -44,7 +43,7 @@ type mkdirResult struct {
4443

4544
func mkdir(cmd *cobra.Command, args []string) (err error) {
4645
if len(args) != 1 {
47-
return errors.New("`mkdir` requires a `directory` argument")
46+
return invalidArgumentsError("`mkdir` requires a `directory` argument")
4847
}
4948

5049
dst, err := validatePath(args[0])
@@ -77,7 +76,7 @@ func mkdir(cmd *cobra.Command, args []string) (err error) {
7776
}
7877
status = mkdirStatusExisting
7978
case ok && (conflictTag == files.WriteConflictErrorFile || conflictTag == files.WriteConflictErrorFileAncestor):
80-
return fmt.Errorf("path exists and is not a folder: %s", dst)
79+
return pathConflictErrorf("path exists and is not a folder: %s", dst)
8180
case ok:
8281
return err
8382
case isConflictError(err):
@@ -110,7 +109,7 @@ func existingFolderMetadata(dbx files.Client, dst string) (*files.FolderMetadata
110109
}
111110
folder, ok := metadata.(*files.FolderMetadata)
112111
if !ok || folder == nil {
113-
return nil, fmt.Errorf("path exists and is not a folder: %s", dst)
112+
return nil, pathConflictErrorf("path exists and is not a folder: %s", dst)
114113
}
115114
return folder, nil
116115
}

cmd/mv.go

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

3939
var mvErrors []error

0 commit comments

Comments
 (0)