Skip to content

Commit 63abf16

Browse files
Merge pull request #280 from dropbox/json-normalize-single-operations
Normalize mkdir and restore to shared JSON operation output
2 parents 4ba830d + 85177d9 commit 63abf16

5 files changed

Lines changed: 135 additions & 42 deletions

File tree

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,24 +158,35 @@ Structured success output is rolling out command by command. Currently migrated
158158

159159
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

161-
Successful JSON responses are command-specific. Commands that operate on one path usually return an `input` object and a `result` metadata object:
161+
Successful JSON responses are command-specific. Operation commands return an `input` object, a `results` array, and a `warnings` array. For commands such as `mkdir`, each result reports what happened to the requested path:
162162

163163
```json
164164
{
165165
"input": {
166166
"path": "/new-folder",
167167
"parents": false
168168
},
169-
"result": {
170-
"type": "folder",
171-
"path_display": "/new-folder",
172-
"path_lower": "/new-folder",
173-
"id": "id:..."
174-
}
169+
"results": [
170+
{
171+
"status": "created",
172+
"kind": "folder",
173+
"input": {
174+
"path": "/new-folder",
175+
"parents": false
176+
},
177+
"result": {
178+
"type": "folder",
179+
"path_display": "/new-folder",
180+
"path_lower": "/new-folder",
181+
"id": "id:..."
182+
}
183+
}
184+
],
185+
"warnings": []
175186
}
176187
```
177188

178-
Commands that operate on multiple paths return a `results` array. For `cp` and `mv`, each `input` object uses `from_path` and `to_path`:
189+
For `cp` and `mv`, each result input object uses `from_path` and `to_path`:
179190

180191
```json
181192
{

cmd/mkdir.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,16 @@ type mkdirInput struct {
2828
Parents bool `json:"parents"`
2929
}
3030

31+
const (
32+
mkdirStatusCreated = "created"
33+
mkdirStatusExisting = "existing"
34+
35+
mkdirKindFolder = "folder"
36+
)
37+
3138
type mkdirResult struct {
39+
Status string `json:"status"`
40+
Kind string `json:"kind"`
3241
Input mkdirInput `json:"input"`
3342
Result jsonMetadata `json:"result"`
3443
}
@@ -50,6 +59,7 @@ func mkdir(cmd *cobra.Command, args []string) (err error) {
5059
dbx := filesNewFunc(config)
5160
created, err := dbx.CreateFolderV2(arg)
5261
var metadata *files.FolderMetadata
62+
status := mkdirStatusCreated
5363
if err != nil {
5464
if !parents {
5565
return err
@@ -65,6 +75,7 @@ func mkdir(cmd *cobra.Command, args []string) (err error) {
6575
if err != nil {
6676
return err
6777
}
78+
status = mkdirStatusExisting
6879
case ok && (conflictTag == files.WriteConflictErrorFile || conflictTag == files.WriteConflictErrorFileAncestor):
6980
return fmt.Errorf("path exists and is not a folder: %s", dst)
7081
case ok:
@@ -77,6 +88,7 @@ func mkdir(cmd *cobra.Command, args []string) (err error) {
7788
if err != nil {
7889
return err
7990
}
91+
status = mkdirStatusExisting
8092
default:
8193
return err
8294
}
@@ -87,8 +99,8 @@ func mkdir(cmd *cobra.Command, args []string) (err error) {
8799
metadata = created.Metadata
88100
}
89101

90-
result := newMkdirResult(dst, parents, metadata)
91-
return commandOutput(cmd).Render(nil, result)
102+
result := newMkdirResult(status, dst, parents, metadata)
103+
return renderJSONOperationOutput(cmd, result.Input, []jsonOperationResult{mkdirOperationResult(result)})
92104
}
93105

94106
func existingFolderMetadata(dbx files.Client, dst string) (*files.FolderMetadata, error) {
@@ -103,11 +115,13 @@ func existingFolderMetadata(dbx files.Client, dst string) (*files.FolderMetadata
103115
return folder, nil
104116
}
105117

106-
func newMkdirResult(path string, parents bool, metadata *files.FolderMetadata) mkdirResult {
118+
func newMkdirResult(status, path string, parents bool, metadata *files.FolderMetadata) mkdirResult {
107119
result := jsonMetadataFromDropbox(metadata)
108120
result.PathDisplay = metadataDisplayPath(path, result.PathDisplay)
109121

110122
return mkdirResult{
123+
Status: status,
124+
Kind: mkdirKindFolder,
111125
Input: mkdirInput{
112126
Path: path,
113127
Parents: parents,
@@ -116,6 +130,10 @@ func newMkdirResult(path string, parents bool, metadata *files.FolderMetadata) m
116130
}
117131
}
118132

133+
func mkdirOperationResult(result mkdirResult) jsonOperationResult {
134+
return newJSONOperationResult(result.Status, result.Kind, result.Input, result.Result)
135+
}
136+
119137
func createFolderConflictTag(err error) (string, bool) {
120138
var apiErrPtr *files.CreateFolderV2APIError
121139
if errors.As(err, &apiErrPtr) && apiErrPtr != nil {

cmd/mkdir_test.go

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,24 @@ func TestMkdirJSONOutputsCreatedFolder(t *testing.T) {
7474
if got.Input.Path != "/Projects" || got.Input.Parents {
7575
t.Fatalf("input = %#v, want path /Projects and parents false", got.Input)
7676
}
77-
if got.Result.Type != "folder" {
78-
t.Fatalf("result type = %q, want folder", got.Result.Type)
77+
result := got.Results[0]
78+
if result.Status != mkdirStatusCreated || result.Kind != mkdirKindFolder {
79+
t.Fatalf("status/kind = %s/%s, want created/folder", result.Status, result.Kind)
7980
}
80-
if got.Result.PathDisplay != "/Projects" {
81-
t.Fatalf("path_display = %q, want /Projects", got.Result.PathDisplay)
81+
if result.Input.Path != "/Projects" || result.Input.Parents {
82+
t.Fatalf("result input = %#v, want path /Projects and parents false", result.Input)
8283
}
83-
if got.Result.PathLower != "/projects" {
84-
t.Fatalf("path_lower = %q, want /projects", got.Result.PathLower)
84+
if result.Result.Type != "folder" {
85+
t.Fatalf("result type = %q, want folder", result.Result.Type)
8586
}
86-
if got.Result.ID != "id:folder" {
87-
t.Fatalf("id = %q, want id:folder", got.Result.ID)
87+
if result.Result.PathDisplay != "/Projects" {
88+
t.Fatalf("path_display = %q, want /Projects", result.Result.PathDisplay)
89+
}
90+
if result.Result.PathLower != "/projects" {
91+
t.Fatalf("path_lower = %q, want /projects", result.Result.PathLower)
92+
}
93+
if result.Result.ID != "id:folder" {
94+
t.Fatalf("id = %q, want id:folder", result.Result.ID)
8895
}
8996
if strings.Contains(stdout.String(), `"rev"`) || strings.Contains(stdout.String(), `"size"`) {
9097
t.Fatalf("folder JSON output = %s, want no file-only fields", stdout.String())
@@ -121,8 +128,12 @@ func TestMkdirJSONParentsReturnsExistingFolderMetadata(t *testing.T) {
121128
if got.Input.Path != "/Existing" || !got.Input.Parents {
122129
t.Fatalf("input = %#v, want path /Existing and parents true", got.Input)
123130
}
124-
if got.Result.Type != "folder" || got.Result.PathDisplay != "/Existing" {
125-
t.Fatalf("result = %#v, want existing folder metadata", got.Result)
131+
result := got.Results[0]
132+
if result.Status != mkdirStatusExisting || result.Kind != mkdirKindFolder {
133+
t.Fatalf("status/kind = %s/%s, want existing/folder", result.Status, result.Kind)
134+
}
135+
if result.Result.Type != "folder" || result.Result.PathDisplay != "/Existing" {
136+
t.Fatalf("result = %#v, want existing folder metadata", result.Result)
126137
}
127138
}
128139

@@ -211,8 +222,8 @@ func TestMkdirJSONUsesInputPathWhenMetadataPathDisplayMissing(t *testing.T) {
211222
}
212223

213224
got := decodeMkdirOutput(t, stdout)
214-
if got.Result.PathDisplay != "/Projects" {
215-
t.Fatalf("path_display = %q, want fallback input path", got.Result.PathDisplay)
225+
if got.Results[0].Result.PathDisplay != "/Projects" {
226+
t.Fatalf("path_display = %q, want fallback input path", got.Results[0].Result.PathDisplay)
216227
}
217228
}
218229

@@ -294,12 +305,27 @@ func createFolderConflictError(conflictTag string) files.CreateFolderV2APIError
294305
}
295306
}
296307

297-
func decodeMkdirOutput(t *testing.T, stdout *bytes.Buffer) mkdirResult {
308+
type mkdirOutput struct {
309+
Input mkdirInput `json:"input"`
310+
Results []mkdirResult `json:"results"`
311+
Warnings []jsonWarning `json:"warnings"`
312+
}
313+
314+
func decodeMkdirOutput(t *testing.T, stdout *bytes.Buffer) mkdirOutput {
298315
t.Helper()
299316

300-
var got mkdirResult
317+
var got mkdirOutput
301318
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
302319
t.Fatalf("decode JSON output: %v\noutput: %s", err, stdout.String())
303320
}
321+
if got.Warnings == nil {
322+
t.Fatalf("warnings = nil, want empty array")
323+
}
324+
if len(got.Warnings) != 0 {
325+
t.Fatalf("warnings = %+v, want empty", got.Warnings)
326+
}
327+
if len(got.Results) != 1 {
328+
t.Fatalf("results len = %d, want 1", len(got.Results))
329+
}
304330
return got
305331
}

cmd/restore.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ type restoreInput struct {
2929
Revision string `json:"revision"`
3030
}
3131

32+
const (
33+
restoreStatusRestored = "restored"
34+
restoreKindFile = "file"
35+
)
36+
3237
type restoreResult struct {
38+
Status string `json:"status"`
39+
Kind string `json:"kind"`
3340
Input restoreInput `json:"input"`
3441
Result jsonMetadata `json:"result"`
3542
}
@@ -62,11 +69,13 @@ func restore(cmd *cobra.Command, args []string) (err error) {
6269
return nil
6370
}
6471
return renderRestoreResult(w, result)
65-
}, result)
72+
}, newJSONOperationOutput(result.Input, []jsonOperationResult{restoreOperationResult(result)}, nil))
6673
}
6774

6875
func newRestoreResult(path, revision string, metadata *files.FileMetadata) restoreResult {
6976
return restoreResult{
77+
Status: restoreStatusRestored,
78+
Kind: restoreKindFile,
7079
Input: restoreInput{
7180
Path: path,
7281
Revision: revision,
@@ -75,6 +84,10 @@ func newRestoreResult(path, revision string, metadata *files.FileMetadata) resto
7584
}
7685
}
7786

87+
func restoreOperationResult(result restoreResult) jsonOperationResult {
88+
return newJSONOperationResult(result.Status, result.Kind, result.Input, result.Result)
89+
}
90+
7891
func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) jsonMetadata {
7992
if metadata == nil {
8093
return jsonMetadata{

cmd/restore_test.go

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) {
115115
if result.Input.Path != "/Reports/old.pdf" || result.Input.Revision != "target-rev" {
116116
t.Fatalf("input = %#v, want path and target revision", result.Input)
117117
}
118+
if result.Status != restoreStatusRestored || result.Kind != restoreKindFile {
119+
t.Fatalf("status/kind = %s/%s, want restored/file", result.Status, result.Kind)
120+
}
118121
if result.Result.Type != "file" || result.Result.PathDisplay != "/Reports/old.pdf" {
119122
t.Fatalf("metadata = %#v, want file path metadata", result.Result)
120123
}
@@ -165,20 +168,27 @@ func TestRestoreJSONOutputsInputAndMetadata(t *testing.T) {
165168
if got.Input.Path != "/Reports/old.pdf" || got.Input.Revision != "target-rev" {
166169
t.Fatalf("input = %#v, want path and target revision", got.Input)
167170
}
168-
if got.Result.Type != "file" || got.Result.PathDisplay != "/Reports/old.pdf" || got.Result.PathLower != "/reports/old.pdf" {
169-
t.Fatalf("metadata = %#v, want file path metadata", got.Result)
171+
result := got.Results[0]
172+
if result.Status != restoreStatusRestored || result.Kind != restoreKindFile {
173+
t.Fatalf("status/kind = %s/%s, want restored/file", result.Status, result.Kind)
174+
}
175+
if result.Input.Path != "/Reports/old.pdf" || result.Input.Revision != "target-rev" {
176+
t.Fatalf("result input = %#v, want path and target revision", result.Input)
170177
}
171-
if got.Result.ID != "id:abc" || got.Result.Rev != "current-rev" {
172-
t.Fatalf("metadata = %#v, want returned id and current revision", got.Result)
178+
if result.Result.Type != "file" || result.Result.PathDisplay != "/Reports/old.pdf" || result.Result.PathLower != "/reports/old.pdf" {
179+
t.Fatalf("metadata = %#v, want file path metadata", result.Result)
173180
}
174-
if got.Result.Size == nil || *got.Result.Size != 123 {
175-
t.Fatalf("size = %v, want 123", got.Result.Size)
181+
if result.Result.ID != "id:abc" || result.Result.Rev != "current-rev" {
182+
t.Fatalf("metadata = %#v, want returned id and current revision", result.Result)
176183
}
177-
if got.Result.ServerModified == nil || *got.Result.ServerModified != "2026-06-17T12:30:00Z" {
178-
t.Fatalf("server modified = %v, want 2026-06-17T12:30:00Z", got.Result.ServerModified)
184+
if result.Result.Size == nil || *result.Result.Size != 123 {
185+
t.Fatalf("size = %v, want 123", result.Result.Size)
186+
}
187+
if result.Result.ServerModified == nil || *result.Result.ServerModified != "2026-06-17T12:30:00Z" {
188+
t.Fatalf("server modified = %v, want 2026-06-17T12:30:00Z", result.Result.ServerModified)
179189
}
180-
if got.Result.ClientModified == nil || *got.Result.ClientModified != "2026-06-16T10:00:00Z" {
181-
t.Fatalf("client modified = %v, want 2026-06-16T10:00:00Z", got.Result.ClientModified)
190+
if result.Result.ClientModified == nil || *result.Result.ClientModified != "2026-06-16T10:00:00Z" {
191+
t.Fatalf("client modified = %v, want 2026-06-16T10:00:00Z", result.Result.ClientModified)
182192
}
183193
}
184194

@@ -197,8 +207,8 @@ func TestRestoreJSONUsesInputPathWhenMetadataPathDisplayMissing(t *testing.T) {
197207
}
198208

199209
got := decodeRestoreOutput(t, stdout)
200-
if got.Result.PathDisplay != "/Reports/old.pdf" {
201-
t.Fatalf("path_display = %q, want fallback input path", got.Result.PathDisplay)
210+
if got.Results[0].Result.PathDisplay != "/Reports/old.pdf" {
211+
t.Fatalf("path_display = %q, want fallback input path", got.Results[0].Result.PathDisplay)
202212
}
203213
}
204214

@@ -227,8 +237,8 @@ func TestRestoreJSONVerboseDoesNotPrintText(t *testing.T) {
227237
t.Fatalf("stdout = %q, want JSON only", stdout.String())
228238
}
229239
got := decodeRestoreOutput(t, stdout)
230-
if got.Result.Rev != "current-rev" {
231-
t.Fatalf("rev = %q, want current-rev", got.Result.Rev)
240+
if got.Results[0].Result.Rev != "current-rev" {
241+
t.Fatalf("rev = %q, want current-rev", got.Results[0].Result.Rev)
232242
}
233243
}
234244

@@ -273,12 +283,27 @@ func setRestoreOutputJSON(t *testing.T, cmd *cobra.Command) {
273283
}
274284
}
275285

276-
func decodeRestoreOutput(t *testing.T, stdout *bytes.Buffer) restoreResult {
286+
type restoreOutput struct {
287+
Input restoreInput `json:"input"`
288+
Results []restoreResult `json:"results"`
289+
Warnings []jsonWarning `json:"warnings"`
290+
}
291+
292+
func decodeRestoreOutput(t *testing.T, stdout *bytes.Buffer) restoreOutput {
277293
t.Helper()
278294

279-
var got restoreResult
295+
var got restoreOutput
280296
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
281297
t.Fatalf("decode JSON output: %v\noutput: %s", err, stdout.String())
282298
}
299+
if got.Warnings == nil {
300+
t.Fatalf("warnings = nil, want empty array")
301+
}
302+
if len(got.Warnings) != 0 {
303+
t.Fatalf("warnings = %+v, want empty", got.Warnings)
304+
}
305+
if len(got.Results) != 1 {
306+
t.Fatalf("results len = %d, want 1", len(got.Results))
307+
}
283308
return got
284309
}

0 commit comments

Comments
 (0)