Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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": []
}
```

Expand Down Expand Up @@ -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

Expand Down
75 changes: 75 additions & 0 deletions cmd/json_output.go
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 1 addition & 17 deletions cmd/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
65 changes: 64 additions & 1 deletion cmd/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Loading