diff --git a/cmd/speccheck/check.go b/cmd/speccheck/check.go new file mode 100644 index 0000000..438cb28 --- /dev/null +++ b/cmd/speccheck/check.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + openrpc "github.com/open-rpc/meta-schema" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// checkSpec reads the schemas from the spec and test files, then validates +// them against each other. +func checkSpec(methods map[string]*methodSchema, rts []*roundTrip, re *regexp.Regexp) error { + for _, rt := range rts { + method, ok := methods[rt.method] + if !ok { + return fmt.Errorf("undefined method: %s", rt.method) + } + // skip validator of test if name includes "invalid" as the schema + // doesn't yet support it. + // TODO(matt): create error schemas. + if strings.Contains(rt.name, "invalid") { + continue + } + if len(method.params) < len(rt.params) { + return fmt.Errorf("%s: too many parameters", method.name) + } + // Validate each parameter value against their respective schema. + for i, cd := range method.params { + if len(rt.params) <= i { + if !cd.required { + // skip missing optional values + continue + } + return fmt.Errorf("missing required parameter %s.param[%d]", rt.method, i) + } + if err := validate(&method.params[i].schema, rt.params[i], fmt.Sprintf("%s.param[%d]", rt.method, i)); err != nil { + return fmt.Errorf("unable to validate parameter: %s", err) + } + } + if err := validate(&method.result.schema, rt.response, fmt.Sprintf("%s.result", rt.method)); err != nil { + // Print out the value and schema if there is an error to further debug. + buf, _ := json.Marshal(method.result.schema) + fmt.Println(string(buf)) + fmt.Println(string(rt.response)) + fmt.Println() + return fmt.Errorf("invalid result %s\n%#v", rt.name, err) + } + } + + fmt.Println("all passing.") + return nil +} + +// validateParam validates the provided value against schema using the url base. +func validate(schema *openrpc.JSONSchemaObject, val []byte, url string) error { + // Set $schema explicitly to force jsonschema to use draft 2019-09. + draft := openrpc.Schema("https://json-schema.org/draft/2019-09/schema") + schema.Schema = &draft + + // Compile schema. + b, err := json.Marshal(schema) + if err != nil { + return fmt.Errorf("unable to marshal schema to json") + } + s, err := jsonschema.CompileString(url, string(b)) + if err != nil { + return err + } + + // Validate value + var x interface{} + json.Unmarshal(val, &x) + if err := s.Validate(x); err != nil { + return err + } + return nil +} diff --git a/cmd/speccheck/main.go b/cmd/speccheck/main.go index ec9e44c..f21c367 100644 --- a/cmd/speccheck/main.go +++ b/cmd/speccheck/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "regexp" "github.com/alexflint/go-arg" ) @@ -17,11 +18,32 @@ type Args struct { func main() { var args Args arg.MustParse(&args) - if err := checkSpec(&args); err != nil { + if err := run(&args); err != nil { exit(err) } } +func run(args *Args) error { + re, err := regexp.Compile(args.TestsRegex) + if err != nil { + return err + } + + // Read all method schemas (params+result) from the OpenRPC spec. + methods, err := parseSpec(args.SpecPath) + if err != nil { + return err + } + + // Read all tests and parse out roundtrip HTTP exchanges so they can be validated. + rts, err := readRtts(args.TestsRoot, re) + if err != nil { + return err + } + + return checkSpec(methods, rts, re) +} + func exit(err error) { if err != nil { fmt.Fprintf(os.Stderr, "%s\n", err) diff --git a/cmd/speccheck/parse.go b/cmd/speccheck/roundtrips.go similarity index 54% rename from cmd/speccheck/parse.go rename to cmd/speccheck/roundtrips.go index 057bbd3..57f1d39 100644 --- a/cmd/speccheck/parse.go +++ b/cmd/speccheck/roundtrips.go @@ -7,8 +7,6 @@ import ( "path/filepath" "regexp" "strings" - - openrpc "github.com/open-rpc/meta-schema" ) type jsonrpcMessage struct { @@ -26,9 +24,18 @@ type jsonError struct { Data interface{} `json:"data,omitempty"` } -// parseRoundTrips walks a root directory and parses round trip HTTP exchanges +// roundTrip is a single round trip interaction between a certain JSON-RPC +// method. +type roundTrip struct { + method string + name string + params [][]byte + response []byte +} + +// readRtts walks a root directory and parses round trip HTTP exchanges // from files that match the regular expression. -func parseRoundTrips(root string, re *regexp.Regexp) ([]*roundTrip, error) { +func readRtts(root string, re *regexp.Regexp) ([]*roundTrip, error) { rts := make([]*roundTrip, 0) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -47,7 +54,7 @@ func parseRoundTrips(root string, re *regexp.Regexp) ([]*roundTrip, error) { return nil // skip } // Found a good test, parse it and append to list. - test, err := parseTest(pathname, path) + test, err := readTest(pathname, path) if err != nil { return err } @@ -60,8 +67,8 @@ func parseRoundTrips(root string, re *regexp.Regexp) ([]*roundTrip, error) { return rts, nil } -// parseTest parses a single test into a slice of HTTP round trips. -func parseTest(testname string, filename string) ([]*roundTrip, error) { +// readTest reads a single test into a slice of HTTP round trips. +func readTest(testname string, filename string) ([]*roundTrip, error) { data, err := os.ReadFile(filename) if err != nil { return nil, err @@ -103,63 +110,3 @@ func parseTest(testname string, filename string) ([]*roundTrip, error) { } return rts, nil } - -// parseParamValues parses each parameter out of the raw json value in its own byte -// slice. -func parseParamValues(raw json.RawMessage) ([][]byte, error) { - if len(raw) == 0 { - return [][]byte{}, nil - } - var params []interface{} - if err := json.Unmarshal(raw, ¶ms); err != nil { - return nil, err - } - // Iterate over top-level parameter values and re-marshal them to get a - // list of json-encoded parameter values. - var out [][]byte - for _, param := range params { - buf, err := json.Marshal(param) - if err != nil { - return nil, err - } - out = append(out, buf) - } - return out, nil -} - -// parseMethodSchemas reads an OpenRPC specification and parses out each -// method's schemas. -func parseMethodSchemas(filename string) (map[string]*methodSchema, error) { - spec, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - var doc openrpc.OpenrpcDocument - if err := json.Unmarshal(spec, &doc); err != nil { - return nil, err - } - // Iterate over each method in the OpenRPC spec and pull out the parameter - // schema and result schema. - parsed := make(map[string]*methodSchema) - for _, method := range *doc.Methods { - var schema methodSchema - - // Read parameter schemas. - for _, param := range *method.MethodObject.Params { - if param.ReferenceObject != nil { - return nil, fmt.Errorf("parameter references not supported") - } - schema.params = append(schema.params, *param.ContentDescriptorObject) - } - - // Read result schema. - buf, err := json.Marshal(method.MethodObject.Result.ContentDescriptorObject.Schema) - if err != nil { - return nil, err - } - schema.result = buf - parsed[string(*method.MethodObject.Name)] = &schema - } - - return parsed, nil -} diff --git a/cmd/speccheck/spec.go b/cmd/speccheck/spec.go index f125e64..9413ef5 100644 --- a/cmd/speccheck/spec.go +++ b/cmd/speccheck/spec.go @@ -3,126 +3,136 @@ package main import ( "encoding/json" "fmt" - "regexp" - "strings" + "os" openrpc "github.com/open-rpc/meta-schema" - "github.com/santhosh-tekuri/jsonschema/v5" ) +type ContentDescriptor struct { + name string + required bool + schema openrpc.JSONSchemaObject +} + // methodSchema stores all the schemas neccessary to validate a request or // response corresponding to the method. type methodSchema struct { - // Schemas - params []openrpc.ContentDescriptorObject - result []byte -} - -// roundTrip is a single round trip interaction between a certain JSON-RPC -// method. -type roundTrip struct { - method string - name string - params [][]byte - response []byte + name string + params []*ContentDescriptor + result *ContentDescriptor } -// checkSpec reads the schemas from the spec and test files, then validates -// them against each other. -func checkSpec(args *Args) error { - re, err := regexp.Compile(args.TestsRegex) - if err != nil { - return err - } - - // Read all method schemas (params+result) from the OpenRPC spec. - methods, err := parseMethodSchemas(args.SpecPath) - if err != nil { - return err - } - - // Read all tests and parse out roundtrip HTTP exchanges so they can be validated. - rts, err := parseRoundTrips(args.TestsRoot, re) +// parseSpec reads an OpenRPC specification and parses out each +// method's schemas. +func parseSpec(filename string) (map[string]*methodSchema, error) { + doc, err := readSpec(filename) if err != nil { - return err + return nil, fmt.Errorf("unable to read spec: %v", err) } - for _, rt := range rts { - methodSchema, ok := methods[rt.method] - if !ok { - return fmt.Errorf("undefined method: %s", rt.method) + // Iterate over each method in the OpenRPC spec and pull out the parameter + // schema and result schema. + parsed := make(map[string]*methodSchema) + for _, method := range *doc.Methods { + if method.ReferenceObject != nil { + return nil, fmt.Errorf("reference object not supported, %s", *method.ReferenceObject.Ref) } - // skip validator of test if name includes "invalid" as the schema - // doesn't yet support it. - // TODO(matt): create error schemas. - if strings.Contains(rt.name, "invalid") { - continue - } - if len(methodSchema.params) < len(rt.params) { - return fmt.Errorf("too many parameters") - } - // Validate each parameter value against their respective schema. - for i, schema := range methodSchema.params { - if len(rt.params) <= i { - if schema.Required == nil || !(*schema.Required) { - // skip missing optional values - continue - } - return fmt.Errorf("missing required parameter %s.param[%d]", rt.method, i) + var ( + method = method.MethodObject + ms = methodSchema{name: string(*method.Name)} + ) + // Add parameter schemas. + for i, param := range *method.Params { + if err := checkCDOR(param); err != nil { + return nil, fmt.Errorf("%s, parameter %d: %v", *method.Name, i, err) } - raw, err := json.Marshal(schema.Schema.JSONSchemaObject) - if err != nil { - return err + required := false + if param.ContentDescriptorObject.Required != nil && *param.ContentDescriptorObject.Required { + required = true } - if err := validate(rt.params[i], raw, fmt.Sprintf("%s.param[%d]", rt.method, i)); err != nil { - return fmt.Errorf("unable to validate parameter: %s", err) + cd := &ContentDescriptor{ + name: string(*param.ContentDescriptorObject.Name), + required: required, + schema: *param.ContentDescriptorObject.Schema.JSONSchemaObject, } + ms.params = append(ms.params, cd) + } + + // Add result schema. + if method.Result == nil { + return nil, fmt.Errorf("%s: missing result", *method.Name) } - if err := validate(rt.response, methodSchema.result, fmt.Sprintf("%s.result", rt.method)); err != nil { - // Print out the value and schema if there is an error to further debug. - var schema interface{} - json.Unmarshal(methodSchema.result, &schema) - buf, _ := json.MarshalIndent(schema, "", " ") - fmt.Println(string(buf)) - fmt.Println(string(methodSchema.result)) - fmt.Println(string(rt.response)) - return fmt.Errorf("invalid result %s: %w", rt.name, err) + cdor := openrpc.ContentDescriptorOrReference{ + ContentDescriptorObject: method.Result.ContentDescriptorObject, + ReferenceObject: method.Result.ReferenceObject, } + if err := checkCDOR(cdor); err != nil { + return nil, fmt.Errorf("%s: %v", *method.Name, err) + } + obj := method.Result.ContentDescriptorObject + required := false + if obj.Required != nil && *obj.Required { + required = true + } + ms.result = &ContentDescriptor{ + name: string(*obj.Name), + required: required, + schema: *obj.Schema.JSONSchemaObject, + } + parsed[string(*method.Name)] = &ms } - fmt.Println("all passing.") - return nil + return parsed, nil } -// validateParam validates the provided value against schema using the url base. -func validate(val []byte, baseSchema []byte, url string) error { - // Unmarshal value into interface{} so that validator can properly reflect - // the contents. - var x interface{} - if err := json.Unmarshal(val, &x); len(val) != 0 && err != nil { - return fmt.Errorf("unable to unmarshal testcase: %w", err) +// parseParamValues parses each parameter out of the raw json value in its own byte +// slice. +func parseParamValues(raw json.RawMessage) ([][]byte, error) { + if len(raw) == 0 { + return [][]byte{}, nil } - // Add $schema explicitly to force jsonschema to use draft 2019-09. - schema, err := appendDraft201909(baseSchema) - if err != nil { - return fmt.Errorf("unable to append draft: %w", err) + var params []interface{} + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, err } - s, err := jsonschema.CompileString(url, string(schema)) - if err != nil { - return fmt.Errorf("unable to compile schema: %w", err) + // Iterate over top-level parameter values and re-marshal them to get a + // list of json-encoded parameter values. + var out [][]byte + for _, param := range params { + buf, err := json.Marshal(param) + if err != nil { + return nil, err + } + out = append(out, buf) } - if err := s.Validate(x); err != nil { - return fmt.Errorf("validation error: %w", err) + return out, nil +} + +func checkCDOR(obj openrpc.ContentDescriptorOrReference) error { + if obj.ReferenceObject != nil { + return fmt.Errorf("references not supported") + } + if obj.ContentDescriptorObject == nil { + return fmt.Errorf("missing content descriptor") + } + cd := obj.ContentDescriptorObject + if cd.Name == nil { + return fmt.Errorf("missing name") + } + if cd.Schema == nil || cd.Schema.JSONSchemaObject == nil { + return fmt.Errorf("missing schema") } return nil } -// appendDraft201909 adds $schema = draft 2019-09 to the schema. -func appendDraft201909(schema []byte) ([]byte, error) { - var out map[string]interface{} - if err := json.Unmarshal(schema, &out); err != nil { +func readSpec(path string) (*openrpc.OpenrpcDocument, error) { + spec, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc openrpc.OpenrpcDocument + if err := json.Unmarshal(spec, &doc); err != nil { return nil, err } - out["$schema"] = "https://json-schema.org/draft/2019-09/schema" - return json.Marshal(out) + return &doc, nil }