diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffec3e3..ac5738a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,9 @@ jobs: - run: go run ./tools/gen-docs - run: go run ./tools/gen-json-schemas - run: git diff --exit-code docs/commands - - run: git diff --exit-code docs/json-schema/v1/commands.schema.json + - run: git diff --exit-code docs/json-schema/v1 - run: test -z "$(git status --porcelain -- docs/commands)" - - run: test -z "$(git status --porcelain -- docs/json-schema/v1/commands.schema.json)" + - run: test -z "$(git status --porcelain -- docs/json-schema/v1)" chocolatey: name: Chocolatey package diff --git a/cmd/json_command_schema_test.go b/cmd/json_command_schema_test.go index 0bdcb50..9510c60 100644 --- a/cmd/json_command_schema_test.go +++ b/cmd/json_command_schema_test.go @@ -15,15 +15,15 @@ package cmd import ( + "bytes" "encoding/json" - "fmt" - "math" + "errors" "os" "reflect" - "strings" "testing" schemagen "github.com/dropbox/dbxcli/v3/internal/jsonschema" + "github.com/santhosh-tekuri/jsonschema/v6" ) func TestPublicJSONCommandSuccessSchemaMatchesGeneratedCatalog(t *testing.T) { @@ -46,13 +46,12 @@ func TestPublicJSONCommandSuccessSchemaMatchesGeneratedCatalog(t *testing.T) { } func TestGoldenSuccessOutputsValidateAgainstPublicCommandSuccessSchema(t *testing.T) { - schema := loadJSONValueFile(t, "../docs/json-schema/v1/commands.schema.json") - validator := newSubsetJSONSchemaValidator(t, schema) + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") for command, raw := range loadJSONGoldenSuccessOutputs(t) { t.Run(command, func(t *testing.T) { - value := decodeJSONValue(t, raw) - if err := validator.validate(schema, value, "$"); err != nil { + value := decodeJSONValueForSchema(t, raw) + if err := schema.Validate(value); err != nil { t.Fatalf("golden success output does not validate: %v", err) } }) @@ -60,48 +59,69 @@ func TestGoldenSuccessOutputsValidateAgainstPublicCommandSuccessSchema(t *testin } func TestPublicCommandSuccessSchemaRejectsInvalidStatus(t *testing.T) { - schema := loadJSONValueFile(t, "../docs/json-schema/v1/commands.schema.json") - validator := newSubsetJSONSchemaValidator(t, schema) + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") value := decodeJSONValue(t, loadJSONGoldenSuccessOutputs(t)["cp"]) root := value.(map[string]any) results := root["results"].([]any) first := results[0].(map[string]any) first["status"] = "not-a-real-status" + value = normalizeJSONValueForSchema(t, value) - if err := validator.validate(schema, value, "$"); err == nil { + if err := schema.Validate(value); err == nil { t.Fatal("invalid status validated successfully") } } func TestPublicCommandSuccessSchemaRejectsInvalidPrimitiveType(t *testing.T) { - schema := loadJSONValueFile(t, "../docs/json-schema/v1/commands.schema.json") - validator := newSubsetJSONSchemaValidator(t, schema) + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") value := decodeJSONValue(t, loadJSONGoldenSuccessOutputs(t)["put"]) root := value.(map[string]any) input := root["input"].(map[string]any) input["recursive"] = "false" + value = normalizeJSONValueForSchema(t, value) - if err := validator.validate(schema, value, "$"); err == nil { + if err := schema.Validate(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) + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/manifest.schema.json") RootCmd.InitDefaultHelpCmd() for _, command := range publicCommandSubtree(RootCmd) { manifest := jsonCommandManifestFor(command) - value := normalizeJSONValue(t, manifest) - if err := validator.validate(schema, value, manifest.Path); err != nil { + value := normalizeJSONValueForSchema(t, manifest) + if err := schema.Validate(value); err != nil { t.Fatalf("manifest %q does not validate: %v", manifest.Path, err) } } } +func TestJSONErrorExamplesValidateAgainstPublicErrorSchema(t *testing.T) { + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/error.schema.json") + examples := []struct { + name string + err error + }{ + {name: "generic", err: errors.New("failed")}, + {name: "invalid arguments", err: invalidArgumentsErrorfWithDetails("invalid --if-exists %q", flagValueErrorDetails("if-exists", "replace"), "replace")}, + {name: "path conflict", err: pathConflictErrorWithPath("/file", "path exists: %s", "/file")}, + {name: "auth required", err: missingAccessTokenError(tokenPersonal)}, + } + + for _, example := range examples { + t.Run(example.name, func(t *testing.T) { + value := normalizeJSONValueForSchema(t, newJSONErrorResponse(RootCmd, example.err)) + if err := schema.Validate(value); err != nil { + t.Fatalf("JSON error response does not validate: %v", err) + } + }) + } +} + func loadCommandSchemaCatalog(t *testing.T) schemagen.CommandCatalog { t.Helper() @@ -147,258 +167,48 @@ func normalizeJSONValue(t *testing.T, value any) any { return decodeJSONValue(t, data) } -type subsetJSONSchemaValidator struct { - root any -} - -func newSubsetJSONSchemaValidator(t *testing.T, root any) subsetJSONSchemaValidator { +func compileJSONSchemaFile(t *testing.T, file string) *jsonschema.Schema { t.Helper() - if _, ok := root.(map[string]any); !ok { - t.Fatalf("schema root is %T, want object", root) - } - return subsetJSONSchemaValidator{root: root} -} - -func (v subsetJSONSchemaValidator) validate(schema any, value any, path string) error { - switch schema := schema.(type) { - case bool: - if schema { - return nil - } - return fmt.Errorf("%s is disallowed by false schema", path) - case map[string]any: - return v.validateObjectSchema(schema, value, path) - default: - return fmt.Errorf("%s schema has unsupported type %T", path, schema) - } -} - -func (v subsetJSONSchemaValidator) validateObjectSchema(schema map[string]any, value any, path string) error { - if refValue, ok := schema["$ref"]; ok { - ref, ok := refValue.(string) - if !ok { - return fmt.Errorf("%s $ref is %T, want string", path, refValue) - } - resolved, err := v.resolveRef(ref) - if err != nil { - return fmt.Errorf("%s: %w", path, err) - } - return v.validate(resolved, value, path) - } - - if allOf, ok := schema["allOf"]; ok { - items, ok := allOf.([]any) - if !ok { - return fmt.Errorf("%s allOf is %T, want array", path, allOf) - } - for i, item := range items { - if err := v.validate(item, value, fmt.Sprintf("%s allOf[%d]", path, i)); err != nil { - return err - } - } - } - if oneOf, ok := schema["oneOf"]; ok { - items, ok := oneOf.([]any) - if !ok { - return fmt.Errorf("%s oneOf is %T, want array", path, oneOf) - } - matches := 0 - for _, item := range items { - if err := v.validate(item, value, path); err == nil { - matches++ - } - } - if matches != 1 { - return fmt.Errorf("%s matched %d oneOf schemas, want 1", path, matches) - } - return nil + data := readJSONFile(t, file) + doc := decodeJSONValueForSchema(t, data) + object, ok := doc.(map[string]any) + if !ok { + t.Fatalf("%s schema root is %T, want object", file, doc) } - - if constValue, ok := schema["const"]; ok && !reflect.DeepEqual(value, constValue) { - return fmt.Errorf("%s = %v, want const %v", path, value, constValue) + id, ok := object["$id"].(string) + if !ok || id == "" { + t.Fatalf("%s schema has no $id", file) } - if enumValue, ok := schema["enum"]; ok { - enum, ok := enumValue.([]any) - if !ok { - return fmt.Errorf("%s enum is %T, want array", path, enumValue) - } - found := false - for _, allowed := range enum { - if reflect.DeepEqual(value, allowed) { - found = true - break - } - } - if !found { - return fmt.Errorf("%s = %v, want one of %v", path, value, enum) - } - } - - if typeValue, ok := schema["type"]; ok { - if err := validateJSONSchemaTypeValue(path, value, typeValue); err != nil { - return err - } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource(id, doc); err != nil { + t.Fatalf("add schema resource %s: %v", file, err) } - if minimumValue, ok := schema["minimum"]; ok { - if err := validateJSONSchemaMinimum(path, value, minimumValue); err != nil { - return err - } - } - - object, hasObject := value.(map[string]any) - if requiredValue, ok := schema["required"]; ok { - if !hasObject { - return fmt.Errorf("%s required fields on non-object %T", path, value) - } - for _, field := range requiredValue.([]any) { - name := field.(string) - if _, ok := object[name]; !ok { - return fmt.Errorf("%s missing required field %q", path, name) - } - } - } - - if propertiesValue, ok := schema["properties"]; ok { - if !hasObject { - return fmt.Errorf("%s properties on non-object %T", path, value) - } - properties := propertiesValue.(map[string]any) - 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 { - if propertyValue, ok := object[name]; ok { - if err := v.validate(propertySchema, propertyValue, path+"."+name); err != nil { - return err - } - } - } - } - - if itemsValue, ok := schema["items"]; ok { - array, ok := value.([]any) - if !ok { - return fmt.Errorf("%s items on non-array %T", path, value) - } - for i, item := range array { - if err := v.validate(itemsValue, item, fmt.Sprintf("%s[%d]", path, i)); err != nil { - return err - } - } + schema, err := compiler.Compile(id) + if err != nil { + t.Fatalf("compile schema %s: %v", file, err) } - - return nil + return schema } -func (v subsetJSONSchemaValidator) resolveRef(ref string) (any, error) { - if !strings.HasPrefix(ref, "#/") { - return nil, fmt.Errorf("unsupported ref %q", ref) - } - current := v.root - for _, token := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { - token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") - object, ok := current.(map[string]any) - if !ok { - return nil, fmt.Errorf("ref %q traversed into %T", ref, current) - } - next, ok := object[token] - if !ok { - return nil, fmt.Errorf("ref %q missing token %q", ref, token) - } - current = next - } - return current, nil -} +func decodeJSONValueForSchema(t *testing.T, data []byte) any { + t.Helper() -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) + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode JSON value for schema validation: %v", err) } + return value } -func validateJSONSchemaType(path string, value any, typeName string) error { - switch typeName { - case "object": - if _, ok := value.(map[string]any); !ok { - return fmt.Errorf("%s is %T, want object", path, value) - } - case "array": - if _, ok := value.([]any); !ok { - return fmt.Errorf("%s is %T, want array", path, value) - } - case "string": - if _, ok := value.(string); !ok { - return fmt.Errorf("%s is %T, want string", path, value) - } - case "boolean": - if _, ok := value.(bool); !ok { - return fmt.Errorf("%s is %T, want boolean", path, value) - } - case "integer": - number, ok := value.(float64) - if !ok || number != math.Trunc(number) { - return fmt.Errorf("%s is %T:%v, want integer", path, value, value) - } - case "number": - if _, ok := value.(float64); !ok { - return fmt.Errorf("%s is %T, want number", path, value) - } - default: - return fmt.Errorf("%s has unsupported schema type %q", path, typeName) - } - return nil -} +func normalizeJSONValueForSchema(t *testing.T, value any) any { + t.Helper() -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) + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal JSON value for schema validation: %v", err) } - return nil + return decodeJSONValueForSchema(t, data) } diff --git a/cmd/output_test.go b/cmd/output_test.go index cd55342..b9f686c 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -1016,6 +1016,11 @@ func TestJSONErrorCodeDoesNotClassifyPlainValidationStrings(t *testing.T) { func decodeJSONErrorResponse(t *testing.T, value string) jsonErrorResponse { t.Helper() + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/error.schema.json") + if err := schema.Validate(decodeJSONValueForSchema(t, []byte(value))); err != nil { + t.Fatalf("JSON error response does not validate: %v\noutput: %s", err, value) + } + var got jsonErrorResponse if err := json.Unmarshal([]byte(value), &got); err != nil { t.Fatalf("decode JSON error response: %v\noutput: %s", err, value) diff --git a/docs/automation.md b/docs/automation.md index eef7cc9..f5a1600 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -77,6 +77,21 @@ Use `commands.schema.json` from that directory when a caller needs command-specific success validation for `input`, `results`, primitive field types, statuses, kinds, and warning codes. +## Schema-first automation + +Automation should treat the CLI and schemas as the stable interface: + +* Use `dbxcli --help --output=json` for command discovery. +* Use each manifest's `input_schema` to validate arguments and flags before + building a CLI invocation. +* Use `commands.schema.json` to validate successful JSON responses. +* Use `error.schema.json` to validate JSON error responses. +* Prefer schema URLs from a pinned release tag when reproducibility matters. + +dbxcli intentionally does not expose a separate machine protocol. Tools should +invoke the CLI, read stdout as JSON in `--output=json` mode, and treat stderr as +status, progress, warnings, diagnostics, and verbose logs. + ## JSON help manifest JSON help is the machine-readable command discovery surface. It is separate diff --git a/go.mod b/go.mod index 2483ff1..c42ffd1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 golang.org/x/oauth2 v0.36.0 @@ -19,4 +20,5 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index bf8f5b9..d94d889 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU= github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -120,6 +122,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -248,6 +252,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=