Skip to content

Commit cbd4e5f

Browse files
Merge pull request #290 from dropbox/json-public-command-catalog
Publish command catalog, eliminate unknown types, and harden schema
2 parents 0e82916 + 906c10a commit cbd4e5f

25 files changed

Lines changed: 1015 additions & 118 deletions

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Structured success output is rolling out command by command. Currently migrated
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.
171171
Current warning codes include `deprecated_command` for deprecated command paths and `skipped_symlink` for symlinks skipped by recursive upload.
172172

173-
Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Cobra help output and shell-completion protocol commands are also text-only.
173+
Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Cobra help output and shell-completion protocol commands are also text-only: `dbxcli --help --output=json`, `dbxcli --output=json` without a command, and command-specific help such as `dbxcli version --help --output=json` print text help.
174174

175175
JSON error responses use stable `error.code` values:
176176

@@ -187,12 +187,11 @@ JSON error responses use stable `error.code` values:
187187
| `rate_limited` | Dropbox rate limited the request. |
188188
| `dropbox_api_error` | Dropbox returned an API error that does not map to a more specific code yet. |
189189
| `structured_output_unsupported` | The command does not support `--output=json` yet. |
190-
| `unsupported_output_format` | `--output` was not `text` or `json`. |
191190
| `unknown_command` | Cobra could not resolve the command. |
192191
| `unknown_flag` | Cobra could not resolve a flag. |
193192
| `command_failed` | Fallback for failures without a more specific stable code. |
194193

195-
Successful JSON responses for migrated commands return `ok: true`, `schema_version: "1"`, `command`, an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. Public top-level schemas live under [docs/json-schema/v1](docs/json-schema/v1/). If a multi-target or recursive command fails after some side effects have already happened, dbxcli returns a JSON error envelope and does not include partial success results. For commands such as `mkdir`, each result reports what happened to the requested path:
194+
Successful JSON responses for migrated commands return `ok: true`, `schema_version: "1"`, `command`, an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. Public top-level schemas and the command contract catalog live under [docs/json-schema/v1](docs/json-schema/v1/). If a multi-target or recursive command fails after some side effects have already happened, dbxcli returns a JSON error envelope and does not include partial success results. For commands such as `mkdir`, each result reports what happened to the requested path:
196195

197196
```json
198197
{

cmd/cp.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"fmt"
1919
"strings"
2020

21+
"github.com/dropbox/dbxcli/internal/output"
2122
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
2223
"github.com/spf13/cobra"
2324
)
@@ -39,6 +40,7 @@ func cp(cmd *cobra.Command, args []string) error {
3940
var cpErrors []error
4041
var relocationArgs []*files.RelocationArg
4142
var results []relocationResult
43+
collectResults := commandOutputFormat(cmd) == output.FormatJSON
4244

4345
dbx := filesNewFunc(config)
4446
destIsFolder := len(argsToCopy) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination)
@@ -61,7 +63,15 @@ func cp(cmd *cobra.Command, args []string) error {
6163
cpErrors = append(cpErrors, copyError)
6264
continue
6365
}
64-
results = append(results, newRelocationResult(arg, res))
66+
if collectResults {
67+
result, err := newRelocationResult(arg, res)
68+
if err != nil {
69+
copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)
70+
cpErrors = append(cpErrors, copyError)
71+
continue
72+
}
73+
results = append(results, result)
74+
}
6575
}
6676

6777
if len(cpErrors) > 0 {
@@ -71,6 +81,9 @@ func cp(cmd *cobra.Command, args []string) error {
7181
return fmt.Errorf("cp: %d error(s)", len(cpErrors))
7282
}
7383

84+
if !collectResults {
85+
return nil
86+
}
7487
return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusCopied, results))
7588
}
7689

cmd/get.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ func getErrorOutput(opts getOptions) io.Writer {
162162
return os.Stderr
163163
}
164164

165-
func newGetResult(status, kind, source, target string, metadata files.IsMetadata) getResult {
165+
func newGetResult(status, kind, source, target string, metadata files.IsMetadata) (getResult, error) {
166166
result := getResult{
167167
Status: status,
168168
Kind: kind,
@@ -172,10 +172,13 @@ func newGetResult(status, kind, source, target string, metadata files.IsMetadata
172172
},
173173
}
174174
if metadata != nil {
175-
jsonResult := jsonMetadataFromDropbox(metadata)
175+
jsonResult, err := jsonMetadataFromDropbox(metadata)
176+
if err != nil {
177+
return getResult{}, err
178+
}
176179
result.Result = &jsonResult
177180
}
178-
return result
181+
return result, nil
179182
}
180183

181184
func renderGetResults(cmd *cobra.Command, input getCommandInput, results []getResult) error {
@@ -352,7 +355,7 @@ func ensureLocalDirectoryResult(source, target string, metadata files.IsMetadata
352355
if err := os.MkdirAll(target, 0755); err != nil {
353356
return getResult{}, err
354357
}
355-
return newGetResult(status, getKindFolder, source, target, metadata), nil
358+
return newGetResult(status, getKindFolder, source, target, metadata)
356359
}
357360

358361
func relativeTo(base, full string) (string, error) {
@@ -376,7 +379,7 @@ func downloadFileWithResult(dbx files.Client, src string, dst string, opts getOp
376379
if err != nil {
377380
return getResult{}, err
378381
}
379-
return newGetResult(getStatusDownloaded, getKindFile, src, dst, metadata), nil
382+
return newGetResult(getStatusDownloaded, getKindFile, src, dst, metadata)
380383
}
381384

382385
func downloadFileWithMetadata(dbx files.Client, src string, dst string, errOut io.Writer) (*files.FileMetadata, error) {

cmd/json_contract_test.go

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,13 @@ func TestStructuredOutputGoldenSuccessOutputAudit(t *testing.T) {
130130
continue
131131
}
132132
assertGoldenJSONEqual(t, command, fixture, example)
133+
assertGoldenSuccessOutputStatuses(t, command, fixture)
133134
}
134135
for command := range fixtures {
135136
if !structuredSet[command] {
136137
t.Errorf("golden success output includes non-structured command %q", command)
137138
}
139+
assertGoldenSuccessOutputStatuses(t, command, fixtures[command])
138140
}
139141
for command := range examples {
140142
if !structuredSet[command] {
@@ -195,6 +197,18 @@ func TestPublicJSONSchemaFiles(t *testing.T) {
195197
}
196198
}
197199

200+
func TestPublicJSONCommandCatalogMatchesGoldenContract(t *testing.T) {
201+
got := loadJSONContractFile(t, "../docs/json-schema/v1/commands.json", "public command catalog")
202+
want := loadJSONGoldenContract(t)
203+
if reflect.DeepEqual(got, want) {
204+
return
205+
}
206+
207+
gotJSON, _ := json.MarshalIndent(got, "", " ")
208+
wantJSON, _ := json.MarshalIndent(want, "", " ")
209+
t.Fatalf("public command catalog = %s, want %s", gotJSON, wantJSON)
210+
}
211+
198212
func structuredOutputCommandPathsWithVersion() []string {
199213
paths := structuredOutputCommandPaths(RootCmd)
200214
return append(paths, NewVersionCommand("test").Name())
@@ -379,23 +393,29 @@ func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture {
379393
func loadJSONGoldenContract(t *testing.T) jsonGoldenContract {
380394
t.Helper()
381395

382-
data, err := os.ReadFile("testdata/json_contract/success_schemas.json")
396+
return loadJSONContractFile(t, "testdata/json_contract/success_schemas.json", "golden schema fixture")
397+
}
398+
399+
func loadJSONContractFile(t *testing.T, file string, label string) jsonGoldenContract {
400+
t.Helper()
401+
402+
data, err := os.ReadFile(file)
383403
if err != nil {
384-
t.Fatalf("read golden schema fixture: %v", err)
404+
t.Fatalf("read %s %s: %v", label, file, err)
385405
}
386406

387407
var raw struct {
388408
Definitions map[string]json.RawMessage `json:"definitions"`
389409
Commands map[string]map[string]json.RawMessage `json:"commands"`
390410
}
391411
if err := json.Unmarshal(data, &raw); err != nil {
392-
t.Fatalf("decode raw golden schema fixture: %v", err)
412+
t.Fatalf("decode raw %s %s: %v", label, file, err)
393413
}
394414
if len(raw.Definitions) == 0 {
395-
t.Fatalf("golden schema fixture has no definitions")
415+
t.Fatalf("%s %s has no definitions", label, file)
396416
}
397417
if len(raw.Commands) == 0 {
398-
t.Fatalf("golden schema fixture has no commands")
418+
t.Fatalf("%s %s has no commands", label, file)
399419
}
400420

401421
requiredCommandFields := []string{
@@ -411,14 +431,14 @@ func loadJSONGoldenContract(t *testing.T) jsonGoldenContract {
411431
for command, fields := range raw.Commands {
412432
for _, field := range requiredCommandFields {
413433
if _, ok := fields[field]; !ok {
414-
t.Errorf("golden schema for %q missing %q", command, field)
434+
t.Errorf("%s for %q missing %q", label, command, field)
415435
}
416436
}
417437
}
418438

419439
var contract jsonGoldenContract
420440
if err := json.Unmarshal(data, &contract); err != nil {
421-
t.Fatalf("decode golden schema fixture: %v", err)
441+
t.Fatalf("decode %s %s: %v", label, file, err)
422442
}
423443
return normalizeGoldenContract(contract)
424444
}
@@ -472,7 +492,6 @@ func expectedJSONErrorCodes() []string {
472492
jsonErrorCodeStructuredOutputUnsupported,
473493
jsonErrorCodeUnknownCommand,
474494
jsonErrorCodeUnknownFlag,
475-
jsonErrorCodeUnsupportedOutputFormat,
476495
}
477496
}
478497

@@ -501,6 +520,29 @@ func assertGoldenJSONEqual(t *testing.T, command string, fixture json.RawMessage
501520
t.Errorf("golden output for %q = %s, want %s", command, gotJSON, wantJSON)
502521
}
503522

523+
func assertGoldenSuccessOutputStatuses(t *testing.T, command string, fixture json.RawMessage) {
524+
t.Helper()
525+
526+
var output jsonOperationOutput
527+
if err := json.Unmarshal(fixture, &output); err != nil {
528+
t.Fatalf("decode golden output for %q: %v", command, err)
529+
}
530+
for i, result := range output.Results {
531+
if result.Status == "" {
532+
t.Errorf("golden output for %q result %d has empty status", command, i)
533+
}
534+
if result.Status == "unknown" {
535+
t.Errorf("golden output for %q result %d must not use unknown status", command, i)
536+
}
537+
if result.Kind == "" {
538+
t.Errorf("golden output for %q result %d has empty kind", command, i)
539+
}
540+
if result.Kind == "unknown" {
541+
t.Errorf("golden output for %q result %d must not use unknown kind", command, i)
542+
}
543+
}
544+
}
545+
504546
func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput {
505547
file := sampleJSONFileMetadata("/Reports/old.pdf")
506548
copyFile := sampleJSONFileMetadata("/Reports/copy.pdf")
@@ -789,7 +831,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema {
789831
"mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved}, metadataKinds(), nil),
790832
"put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}),
791833
"restore": operationSchema("restore_input", schemaRef("restore_input"), "metadata", []string{restoreStatusRestored}, []string{restoreKindFile}, nil),
792-
"revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file", "unknown"}, nil),
834+
"revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file"}, nil),
793835
"rm": operationSchema("empty", schemaRef("remove_input"), "metadata", []string{removeJSONStatusDeleted, removeJSONStatusPermanentlyDeleted}, metadataKinds(), nil),
794836
"search": operationSchema("search_input", schemaRef("empty"), "metadata", []string{searchJSONStatusFound}, metadataKinds(), nil),
795837
"share list folder": operationSchema("empty", schemaRef("empty"), "share_folder", []string{shareFolderJSONStatusListed}, []string{shareFolderJSONKindFolder}, nil),
@@ -834,7 +876,7 @@ func schemaRef(name string) *string {
834876
}
835877

836878
func metadataKinds() []string {
837-
return []string{"deleted", "file", "folder", "unknown"}
879+
return []string{"deleted", "file", "folder"}
838880
}
839881

840882
func shareLinkKinds() []string {
@@ -933,6 +975,11 @@ func assertGoldenCommandStatuses(t *testing.T, command string, schema jsonGolden
933975
t.Errorf("golden schema for %q must not allow unknown result status", command)
934976
}
935977
}
978+
for _, kind := range schema.Kinds {
979+
if kind == "unknown" {
980+
t.Errorf("golden schema for %q must not allow unknown result kind", command)
981+
}
982+
}
936983
}
937984

938985
func normalizeGoldenContract(contract jsonGoldenContract) jsonGoldenContract {

cmd/json_metadata.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"fmt"
45
"time"
56

67
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
@@ -18,11 +19,11 @@ type jsonMetadata struct {
1819
Deleted bool `json:"deleted,omitempty"`
1920
}
2021

21-
func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata {
22+
func jsonMetadataFromDropbox(metadata files.IsMetadata) (jsonMetadata, error) {
2223
switch m := metadata.(type) {
2324
case *files.FileMetadata:
2425
if m == nil {
25-
return jsonMetadata{Type: "unknown"}
26+
return jsonMetadata{}, fmt.Errorf("unexpected nil Dropbox file metadata")
2627
}
2728
size := m.Size
2829
return jsonMetadata{
@@ -34,38 +35,42 @@ func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata {
3435
Size: &size,
3536
ServerModified: jsonTime(m.ServerModified),
3637
ClientModified: jsonTime(m.ClientModified),
37-
}
38+
}, nil
3839
case *files.FolderMetadata:
3940
if m == nil {
40-
return jsonMetadata{Type: "unknown"}
41+
return jsonMetadata{}, fmt.Errorf("unexpected nil Dropbox folder metadata")
4142
}
4243
return jsonMetadata{
4344
Type: "folder",
4445
PathDisplay: m.PathDisplay,
4546
PathLower: m.PathLower,
4647
ID: m.Id,
47-
}
48+
}, nil
4849
case *files.DeletedMetadata:
4950
if m == nil {
50-
return jsonMetadata{Type: "unknown"}
51+
return jsonMetadata{}, fmt.Errorf("unexpected nil Dropbox deleted metadata")
5152
}
5253
return jsonMetadata{
5354
Type: "deleted",
5455
PathDisplay: m.PathDisplay,
5556
PathLower: m.PathLower,
5657
Deleted: true,
57-
}
58+
}, nil
5859
default:
59-
return jsonMetadata{Type: "unknown"}
60+
return jsonMetadata{}, fmt.Errorf("unexpected Dropbox metadata type %T", metadata)
6061
}
6162
}
6263

63-
func jsonMetadataListFromDropbox(entries []files.IsMetadata) []jsonMetadata {
64+
func jsonMetadataListFromDropbox(entries []files.IsMetadata) ([]jsonMetadata, error) {
6465
result := make([]jsonMetadata, 0, len(entries))
6566
for _, entry := range entries {
66-
result = append(result, jsonMetadataFromDropbox(entry))
67+
metadata, err := jsonMetadataFromDropbox(entry)
68+
if err != nil {
69+
return nil, err
70+
}
71+
result = append(result, metadata)
6772
}
68-
return result
73+
return result, nil
6974
}
7075

7176
func jsonTime(t time.Time) *string {

cmd/json_metadata_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ func TestJSONMetadataFromDropboxFile(t *testing.T) {
2424
ServerModified: serverModified,
2525
}
2626

27-
got := jsonMetadataFromDropbox(metadata)
27+
got, err := jsonMetadataFromDropbox(metadata)
28+
if err != nil {
29+
t.Fatal(err)
30+
}
2831

2932
if got.Type != "file" {
3033
t.Fatalf("Type = %q, want file", got.Type)
@@ -63,7 +66,10 @@ func TestJSONMetadataFromDropboxFolder(t *testing.T) {
6366
Id: "id:folder",
6467
}
6568

66-
got := jsonMetadataFromDropbox(metadata)
69+
got, err := jsonMetadataFromDropbox(metadata)
70+
if err != nil {
71+
t.Fatal(err)
72+
}
6773

6874
if got.Type != "folder" {
6975
t.Fatalf("Type = %q, want folder", got.Type)
@@ -84,7 +90,10 @@ func TestJSONMetadataFromDropboxDeleted(t *testing.T) {
8490
},
8591
}
8692

87-
got := jsonMetadataFromDropbox(metadata)
93+
got, err := jsonMetadataFromDropbox(metadata)
94+
if err != nil {
95+
t.Fatal(err)
96+
}
8897

8998
if got.Type != "deleted" {
9099
t.Fatalf("Type = %q, want deleted", got.Type)
@@ -93,3 +102,9 @@ func TestJSONMetadataFromDropboxDeleted(t *testing.T) {
93102
t.Fatal("Deleted = false, want true")
94103
}
95104
}
105+
106+
func TestJSONMetadataFromDropboxRejectsUnknownMetadata(t *testing.T) {
107+
if _, err := jsonMetadataFromDropbox(nil); err == nil {
108+
t.Fatal("expected nil metadata to fail")
109+
}
110+
}

cmd/json_output.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,6 @@ func normalizeJSONOperationResults(results []jsonOperationResult) []jsonOperatio
122122
}
123123

124124
func normalizeJSONOperationResult(result jsonOperationResult) jsonOperationResult {
125-
if result.Status == "" {
126-
result.Status = "unknown"
127-
}
128-
if result.Kind == "" {
129-
result.Kind = "unknown"
130-
}
131125
result.Input = normalizeJSONObject(result.Input)
132126
result.Result = normalizeJSONObject(result.Result)
133127
return result

0 commit comments

Comments
 (0)