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
99 changes: 91 additions & 8 deletions cmd/json_command_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ func TestPublicCommandSuccessSchemaRejectsInvalidStatus(t *testing.T) {
}
}

func TestPublicCommandSuccessSchemaRejectsInvalidPrimitiveType(t *testing.T) {
schema := loadJSONValueFile(t, "../docs/json-schema/v1/commands.schema.json")
validator := newSubsetJSONSchemaValidator(t, schema)

value := decodeJSONValue(t, loadJSONGoldenSuccessOutputs(t)["put"])
root := value.(map[string]any)
input := root["input"].(map[string]any)
input["recursive"] = "false"

if err := validator.validate(schema, value, "$"); err == nil {
t.Fatal("invalid primitive type validated successfully")
}
}

func TestJSONHelpManifestsValidateAgainstPublicManifestSchema(t *testing.T) {
schema := loadJSONValueFile(t, "../docs/json-schema/v1/manifest.schema.json")
validator := newSubsetJSONSchemaValidator(t, schema)

RootCmd.InitDefaultHelpCmd()
for _, command := range publicCommandSubtree(RootCmd) {
manifest := jsonCommandManifestFor(command)
value := normalizeJSONValue(t, manifest)
if err := validator.validate(schema, value, manifest.Path); err != nil {
t.Fatalf("manifest %q does not validate: %v", manifest.Path, err)
}
}
}

func loadCommandSchemaCatalog(t *testing.T) schemagen.CommandCatalog {
t.Helper()

Expand Down Expand Up @@ -209,11 +237,12 @@ func (v subsetJSONSchemaValidator) validateObjectSchema(schema map[string]any, v
}

if typeValue, ok := schema["type"]; ok {
typeName, ok := typeValue.(string)
if !ok {
return fmt.Errorf("%s type is %T, want string", path, typeValue)
if err := validateJSONSchemaTypeValue(path, value, typeValue); err != nil {
return err
}
if err := validateJSONSchemaType(path, value, typeName); err != nil {
}
if minimumValue, ok := schema["minimum"]; ok {
if err := validateJSONSchemaMinimum(path, value, minimumValue); err != nil {
return err
}
}
Expand All @@ -236,11 +265,27 @@ func (v subsetJSONSchemaValidator) validateObjectSchema(schema map[string]any, v
return fmt.Errorf("%s properties on non-object %T", path, value)
}
properties := propertiesValue.(map[string]any)
if additionalProperties, ok := schema["additionalProperties"].(bool); ok && !additionalProperties {
for name := range object {
if _, ok := properties[name]; !ok {
return fmt.Errorf("%s has unexpected property %q", path, name)
if additionalProperties, ok := schema["additionalProperties"]; ok {
switch additionalProperties := additionalProperties.(type) {
case bool:
if !additionalProperties {
for name := range object {
if _, ok := properties[name]; !ok {
return fmt.Errorf("%s has unexpected property %q", path, name)
}
}
}
case map[string]any:
for name, propertyValue := range object {
if _, ok := properties[name]; ok {
continue
}
if err := v.validate(additionalProperties, propertyValue, path+"."+name); err != nil {
return err
}
}
default:
return fmt.Errorf("%s additionalProperties is %T, want bool or object", path, additionalProperties)
}
}
for name, propertySchema := range properties {
Expand Down Expand Up @@ -287,6 +332,29 @@ func (v subsetJSONSchemaValidator) resolveRef(ref string) (any, error) {
return current, nil
}

func validateJSONSchemaTypeValue(path string, value any, typeValue any) error {
switch typeValue := typeValue.(type) {
case string:
return validateJSONSchemaType(path, value, typeValue)
case []any:
var errors []string
for _, item := range typeValue {
typeName, ok := item.(string)
if !ok {
return fmt.Errorf("%s type entry is %T, want string", path, item)
}
if err := validateJSONSchemaType(path, value, typeName); err == nil {
return nil
} else {
errors = append(errors, err.Error())
}
}
return fmt.Errorf("%s did not match any allowed type: %s", path, strings.Join(errors, "; "))
default:
return fmt.Errorf("%s type is %T, want string or array", path, typeValue)
}
}

func validateJSONSchemaType(path string, value any, typeName string) error {
switch typeName {
case "object":
Expand Down Expand Up @@ -319,3 +387,18 @@ func validateJSONSchemaType(path string, value any, typeName string) error {
}
return nil
}

func validateJSONSchemaMinimum(path string, value any, minimumValue any) error {
minimum, ok := minimumValue.(float64)
if !ok {
return fmt.Errorf("%s minimum is %T, want number", path, minimumValue)
}
number, ok := value.(float64)
if !ok {
return fmt.Errorf("%s minimum on non-number %T", path, value)
}
if number < minimum {
return fmt.Errorf("%s = %v, want >= %v", path, number, minimum)
}
return nil
}
6 changes: 3 additions & 3 deletions cmd/json_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput {
"help": newJSONOperationOutput(jsonHelpInput{Help: true, Path: "ls"}, []jsonOperationResult{
newJSONOperationResult(jsonHelpStatusDescribed, jsonHelpKindCommand, nil, jsonCommandManifestFor(lsCmd)),
}, nil),
"ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{
"ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "type", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{
newJSONOperationResult(lsJSONStatusListed, file.Type, nil, file),
}, nil),
"logout": newJSONOperationOutput(nil, []jsonOperationResult{
Expand All @@ -677,7 +677,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput {
"rm": newJSONOperationOutput(nil, []jsonOperationResult{
newJSONOperationResult(removeJSONStatusDeleted, file.Type, removeInput{Path: "/Reports/old.pdf", Permanent: false, Recursive: false, Force: false}, file),
}, nil),
"search": newJSONOperationOutput(searchInput{Query: "report", Path: "/Reports", Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{
"search": newJSONOperationOutput(searchInput{Query: "report", Path: "/Reports", Long: true, Sort: "type", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{
newJSONOperationResult(searchJSONStatusFound, folder.Type, nil, folder),
}, nil),
"share list folder": newJSONOperationOutput(shareFolderListInput{}, []jsonOperationResult{
Expand All @@ -686,7 +686,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput {
"share list link": newJSONOperationOutput(shareLinkListInput{Path: "/Reports/old.pdf", DirectOnly: true}, []jsonOperationResult{
shareLinkJSONOperationResult(shareLinkJSONStatusListed, sharedLink),
}, []jsonWarning{{Code: jsonWarningCodeDeprecatedCommand, Message: "use `dbxcli share-link list` instead"}}),
"share-link create": newJSONOperationOutput(shareLinkCreateInput{Path: "/Reports/old.pdf", Access: "viewer", Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true}, []jsonOperationResult{
"share-link create": newJSONOperationOutput(shareLinkCreateInput{Path: "/Reports/old.pdf", Access: "max", Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true}, []jsonOperationResult{
shareLinkJSONOperationResult(shareLinkJSONStatusCreated, sharedLink),
}, nil),
"share-link download": newJSONOperationOutput(shareLinkDownloadInput{URL: sharedLink.URL, Target: "old.pdf", Path: "/old.pdf", Recursive: false, Password: true}, []jsonOperationResult{
Expand Down
6 changes: 3 additions & 3 deletions cmd/testdata/json_contract/success_outputs.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,18 +419,18 @@
],
"warnings": []
},
"ls": {"ok":true,"schema_version":"1","command":"ls","input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]},
"ls": {"ok":true,"schema_version":"1","command":"ls","input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"type","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]},
"logout": {"ok":true,"schema_version":"1","command":"logout","input":{},"results":[{"status":"logged_out","kind":"auth","input":{},"result":{"removed_saved_credentials":true,"remote_token_revoked":true}}],"warnings":[]},
"mkdir": {"ok":true,"schema_version":"1","command":"mkdir","input":{"path":"/Reports/new","parents":true},"results":[{"status":"created","kind":"folder","input":{"path":"/Reports/new","parents":true},"result":{"type":"folder","path_display":"/Reports/new","path_lower":"/reports/new","id":"id:folder"}}],"warnings":[]},
"mv": {"ok":true,"schema_version":"1","command":"mv","input":{},"results":[{"input":{"from_path":"/Reports/copy.pdf","to_path":"/Reports/moved.pdf"},"result":{"type":"file","path_display":"/Reports/moved.pdf","path_lower":"/reports/moved.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"moved","kind":"file"}],"warnings":[]},
"put": {"ok":true,"schema_version":"1","command":"put","input":{"source":"README.md","target":"/README.md","recursive":true,"if_exists":"overwrite","stdin":false},"results":[{"status":"uploaded","kind":"file","input":{"source":"README.md","target":"/README.md"},"result":{"type":"file","path_display":"/README.md","path_lower":"/readme.md","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[{"code":"skipped_symlink","message":"skipped symlink","path":"docs/link"}]},
"restore": {"ok":true,"schema_version":"1","command":"restore","input":{"path":"/Reports/old.pdf","revision":"015f"},"results":[{"status":"restored","kind":"file","input":{"path":"/Reports/old.pdf","revision":"015f"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]},
"revs": {"ok":true,"schema_version":"1","command":"revs","input":{"path":"/Reports/old.pdf","long":true,"time":"server","time_format":"2006-01-02"},"results":[{"status":"revision","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]},
"rm": {"ok":true,"schema_version":"1","command":"rm","input":{},"results":[{"input":{"path":"/Reports/old.pdf","permanent":false,"recursive":false,"force":false},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"deleted","kind":"file"}],"warnings":[]},
"search": {"ok":true,"schema_version":"1","command":"search","input":{"query":"report","path":"/Reports","content":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"},"input":{}}],"warnings":[]},
"search": {"ok":true,"schema_version":"1","command":"search","input":{"query":"report","path":"/Reports","content":false,"long":true,"sort":"type","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"},"input":{}}],"warnings":[]},
"share list folder": {"ok":true,"schema_version":"1","command":"share list folder","input":{},"results":[{"status":"listed","kind":"shared_folder","result":{"type":"shared_folder","name":"Reports","path_lower":"/reports","shared_folder_id":"sfid:reports","preview_url":"https://www.dropbox.com/preview","access_type":"owner","is_inside_team_folder":false,"is_team_folder":true,"owner_display_names":["Ada Lovelace"],"parent_shared_folder_id":"sfid:parent","parent_folder_name":"Parent","time_invited":"2026-06-25T10:00:00Z","access_inheritance":"inherit"},"input":{}}],"warnings":[]},
"share list link": {"ok":true,"schema_version":"1","command":"share list link","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[{"code":"deprecated_command","message":"use `dbxcli share-link list` instead"}]},
"share-link create": {"ok":true,"schema_version":"1","command":"share-link create","input":{"path":"/Reports/old.pdf","access":"viewer","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]},
"share-link create": {"ok":true,"schema_version":"1","command":"share-link create","input":{"path":"/Reports/old.pdf","access":"max","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]},
"share-link download": {"ok":true,"schema_version":"1","command":"share-link download","input":{"url":"https://www.dropbox.com/s/example/old.pdf","target":"old.pdf","path":"/old.pdf","password":true},"results":[{"status":"downloaded","kind":"file","result":{"target":"old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]},
"share-link info": {"ok":true,"schema_version":"1","command":"share-link info","input":{"url":"https://www.dropbox.com/s/example/old.pdf","path":"/old.pdf","password":true},"results":[{"status":"found","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]},
"share-link list": {"ok":true,"schema_version":"1","command":"share-link list","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]},
Expand Down
4 changes: 2 additions & 2 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ a non-zero status:
The full JSON command catalog, stable error codes, and schemas live in
[json-schema/v1](json-schema/v1/README.md).
Use `commands.schema.json` from that directory when a caller needs
command-specific success validation for `input`, `results`, statuses, kinds,
and warning codes.
command-specific success validation for `input`, `results`, primitive field
types, statuses, kinds, and warning codes.

## JSON help manifest

Expand Down
11 changes: 6 additions & 5 deletions docs/json-schema/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,17 @@ continues to print text help, and shell-completion protocol commands remain
text-only.

The current JSON-enabled command paths are listed in `commands.json`.
`commands.schema.json` is generated from that catalog:
`commands.schema.json` is generated from that catalog plus schema metadata in
the generator:

```sh
go run ./tools/gen-json-schemas
```

The command-specific schema intentionally starts with field-set and enum
validation. It locks which fields may appear, which result statuses/kinds are
valid for each command, and which warning codes are valid. It does not yet
describe every nested primitive type.
The command-specific schema locks which fields may appear, which fields are
required when stable, primitive field types, stable nested objects, result
statuses/kinds, and warning codes. It intentionally avoids over-modeling
Dropbox SDK enum values that dbxcli does not own.

## Command Manifest v1

Expand Down
Loading
Loading