From a245f6f6b3ae78629108af28402736b82a8f8f5d Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 24 Jun 2026 10:17:00 -0700 Subject: [PATCH] Add warnings field to JSON output Include a warnings array in JSON error responses so the schema has a stable place for future machine-actionable warnings. Add shared JSON operation output/result types for future consolidation of command-specific JSON payloads, and document the stdout/stderr contract. --- README.md | 7 +++-- cmd/json_output.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/output.go | 18 +---------- cmd/output_test.go | 65 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 cmd/json_output.go diff --git a/README.md b/README.md index 7987eaed..0d96b817 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ $ dbxcli restore --output=json /Reports/old.pdf 015f... Structured success output is rolling out command by command. Currently migrated commands are `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `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`. -Command results are written to stdout. Status, progress, warnings, diagnostics, and verbose logs are written to stderr. +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. Successful JSON responses are command-specific. Commands that operate on one path usually return an `input` object and a `result` metadata object: @@ -334,7 +334,8 @@ In JSON mode, command errors are written to stdout as JSON, including errors fro "error": { "message": "path exists and is not a folder: /old-file.txt", "code": "path_conflict" - } + }, + "warnings": [] } ``` @@ -503,7 +504,7 @@ Dropbox account, team, and folder policies can reject shared-link settings such `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. -New and changed commands should write command results to stdout. Status, progress, warnings, diagnostics, and verbose logs should go to stderr. +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. ### Team management diff --git a/cmd/json_output.go b/cmd/json_output.go new file mode 100644 index 00000000..92fc47fa --- /dev/null +++ b/cmd/json_output.go @@ -0,0 +1,75 @@ +package cmd + +type jsonErrorResponse struct { + OK bool `json:"ok"` + Error jsonError `json:"error"` + Warnings []jsonWarning `json:"warnings"` +} + +type jsonError struct { + Message string `json:"message"` + Code string `json:"code"` +} + +type jsonWarning struct { + Code string `json:"code"` + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +type jsonOperationOutput struct { + Input any `json:"input"` + Results []jsonOperationResult `json:"results"` + Warnings []jsonWarning `json:"warnings"` +} + +type jsonOperationResult struct { + Status string `json:"status,omitempty"` + Kind string `json:"kind,omitempty"` + Input any `json:"input,omitempty"` + Result any `json:"result,omitempty"` +} + +func newJSONErrorResponse(err error) jsonErrorResponse { + return jsonErrorResponse{ + OK: false, + Error: jsonError{ + Message: err.Error(), + Code: jsonErrorCode(err), + }, + Warnings: emptyJSONWarnings(), + } +} + +func newJSONOperationOutput(input any, results []jsonOperationResult, warnings []jsonWarning) jsonOperationOutput { + return jsonOperationOutput{ + Input: normalizeJSONInput(input), + Results: normalizeJSONOperationResults(results), + Warnings: normalizeJSONWarnings(warnings), + } +} + +func normalizeJSONInput(input any) any { + if input == nil { + return struct{}{} + } + return input +} + +func normalizeJSONOperationResults(results []jsonOperationResult) []jsonOperationResult { + if results == nil { + return []jsonOperationResult{} + } + return results +} + +func emptyJSONWarnings() []jsonWarning { + return []jsonWarning{} +} + +func normalizeJSONWarnings(warnings []jsonWarning) []jsonWarning { + if warnings == nil { + return emptyJSONWarnings() + } + return warnings +} diff --git a/cmd/output.go b/cmd/output.go index 03e527cf..bbd9977e 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -14,16 +14,6 @@ const ( structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput" ) -type jsonErrorResponse struct { - OK bool `json:"ok"` - Error jsonError `json:"error"` -} - -type jsonError struct { - Message string `json:"message"` - Code string `json:"code"` -} - func commandOutput(cmd *cobra.Command) *output.Renderer { if cmd == nil { return output.New(nil, nil, output.FormatText) @@ -132,13 +122,7 @@ func renderCommandErrorWithJSON(cmd *cobra.Command, err error, forceJSON bool) { } if forceJSON || commandOutputFormat(cmd) == output.FormatJSON { - renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, jsonErrorResponse{ - OK: false, - Error: jsonError{ - Message: err.Error(), - Code: jsonErrorCode(err), - }, - }) + renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, newJSONErrorResponse(err)) if renderErr == nil { return } diff --git a/cmd/output_test.go b/cmd/output_test.go index 3577ffb3..b27a71c6 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -257,7 +257,8 @@ func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) { if got := stderr.String(); got != "" { t.Fatalf("stderr = %q, want empty", got) } - got := decodeJSONErrorResponse(t, stdout.String()) + output := stdout.String() + got := decodeJSONErrorResponse(t, output) if got.OK { t.Fatalf("ok = true, want false") } @@ -267,6 +268,12 @@ func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) { if got.Error.Code != "command_failed" { t.Fatalf("code = %q, want command_failed", got.Error.Code) } + if len(got.Warnings) != 0 { + t.Fatalf("warnings = %+v, want empty", got.Warnings) + } + if !strings.Contains(output, `"warnings":[]`) { + t.Fatalf("output = %q, want warnings array", output) + } } func TestRenderCommandErrorWritesUnsupportedStructuredOutputAsJSON(t *testing.T) { @@ -289,6 +296,9 @@ func TestRenderCommandErrorWritesUnsupportedStructuredOutputAsJSON(t *testing.T) if !strings.Contains(got.Error.Message, "structured output is not supported") { t.Fatalf("message = %q, want structured output error", got.Error.Message) } + if len(got.Warnings) != 0 { + t.Fatalf("warnings = %+v, want empty", got.Warnings) + } } func TestRenderCommandErrorWithJSONForcesJSONError(t *testing.T) { @@ -308,6 +318,59 @@ func TestRenderCommandErrorWithJSONForcesJSONError(t *testing.T) { if got.Error.Code != "unknown_command" { t.Fatalf("code = %q, want unknown_command", got.Error.Code) } + if len(got.Warnings) != 0 { + t.Fatalf("warnings = %+v, want empty", got.Warnings) + } +} + +func TestNewJSONOperationOutputNormalizesWarnings(t *testing.T) { + got := newJSONOperationOutput( + struct { + Path string `json:"path"` + }{Path: "/file.txt"}, + []jsonOperationResult{ + { + Status: "downloaded", + Kind: "file", + Input: struct { + Path string `json:"path"` + }{Path: "/file.txt"}, + Result: struct { + Type string `json:"type"` + }{Type: "file"}, + }, + }, + nil, + ) + + encoded, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal operation output: %v", err) + } + if !strings.Contains(string(encoded), `"warnings":[]`) { + t.Fatalf("encoded output = %s, want warnings array", encoded) + } + if got.Warnings == nil { + t.Fatal("warnings is nil, want empty slice") + } +} + +func TestNewJSONOperationOutputNormalizesResults(t *testing.T) { + got := newJSONOperationOutput(nil, nil, nil) + + encoded, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal operation output: %v", err) + } + if !strings.Contains(string(encoded), `"results":[]`) { + t.Fatalf("encoded output = %s, want results array", encoded) + } + if !strings.Contains(string(encoded), `"input":{}`) { + t.Fatalf("encoded output = %s, want input object", encoded) + } + if got.Results == nil { + t.Fatal("results is nil, want empty slice") + } } func TestRenderCommandErrorInvalidOutputFormatFallsBackToText(t *testing.T) {