From 694596fd8fcf9247291cfdcfbea97d5ca4b43e49 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Sun, 8 Oct 2023 12:32:40 -0400 Subject: [PATCH] Updated support for most of usescases in #26 bumped `libopenapi` to `v0.12.1` Signed-off-by: Dave Shanley --- go.mod | 2 +- go.sum | 4 +- schema_validation/validate_document.go | 18 +- schema_validation/validate_schema.go | 218 ++++++++++++++-------- schema_validation/validate_schema_test.go | 112 +++++++++++ 5 files changed, 264 insertions(+), 90 deletions(-) diff --git a/go.mod b/go.mod index e41feca..69b67d0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/pb33f/libopenapi-validator go 1.19 require ( - github.com/pb33f/libopenapi v0.10.6 + github.com/pb33f/libopenapi v0.12.1 github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 github.com/stretchr/testify v1.8.0 github.com/vmware-labs/yaml-jsonpath v0.3.2 diff --git a/go.sum b/go.sum index 37d6abf..a13b5b5 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/pb33f/libopenapi v0.10.6 h1:46iGQqoMm6o5gYK34ER/dpxsSYdh0+76U3+obm5uEyk= -github.com/pb33f/libopenapi v0.10.6/go.mod h1:s8uj6S0DjWrwZVj20ianJBz+MMjHAbeeRYNyo9ird74= +github.com/pb33f/libopenapi v0.12.1 h1:DRhgbg1t32OSYoHT/Bk3stVruqquAkKj+S+OqOQIbBc= +github.com/pb33f/libopenapi v0.12.1/go.mod h1:s8uj6S0DjWrwZVj20ianJBz+MMjHAbeeRYNyo9ird74= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index df14287..7ff03a2 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -4,9 +4,10 @@ package schema_validation import ( + "errors" "fmt" "github.com/pb33f/libopenapi" - "github.com/pb33f/libopenapi-validator/errors" + liberrors "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/santhosh-tekuri/jsonschema/v5" _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" @@ -16,11 +17,11 @@ import ( // ValidateOpenAPIDocument will validate an OpenAPI document against the OpenAPI 2, 3.0 and 3.1 schemas (depending on version) // It will return true if the document is valid, false if it is not and a slice of ValidationError pointers. -func ValidateOpenAPIDocument(doc libopenapi.Document) (bool, []*errors.ValidationError) { +func ValidateOpenAPIDocument(doc libopenapi.Document) (bool, []*liberrors.ValidationError) { info := doc.GetSpecInfo() loadedSchema := info.APISchema - var validationErrors []*errors.ValidationError + var validationErrors []*liberrors.ValidationError decodedDocument := *info.SpecJSON compiler := jsonschema.NewCompiler() @@ -29,11 +30,12 @@ func ValidateOpenAPIDocument(doc libopenapi.Document) (bool, []*errors.Validatio scErrs := jsch.Validate(decodedDocument) - var schemaValidationErrors []*errors.SchemaValidationFailure + var schemaValidationErrors []*liberrors.SchemaValidationFailure if scErrs != nil { - if jk, ok := scErrs.(*jsonschema.ValidationError); ok { + var jk *jsonschema.ValidationError + if errors.As(scErrs, &jk) { // flatten the validationErrors schFlatErrs := jk.BasicOutput().Errors @@ -53,7 +55,7 @@ func ValidateOpenAPIDocument(doc libopenapi.Document) (bool, []*errors.Validatio // locate the violated property in the schema located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) - violation := &errors.SchemaValidationFailure{ + violation := &liberrors.SchemaValidationFailure{ Reason: er.Error, Location: er.InstanceLocation, DeepLocation: er.KeywordLocation, @@ -82,13 +84,13 @@ func ValidateOpenAPIDocument(doc libopenapi.Document) (bool, []*errors.Validatio } // add the error to the list - validationErrors = append(validationErrors, &errors.ValidationError{ + validationErrors = append(validationErrors, &liberrors.ValidationError{ ValidationType: helpers.Schema, Message: "Document does not pass validation", Reason: fmt.Sprintf("OpenAPI document is not valid according "+ "to the %s specification", info.Version), SchemaValidationErrors: schemaValidationErrors, - HowToFix: errors.HowToFixInvalidSchema, + HowToFix: liberrors.HowToFixInvalidSchema, }) } if len(validationErrors) > 0 { diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index d015f91..6587fa4 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -4,9 +4,11 @@ package schema_validation import ( + _ "embed" "encoding/json" + "errors" "fmt" - "github.com/pb33f/libopenapi-validator/errors" + liberrors "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/utils" @@ -30,15 +32,15 @@ import ( type SchemaValidator interface { // ValidateSchemaString accepts a schema object to validate against, and a JSON/YAML blob that is defined as a string. - ValidateSchemaString(schema *base.Schema, payload string) (bool, []*errors.ValidationError) + ValidateSchemaString(schema *base.Schema, payload string) (bool, []*liberrors.ValidationError) // ValidateSchemaObject accepts a schema object to validate against, and an object, created from unmarshalled JSON/YAML. // This is a pre-decoded object that will skip the need to unmarshal a string of JSON/YAML. - ValidateSchemaObject(schema *base.Schema, payload interface{}) (bool, []*errors.ValidationError) + ValidateSchemaObject(schema *base.Schema, payload interface{}) (bool, []*liberrors.ValidationError) // ValidateSchemaBytes accepts a schema object to validate against, and a byte slice containing a schema to // validate against. - ValidateSchemaBytes(schema *base.Schema, payload []byte) (bool, []*errors.ValidationError) + ValidateSchemaBytes(schema *base.Schema, payload []byte) (bool, []*liberrors.ValidationError) } var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`) @@ -53,33 +55,41 @@ func NewSchemaValidator() SchemaValidator { return &schemaValidator{logger: logger.Sugar()} } -func (s *schemaValidator) ValidateSchemaString(schema *base.Schema, payload string) (bool, []*errors.ValidationError) { +func (s *schemaValidator) ValidateSchemaString(schema *base.Schema, payload string) (bool, []*liberrors.ValidationError) { return validateSchema(schema, []byte(payload), nil, s.logger) } -func (s *schemaValidator) ValidateSchemaObject(schema *base.Schema, payload interface{}) (bool, []*errors.ValidationError) { +func (s *schemaValidator) ValidateSchemaObject(schema *base.Schema, payload interface{}) (bool, []*liberrors.ValidationError) { return validateSchema(schema, nil, payload, s.logger) } -func (s *schemaValidator) ValidateSchemaBytes(schema *base.Schema, payload []byte) (bool, []*errors.ValidationError) { +func (s *schemaValidator) ValidateSchemaBytes(schema *base.Schema, payload []byte) (bool, []*liberrors.ValidationError) { return validateSchema(schema, payload, nil, s.logger) } var renderLock = &sync.Mutex{} -func validateSchema(schema *base.Schema, payload []byte, decodedObject interface{}, log *zap.SugaredLogger) (bool, []*errors.ValidationError) { +func validateSchema(schema *base.Schema, payload []byte, decodedObject interface{}, log *zap.SugaredLogger) (bool, []*liberrors.ValidationError) { - var validationErrors []*errors.ValidationError + var validationErrors []*liberrors.ValidationError if schema == nil { log.Infoln("schema is empty and cannot be validated. This generally means the schema is missing from the spec, or could not be read.") return false, validationErrors } + // extract index of schema, and check the version + schemaIndex := schema.GoLow().Index + var renderedSchema []byte + // render the schema, to be used for validation, stop this from running concurrently, mutations are made to state // and, it will cause async issues. renderLock.Lock() - renderedSchema, _ := schema.RenderInline() + version := float32(0.0) + if schemaIndex != nil { + version = schemaIndex.GetConfig().SpecInfo.VersionNumeric + renderedSchema, _ = schema.RenderInline() + } renderLock.Unlock() jsonSchema, _ := utils.ConvertYAMLtoJSON(renderedSchema) @@ -89,21 +99,21 @@ func validateSchema(schema *base.Schema, payload []byte, decodedObject interface if err != nil { // cannot decode the request body, so it's not valid - violation := &errors.SchemaValidationFailure{ + violation := &liberrors.SchemaValidationFailure{ Reason: err.Error(), Location: "unavailable", ReferenceSchema: string(renderedSchema), ReferenceObject: string(payload), } - validationErrors = append(validationErrors, &errors.ValidationError{ + validationErrors = append(validationErrors, &liberrors.ValidationError{ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema, Message: "schema does not pass validation", Reason: fmt.Sprintf("The schema cannot be decoded: %s", err.Error()), SpecLine: 1, SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: errors.HowToFixInvalidSchema, + SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, + HowToFix: liberrors.HowToFixInvalidSchema, Context: string(renderedSchema), // attach the rendered schema to the error }) return false, validationErrors @@ -111,86 +121,72 @@ func validateSchema(schema *base.Schema, payload []byte, decodedObject interface } compiler := jsonschema.NewCompiler() + if version >= 3.1 { + compiler.Draft = jsonschema.Draft2020 + } else { + compiler.Draft = jsonschema.Draft4 + } + _ = compiler.AddResource("schema.json", strings.NewReader(string(jsonSchema))) - jsch, _ := compiler.Compile("schema.json") + jsch, err := compiler.Compile("schema.json") + + var schemaValidationErrors []*liberrors.SchemaValidationFailure + + // is the schema even valid? did it compile? + if err != nil { + var se *jsonschema.SchemaError + if errors.As(err, &se) { + var ve *jsonschema.ValidationError + if errors.As(se.Err, &ve) { + + // no, this won't work, so we need to extract the errors and return them. + basicErrors := ve.BasicOutput().Errors + schemaValidationErrors = extractBasicErrors(basicErrors, renderedSchema, decodedObject, payload, ve, schemaValidationErrors) + // cannot compile schema, so it's not valid + violation := &liberrors.SchemaValidationFailure{ + Reason: err.Error(), + Location: "unavailable", + ReferenceSchema: string(renderedSchema), + ReferenceObject: string(payload), + } + validationErrors = append(validationErrors, &liberrors.ValidationError{ + ValidationType: helpers.RequestBodyValidation, + ValidationSubType: helpers.Schema, + Message: "schema does not pass validation", + Reason: fmt.Sprintf("The schema cannot be decoded: %s", err.Error()), + SpecLine: 1, + SpecCol: 0, + SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, + HowToFix: liberrors.HowToFixInvalidSchema, + Context: string(renderedSchema), // attach the rendered schema to the error + }) + return false, validationErrors + } + } + } // 4. validate the object against the schema - if decodedObject != nil { + if jsch != nil && decodedObject != nil { scErrs := jsch.Validate(decodedObject) if scErrs != nil { - var schemaValidationErrors []*errors.SchemaValidationFailure // check for invalid JSON type errors. - if _, ok := scErrs.(jsonschema.InvalidJSONTypeError); ok { - violation := &errors.SchemaValidationFailure{ + var invalidJSONTypeError jsonschema.InvalidJSONTypeError + if errors.As(scErrs, &invalidJSONTypeError) { + violation := &liberrors.SchemaValidationFailure{ Reason: scErrs.Error(), Location: "unavailable", // we don't have a location for this error, so we'll just say it's unavailable. } schemaValidationErrors = append(schemaValidationErrors, violation) } - if jk, ok := scErrs.(*jsonschema.ValidationError); ok { + var jk *jsonschema.ValidationError + if errors.As(scErrs, &jk) { // flatten the validationErrors schFlatErrs := jk.BasicOutput().Errors - for q := range schFlatErrs { - er := schFlatErrs[q] - if er.KeywordLocation == "" || strings.HasPrefix(er.Error, "doesn't validate with") { - continue // ignore this error, it's useless tbh, utter noise. - } - if er.Error != "" { - - // re-encode the schema. - var renderedNode yaml.Node - _ = yaml.Unmarshal(renderedSchema, &renderedNode) - - // locate the violated property in the schema - located := LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation) - - // extract the element specified by the instance - val := instanceLocationRegex.FindStringSubmatch(er.InstanceLocation) - var referenceObject string - - if len(val) > 0 { - referenceIndex, _ := strconv.Atoi(val[1]) - if reflect.ValueOf(decodedObject).Type().Kind() == reflect.Slice { - found := decodedObject.([]any)[referenceIndex] - recoded, _ := json.MarshalIndent(found, "", " ") - referenceObject = string(recoded) - } - } - if referenceObject == "" { - referenceObject = string(payload) - } - - violation := &errors.SchemaValidationFailure{ - Reason: er.Error, - Location: er.InstanceLocation, - DeepLocation: er.KeywordLocation, - AbsoluteLocation: er.AbsoluteKeywordLocation, - ReferenceSchema: string(renderedSchema), - ReferenceObject: referenceObject, - OriginalError: jk, - } - // if we have a location within the schema, add it to the error - if located != nil { - line := located.Line - // if the located node is a map or an array, then the actual human interpretable - // line on which the violation occurred is the line of the key, not the value. - if located.Kind == yaml.MappingNode || located.Kind == yaml.SequenceNode { - if line > 0 { - line-- - } - } - - // location of the violation within the rendered schema. - violation.Line = line - violation.Column = located.Column - } - schemaValidationErrors = append(schemaValidationErrors, violation) - } - } + schemaValidationErrors = extractBasicErrors(schFlatErrs, renderedSchema, decodedObject, payload, jk, schemaValidationErrors) } line := 1 col := 0 @@ -200,14 +196,14 @@ func validateSchema(schema *base.Schema, payload []byte, decodedObject interface } // add the error to the list - validationErrors = append(validationErrors, &errors.ValidationError{ + validationErrors = append(validationErrors, &liberrors.ValidationError{ ValidationType: helpers.Schema, Message: "schema does not pass validation", Reason: "Schema failed to validate against the contract requirements", SpecLine: line, SpecCol: col, SchemaValidationErrors: schemaValidationErrors, - HowToFix: errors.HowToFixInvalidSchema, + HowToFix: liberrors.HowToFixInvalidSchema, Context: string(renderedSchema), // attach the rendered schema to the error }) } @@ -217,3 +213,67 @@ func validateSchema(schema *base.Schema, payload []byte, decodedObject interface } return true, nil } + +func extractBasicErrors(schFlatErrs []jsonschema.BasicError, + renderedSchema []byte, decodedObject interface{}, + payload []byte, jk *jsonschema.ValidationError, + schemaValidationErrors []*liberrors.SchemaValidationFailure) []*liberrors.SchemaValidationFailure { + for q := range schFlatErrs { + er := schFlatErrs[q] + if er.KeywordLocation == "" || strings.HasPrefix(er.Error, "doesn't validate with") { + continue // ignore this error, it's useless tbh, utter noise. + } + if er.Error != "" { + + // re-encode the schema. + var renderedNode yaml.Node + _ = yaml.Unmarshal(renderedSchema, &renderedNode) + + // locate the violated property in the schema + located := LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation) + + // extract the element specified by the instance + val := instanceLocationRegex.FindStringSubmatch(er.InstanceLocation) + var referenceObject string + + if len(val) > 0 { + referenceIndex, _ := strconv.Atoi(val[1]) + if reflect.ValueOf(decodedObject).Type().Kind() == reflect.Slice { + found := decodedObject.([]any)[referenceIndex] + recoded, _ := json.MarshalIndent(found, "", " ") + referenceObject = string(recoded) + } + } + if referenceObject == "" { + referenceObject = string(payload) + } + + violation := &liberrors.SchemaValidationFailure{ + Reason: er.Error, + Location: er.InstanceLocation, + DeepLocation: er.KeywordLocation, + AbsoluteLocation: er.AbsoluteKeywordLocation, + ReferenceSchema: string(renderedSchema), + ReferenceObject: referenceObject, + OriginalError: jk, + } + // if we have a location within the schema, add it to the error + if located != nil { + line := located.Line + // if the located node is a map or an array, then the actual human interpretable + // line on which the violation occurred is the line of the key, not the value. + if located.Kind == yaml.MappingNode || located.Kind == yaml.SequenceNode { + if line > 0 { + line-- + } + } + + // location of the violation within the rendered schema. + violation.Line = line + violation.Column = located.Column + } + schemaValidationErrors = append(schemaValidationErrors, violation) + } + } + return schemaValidationErrors +} diff --git a/schema_validation/validate_schema_test.go b/schema_validation/validate_schema_test.go index d48adad..55f5c2f 100644 --- a/schema_validation/validate_schema_test.go +++ b/schema_validation/validate_schema_test.go @@ -535,6 +535,118 @@ paths: } +// https://github.com/pb33f/libopenapi-validator/issues/26 +func TestValidateSchema_v3_0_BooleanExclusiveMinimum(t *testing.T) { + + spec := `openapi: 3.0.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + amount: + type: number + minimum: 0 + exclusiveMinimum: true` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + body := map[string]interface{}{"amount": 3} + + bodyBytes, _ := json.Marshal(body) + sch := m.Model.Paths.PathItems["/burgers/createBurger"].Post.RequestBody.Content["application/json"].Schema + + // create a schema validator + v := NewSchemaValidator() + + // validate! + valid, errors := v.ValidateSchemaString(sch.Schema(), string(bodyBytes)) + + assert.True(t, valid) + assert.Empty(t, errors) + +} + +// https://github.com/pb33f/libopenapi-validator/issues/26 +func TestValidateSchema_v3_0_NumericExclusiveMinimum(t *testing.T) { + + spec := `openapi: 3.0.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + amount: + type: number + exclusiveMinimum: 0` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + body := map[string]interface{}{"amount": 3} + + bodyBytes, _ := json.Marshal(body) + sch := m.Model.Paths.PathItems["/burgers/createBurger"].Post.RequestBody.Content["application/json"].Schema + + // create a schema validator + v := NewSchemaValidator() + + // validate! + valid, errors := v.ValidateSchemaString(sch.Schema(), string(bodyBytes)) + + assert.False(t, valid) + assert.NotEmpty(t, errors) + +} + +// https://github.com/pb33f/libopenapi-validator/issues/26 +func TestValidateSchema_v3_1_NumericExclusiveMinimum(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + amount: + type: number + exclusiveMinimum: 0` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + body := map[string]interface{}{"amount": 3} + + bodyBytes, _ := json.Marshal(body) + sch := m.Model.Paths.PathItems["/burgers/createBurger"].Post.RequestBody.Content["application/json"].Schema + + // create a schema validator + v := NewSchemaValidator() + + // validate! + valid, errors := v.ValidateSchemaString(sch.Schema(), string(bodyBytes)) + + assert.True(t, valid) + assert.Empty(t, errors) + +} + //func TestValidateSchema_NullableEnum(t *testing.T) { // spec := `openapi: 3.0.0 //paths: