Skip to content

Commit db66e83

Browse files
Merge pull request #278 from dropbox/json-output-warnings
Add warnings array to JSON error responses
2 parents a68341c + a245f6f commit db66e83

4 files changed

Lines changed: 144 additions & 21 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ $ dbxcli restore --output=json /Reports/old.pdf 015f...
156156

157157
Structured success output is rolling out command by command. Currently migrated commands are `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`, `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`.
158158

159-
Command results are written to stdout. Status, progress, warnings, diagnostics, and verbose logs are written to stderr.
159+
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. New operation-style JSON payloads should use the same `warnings` field.
160160

161161
Successful JSON responses are command-specific. Commands that operate on one path usually return an `input` object and a `result` metadata object:
162162

@@ -337,7 +337,8 @@ In JSON mode, command errors are written to stdout as JSON, including errors fro
337337
"error": {
338338
"message": "path exists and is not a folder: /old-file.txt",
339339
"code": "path_conflict"
340-
}
340+
},
341+
"warnings": []
341342
}
342343
```
343344

@@ -506,7 +507,7 @@ Dropbox account, team, and folder policies can reject shared-link settings such
506507

507508
`share-link download` writes to the metadata filename when `target` is omitted. Use `--path` to download a single file inside a folder shared link. Use `-` as the target to write file bytes to stdout. Folder shared links require `--recursive` and cannot be written to stdout.
508509

509-
New and changed commands should write command results to stdout. Status, progress, warnings, diagnostics, and verbose logs should go to stderr.
510+
New and changed commands should write command results to stdout. Status, progress, human-facing warnings, diagnostics, and verbose logs should go to stderr. Machine-actionable JSON warnings should use the `warnings` array.
510511

511512
### Team management
512513

cmd/json_output.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package cmd
2+
3+
type jsonErrorResponse struct {
4+
OK bool `json:"ok"`
5+
Error jsonError `json:"error"`
6+
Warnings []jsonWarning `json:"warnings"`
7+
}
8+
9+
type jsonError struct {
10+
Message string `json:"message"`
11+
Code string `json:"code"`
12+
}
13+
14+
type jsonWarning struct {
15+
Code string `json:"code"`
16+
Message string `json:"message"`
17+
Path string `json:"path,omitempty"`
18+
}
19+
20+
type jsonOperationOutput struct {
21+
Input any `json:"input"`
22+
Results []jsonOperationResult `json:"results"`
23+
Warnings []jsonWarning `json:"warnings"`
24+
}
25+
26+
type jsonOperationResult struct {
27+
Status string `json:"status,omitempty"`
28+
Kind string `json:"kind,omitempty"`
29+
Input any `json:"input,omitempty"`
30+
Result any `json:"result,omitempty"`
31+
}
32+
33+
func newJSONErrorResponse(err error) jsonErrorResponse {
34+
return jsonErrorResponse{
35+
OK: false,
36+
Error: jsonError{
37+
Message: err.Error(),
38+
Code: jsonErrorCode(err),
39+
},
40+
Warnings: emptyJSONWarnings(),
41+
}
42+
}
43+
44+
func newJSONOperationOutput(input any, results []jsonOperationResult, warnings []jsonWarning) jsonOperationOutput {
45+
return jsonOperationOutput{
46+
Input: normalizeJSONInput(input),
47+
Results: normalizeJSONOperationResults(results),
48+
Warnings: normalizeJSONWarnings(warnings),
49+
}
50+
}
51+
52+
func normalizeJSONInput(input any) any {
53+
if input == nil {
54+
return struct{}{}
55+
}
56+
return input
57+
}
58+
59+
func normalizeJSONOperationResults(results []jsonOperationResult) []jsonOperationResult {
60+
if results == nil {
61+
return []jsonOperationResult{}
62+
}
63+
return results
64+
}
65+
66+
func emptyJSONWarnings() []jsonWarning {
67+
return []jsonWarning{}
68+
}
69+
70+
func normalizeJSONWarnings(warnings []jsonWarning) []jsonWarning {
71+
if warnings == nil {
72+
return emptyJSONWarnings()
73+
}
74+
return warnings
75+
}

cmd/output.go

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,6 @@ const (
1414
structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput"
1515
)
1616

17-
type jsonErrorResponse struct {
18-
OK bool `json:"ok"`
19-
Error jsonError `json:"error"`
20-
}
21-
22-
type jsonError struct {
23-
Message string `json:"message"`
24-
Code string `json:"code"`
25-
}
26-
2717
func commandOutput(cmd *cobra.Command) *output.Renderer {
2818
if cmd == nil {
2919
return output.New(nil, nil, output.FormatText)
@@ -132,13 +122,7 @@ func renderCommandErrorWithJSON(cmd *cobra.Command, err error, forceJSON bool) {
132122
}
133123

134124
if forceJSON || commandOutputFormat(cmd) == output.FormatJSON {
135-
renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, jsonErrorResponse{
136-
OK: false,
137-
Error: jsonError{
138-
Message: err.Error(),
139-
Code: jsonErrorCode(err),
140-
},
141-
})
125+
renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, newJSONErrorResponse(err))
142126
if renderErr == nil {
143127
return
144128
}

cmd/output_test.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) {
257257
if got := stderr.String(); got != "" {
258258
t.Fatalf("stderr = %q, want empty", got)
259259
}
260-
got := decodeJSONErrorResponse(t, stdout.String())
260+
output := stdout.String()
261+
got := decodeJSONErrorResponse(t, output)
261262
if got.OK {
262263
t.Fatalf("ok = true, want false")
263264
}
@@ -267,6 +268,12 @@ func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) {
267268
if got.Error.Code != "command_failed" {
268269
t.Fatalf("code = %q, want command_failed", got.Error.Code)
269270
}
271+
if len(got.Warnings) != 0 {
272+
t.Fatalf("warnings = %+v, want empty", got.Warnings)
273+
}
274+
if !strings.Contains(output, `"warnings":[]`) {
275+
t.Fatalf("output = %q, want warnings array", output)
276+
}
270277
}
271278

272279
func TestRenderCommandErrorWritesUnsupportedStructuredOutputAsJSON(t *testing.T) {
@@ -289,6 +296,9 @@ func TestRenderCommandErrorWritesUnsupportedStructuredOutputAsJSON(t *testing.T)
289296
if !strings.Contains(got.Error.Message, "structured output is not supported") {
290297
t.Fatalf("message = %q, want structured output error", got.Error.Message)
291298
}
299+
if len(got.Warnings) != 0 {
300+
t.Fatalf("warnings = %+v, want empty", got.Warnings)
301+
}
292302
}
293303

294304
func TestRenderCommandErrorWithJSONForcesJSONError(t *testing.T) {
@@ -308,6 +318,59 @@ func TestRenderCommandErrorWithJSONForcesJSONError(t *testing.T) {
308318
if got.Error.Code != "unknown_command" {
309319
t.Fatalf("code = %q, want unknown_command", got.Error.Code)
310320
}
321+
if len(got.Warnings) != 0 {
322+
t.Fatalf("warnings = %+v, want empty", got.Warnings)
323+
}
324+
}
325+
326+
func TestNewJSONOperationOutputNormalizesWarnings(t *testing.T) {
327+
got := newJSONOperationOutput(
328+
struct {
329+
Path string `json:"path"`
330+
}{Path: "/file.txt"},
331+
[]jsonOperationResult{
332+
{
333+
Status: "downloaded",
334+
Kind: "file",
335+
Input: struct {
336+
Path string `json:"path"`
337+
}{Path: "/file.txt"},
338+
Result: struct {
339+
Type string `json:"type"`
340+
}{Type: "file"},
341+
},
342+
},
343+
nil,
344+
)
345+
346+
encoded, err := json.Marshal(got)
347+
if err != nil {
348+
t.Fatalf("marshal operation output: %v", err)
349+
}
350+
if !strings.Contains(string(encoded), `"warnings":[]`) {
351+
t.Fatalf("encoded output = %s, want warnings array", encoded)
352+
}
353+
if got.Warnings == nil {
354+
t.Fatal("warnings is nil, want empty slice")
355+
}
356+
}
357+
358+
func TestNewJSONOperationOutputNormalizesResults(t *testing.T) {
359+
got := newJSONOperationOutput(nil, nil, nil)
360+
361+
encoded, err := json.Marshal(got)
362+
if err != nil {
363+
t.Fatalf("marshal operation output: %v", err)
364+
}
365+
if !strings.Contains(string(encoded), `"results":[]`) {
366+
t.Fatalf("encoded output = %s, want results array", encoded)
367+
}
368+
if !strings.Contains(string(encoded), `"input":{}`) {
369+
t.Fatalf("encoded output = %s, want input object", encoded)
370+
}
371+
if got.Results == nil {
372+
t.Fatal("results is nil, want empty slice")
373+
}
311374
}
312375

313376
func TestRenderCommandErrorInvalidOutputFormatFallsBackToText(t *testing.T) {

0 commit comments

Comments
 (0)