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
64 changes: 45 additions & 19 deletions schema_validation/validate_document.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package schema_validation

import (
"bytes"
"encoding/json"
"errors"
"fmt"
Expand All @@ -29,14 +30,22 @@ func normalizeJSON(data any) any {
// 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, opts ...config.Option) (bool, []*liberrors.ValidationError) {
return ValidateOpenAPIDocumentWithPrecompiled(doc, nil, opts...)
}

// ValidateOpenAPIDocumentWithPrecompiled validates an OpenAPI document against the OAS JSON Schema.
// When compiledSchema is non-nil it is used directly, skipping schema compilation.
// When SpecJSONBytes is available on the document's SpecInfo, the normalizeJSON round-trip is
// bypassed in favour of a single jsonschema.UnmarshalJSON call.
func ValidateOpenAPIDocumentWithPrecompiled(doc libopenapi.Document, compiledSchema *jsonschema.Schema, opts ...config.Option) (bool, []*liberrors.ValidationError) {
options := config.NewValidationOptions(opts...)

info := doc.GetSpecInfo()
loadedSchema := info.APISchema
var validationErrors []*liberrors.ValidationError

// Check if SpecJSON is nil before dereferencing
if info.SpecJSON == nil {
// Check if both JSON representations are nil before proceeding
if info.SpecJSON == nil && info.SpecJSONBytes == nil {
validationErrors = append(validationErrors, &liberrors.ValidationError{
ValidationType: helpers.Schema,
ValidationSubType: "document",
Expand All @@ -50,27 +59,44 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo
return false, validationErrors
}

decodedDocument := *info.SpecJSON
// Use the precompiled schema if provided, otherwise compile it
jsch := compiledSchema
if jsch == nil {
var err error
jsch, err = helpers.NewCompiledSchema("schema", []byte(loadedSchema), options)
if err != nil {
validationErrors = append(validationErrors, &liberrors.ValidationError{
ValidationType: helpers.Schema,
ValidationSubType: "compilation",
Message: "OpenAPI document schema compilation failed",
Reason: fmt.Sprintf("The OpenAPI schema failed to compile: %s", err.Error()),
SpecLine: 1,
SpecCol: 0,
HowToFix: "check the OpenAPI schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs",
Context: loadedSchema,
})
return false, validationErrors
}
}

// Compile the JSON Schema
jsch, err := helpers.NewCompiledSchema("schema", []byte(loadedSchema), options)
if err != nil {
// schema compilation failed, return validation error instead of panicking
validationErrors = append(validationErrors, &liberrors.ValidationError{
ValidationType: helpers.Schema,
ValidationSubType: "compilation",
Message: "OpenAPI document schema compilation failed",
Reason: fmt.Sprintf("The OpenAPI schema failed to compile: %s", err.Error()),
SpecLine: 1,
SpecCol: 0,
HowToFix: "check the OpenAPI schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs",
Context: loadedSchema,
})
return false, validationErrors
// Build the normalized document value for validation.
// Prefer SpecJSONBytes (single unmarshal) over SpecJSON (marshal+unmarshal round-trip).
var normalized any
if info.SpecJSONBytes != nil && len(*info.SpecJSONBytes) > 0 {
var err error
normalized, err = jsonschema.UnmarshalJSON(bytes.NewReader(*info.SpecJSONBytes))
if err != nil {
// Fall back to normalizeJSON if UnmarshalJSON fails
if info.SpecJSON != nil {
normalized = normalizeJSON(*info.SpecJSON)
}
}
} else if info.SpecJSON != nil {
normalized = normalizeJSON(*info.SpecJSON)
}

// Validate the document
scErrs := jsch.Validate(normalizeJSON(decodedDocument))
scErrs := jsch.Validate(normalized)

var schemaValidationErrors []*liberrors.SchemaValidationFailure

Expand Down
116 changes: 115 additions & 1 deletion schema_validation/validate_document_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,10 @@ info:

doc, _ := libopenapi.NewDocument([]byte(spec))

// Simulate the nil SpecJSON scenario by setting it to nil
// Simulate the nil SpecJSON scenario by setting both to nil
info := doc.GetSpecInfo()
info.SpecJSON = nil
info.SpecJSONBytes = nil

// validate!
valid, errors := ValidateOpenAPIDocument(doc)
Expand All @@ -201,3 +202,116 @@ info:
// Pre-validation errors should not have SchemaValidationErrors
assert.Empty(t, validationError.SchemaValidationErrors)
}

func TestValidateDocument_WithPrecompiledSchema(t *testing.T) {
petstore, _ := os.ReadFile("../test_specs/petstorev3.json")
doc, _ := libopenapi.NewDocument(petstore)

info := doc.GetSpecInfo()

// Pre-compile the schema
options := config.NewValidationOptions()
compiledSchema, err := helpers.NewCompiledSchema("schema", []byte(info.APISchema), options)
assert.NoError(t, err)

// Validate with precompiled schema
valid, errs := ValidateOpenAPIDocumentWithPrecompiled(doc, compiledSchema)
assert.True(t, valid)
assert.Len(t, errs, 0)

// Validate without precompiled schema (should produce identical results)
valid2, errs2 := ValidateOpenAPIDocument(doc)
assert.True(t, valid2)
assert.Len(t, errs2, 0)
}

func TestValidateDocument_WithPrecompiledSchema_Invalid(t *testing.T) {
petstore, _ := os.ReadFile("../test_specs/invalid_31.yaml")
doc, _ := libopenapi.NewDocument(petstore)

info := doc.GetSpecInfo()

// Pre-compile the schema
options := config.NewValidationOptions()
compiledSchema, err := helpers.NewCompiledSchema("schema", []byte(info.APISchema), options)
assert.NoError(t, err)

// Validate with precompiled schema
valid, errs := ValidateOpenAPIDocumentWithPrecompiled(doc, compiledSchema)
assert.False(t, valid)
assert.Len(t, errs, 1)
assert.Len(t, errs[0].SchemaValidationErrors, 6)

// Validate without precompiled schema (should produce identical error count)
valid2, errs2 := ValidateOpenAPIDocument(doc)
assert.False(t, valid2)
assert.Len(t, errs2, 1)
assert.Len(t, errs2[0].SchemaValidationErrors, 6)
}

func TestValidateDocument_SpecJSONBytesPath(t *testing.T) {
petstore, _ := os.ReadFile("../test_specs/petstorev3.json")
doc, _ := libopenapi.NewDocument(petstore)

info := doc.GetSpecInfo()

// Nil out SpecJSON but leave SpecJSONBytes intact — forces the SpecJSONBytes path
assert.NotNil(t, info.SpecJSONBytes, "SpecJSONBytes should be populated by libopenapi")
info.SpecJSON = nil

valid, errs := ValidateOpenAPIDocument(doc)
assert.True(t, valid)
assert.Len(t, errs, 0)
}

func TestValidateDocument_SpecJSONBytesCorrupt_NilSpecJSON(t *testing.T) {
petstore, _ := os.ReadFile("../test_specs/petstorev3.json")
doc, _ := libopenapi.NewDocument(petstore)

info := doc.GetSpecInfo()

// Put corrupt bytes in SpecJSONBytes so UnmarshalJSON fails,
// and nil out SpecJSON so the fallback normalizeJSON path is skipped.
// This exercises the nil guard on SpecJSON inside the error branch.
corrupt := []byte(`{not valid json!!!}`)
info.SpecJSONBytes = &corrupt
info.SpecJSON = nil

// Validation should still run (against nil normalized value) and report errors
valid, errs := ValidateOpenAPIDocument(doc)
assert.False(t, valid)
assert.NotEmpty(t, errs)
}

func TestValidateDocument_SpecJSONBytesCorrupt_FallbackToSpecJSON(t *testing.T) {
petstore, _ := os.ReadFile("../test_specs/petstorev3.json")
doc, _ := libopenapi.NewDocument(petstore)

info := doc.GetSpecInfo()

// Put corrupt bytes in SpecJSONBytes so UnmarshalJSON fails,
// but leave SpecJSON intact so the fallback to normalizeJSON executes.
corrupt := []byte(`{not valid json!!!}`)
info.SpecJSONBytes = &corrupt

// Should still validate successfully via the SpecJSON fallback
valid, errs := ValidateOpenAPIDocument(doc)
assert.True(t, valid)
assert.Len(t, errs, 0)
}

func TestValidateDocument_SpecJSONBytesPath_Invalid(t *testing.T) {
petstore, _ := os.ReadFile("../test_specs/invalid_31.yaml")
doc, _ := libopenapi.NewDocument(petstore)

info := doc.GetSpecInfo()

// Nil out SpecJSON but leave SpecJSONBytes intact
assert.NotNil(t, info.SpecJSONBytes, "SpecJSONBytes should be populated by libopenapi")
info.SpecJSON = nil

valid, errs := ValidateOpenAPIDocument(doc)
assert.False(t, valid)
assert.Len(t, errs, 1)
assert.NotEmpty(t, errs[0].SchemaValidationErrors)
}