Skip to content

Commit 17799f5

Browse files
Add coded errors, JSON warnings, and contract tests
Replace fragile string-matching in jsonErrorCode with typed coded errors set at the point of creation. Add warnings infrastructure for deprecated commands and skipped symlinks during recursive upload. Add contract tests that audit the structured output surface and verify the JSON operation output envelope shape.
1 parent 8285450 commit 17799f5

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)