From b87cec988431749ae47bdb5092a4687ffa3862d2 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Wed, 8 Oct 2025 18:08:07 -0700 Subject: [PATCH 01/25] Refactor SchemaValidationFailure struct fields --- errors/validation_error.go | 35 +++++++++++++------------- parameters/validate_parameter.go | 12 ++++----- requests/validate_request.go | 16 ++++++------ responses/validate_response.go | 16 ++++++------ schema_validation/validate_document.go | 16 ++++++------ schema_validation/validate_schema.go | 20 +++++++-------- 6 files changed, 57 insertions(+), 58 deletions(-) diff --git a/errors/validation_error.go b/errors/validation_error.go index 4c590ae..80904ec 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -9,29 +9,27 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -// SchemaValidationFailure is a wrapper around the jsonschema.ValidationError object, to provide a more -// user-friendly way to break down what went wrong. +// SchemaValidationFailure describes any failure that occurs when validating data +// against either an OpenAPI or JSON Schema. It aims to be a more user-friendly +// representation of the error than what is provided by the jsonschema library. type SchemaValidationFailure struct { // Reason is a human-readable message describing the reason for the error. Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` - // Location is the XPath-like location of the validation failure - Location string `json:"location,omitempty" yaml:"location,omitempty"` + // InstancePath is the raw path segments from the root to the failing field + InstancePath []string `json:"instancePath,omitempty" yaml:"instancePath,omitempty"` // FieldName is the name of the specific field that failed validation (last segment of the path) FieldName string `json:"fieldName,omitempty" yaml:"fieldName,omitempty"` - // FieldPath is the JSONPath representation of the field location (e.g., "$.user.email") + // FieldPath is the JSONPath representation of the field location that failed validation (e.g., "$.user.email") FieldPath string `json:"fieldPath,omitempty" yaml:"fieldPath,omitempty"` - // InstancePath is the raw path segments from the root to the failing field - InstancePath []string `json:"instancePath,omitempty" yaml:"instancePath,omitempty"` - - // DeepLocation is the path to the validation failure as exposed by the jsonschema library. - DeepLocation string `json:"deepLocation,omitempty" yaml:"deepLocation,omitempty"` + // KeywordLocation is the relative path to the JsonSchema keyword that failed validation + KeywordLocation string `json:"keywordLocation,omitempty" yaml:"keywordLocation,omitempty"` - // AbsoluteLocation is the absolute path to the validation failure as exposed by the jsonschema library. - AbsoluteLocation string `json:"absoluteLocation,omitempty" yaml:"absoluteLocation,omitempty"` + // AbsoluteKeywordLocation is the absolute path to the validation failure as exposed by the jsonschema library. + AbsoluteKeywordLocation string `json:"absoluteKeywordLocation,omitempty" yaml:"absoluteKeywordLocation,omitempty"` // Line is the line number where the violation occurred. This may a local line number // if the validation is a schema (only schemas are validated locally, so the line number will be relative to @@ -46,14 +44,15 @@ type SchemaValidationFailure struct { // ReferenceSchema is the schema that was referenced in the validation failure. ReferenceSchema string `json:"referenceSchema,omitempty" yaml:"referenceSchema,omitempty"` - // ReferenceObject is the object that was referenced in the validation failure. + // ReferenceObject is the object that failed schema validation ReferenceObject string `json:"referenceObject,omitempty" yaml:"referenceObject,omitempty"` - // ReferenceExample is an example object generated from the schema that was referenced in the validation failure. - ReferenceExample string `json:"referenceExample,omitempty" yaml:"referenceExample,omitempty"` + // The original jsonschema.ValidationError object, if the schema failure originated from the jsonschema library. + OriginalJsonSchemaError *jsonschema.ValidationError `json:"-" yaml:"-"` - // The original error object, which is a jsonschema.ValidationError object. - OriginalError *jsonschema.ValidationError `json:"-" yaml:"-"` + // DEPRECATED in favor of explicit use of FieldPath & InstancePath + // Location is the XPath-like location of the validation failure + Location string `json:"location,omitempty" yaml:"location,omitempty"` } // Error returns a string representation of the error @@ -97,7 +96,7 @@ type ValidationError struct { ParameterName string `json:"parameterName,omitempty" yaml:"parameterName,omitempty"` // SchemaValidationErrors is a slice of SchemaValidationFailure objects that describe the validation errors - // This is only populated whe the validation type is against a schema. + // This is only populated when the validation type is against a schema. SchemaValidationErrors []*SchemaValidationFailure `json:"validationErrors,omitempty" yaml:"validationErrors,omitempty"` // Context is the object that the validation error occurred on. This is usually a pointer to a schema diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 12dbd83..d9914ca 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -226,12 +226,12 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val } fail := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.KeywordLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - OriginalError: scErrs, + Reason: errMsg, + Location: er.KeywordLocation, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + OriginalJsonSchemaError: scErrs, } if schema != nil { rendered, err := schema.RenderInline() diff --git a/requests/validate_request.go b/requests/validate_request.go index 64550cb..f6f6aac 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -167,14 +167,14 @@ func ValidateRequestSchema( errMsg := er.Error.Kind.LocalizedString(message.NewPrinter(language.Tag{})) violation := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.KeywordLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - ReferenceSchema: string(renderedSchema), - ReferenceObject: referenceObject, - OriginalError: jk, + Reason: errMsg, + Location: er.KeywordLocation, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + ReferenceSchema: string(renderedSchema), + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, } // if we have a location within the schema, add it to the error if located != nil { diff --git a/responses/validate_response.go b/responses/validate_response.go index 7011533..865ddb5 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -194,14 +194,14 @@ func ValidateResponseSchema( } violation := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.KeywordLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - ReferenceSchema: string(renderedSchema), - ReferenceObject: referenceObject, - OriginalError: jk, + Reason: errMsg, + Location: er.KeywordLocation, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + ReferenceSchema: string(renderedSchema), + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, } // if we have a location within the schema, add it to the error if located != nil { diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index 42d198e..d17cf0e 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -84,14 +84,14 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo // locate the violated property in the schema located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) violation := &liberrors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.InstanceLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - DeepLocation: er.KeywordLocation, - AbsoluteLocation: er.AbsoluteKeywordLocation, - OriginalError: jk, + Reason: errMsg, + Location: er.InstanceLocation, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + AbsoluteKeywordLocation: er.AbsoluteKeywordLocation, + OriginalJsonSchemaError: jk, } // if we have a location within the schema, add it to the error diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index 4d1c67a..f20b92b 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -300,16 +300,16 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, } violation := &liberrors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.InstanceLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - DeepLocation: er.KeywordLocation, - AbsoluteLocation: er.AbsoluteKeywordLocation, - ReferenceSchema: string(renderedSchema), - ReferenceObject: referenceObject, - OriginalError: jk, + Reason: errMsg, + Location: er.InstanceLocation, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + AbsoluteKeywordLocation: er.AbsoluteKeywordLocation, + ReferenceSchema: string(renderedSchema), + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, } // if we have a location within the schema, add it to the error if located != nil { From 56c1f23a35d274dd91c5ded19eb04549f0fee5ab Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 10 Oct 2025 11:55:31 -0700 Subject: [PATCH 02/25] document expectation if SchemaValidationFailure did not originate from JSON Schema --- errors/validation_error.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/errors/validation_error.go b/errors/validation_error.go index 80904ec..f695e68 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -26,9 +26,11 @@ type SchemaValidationFailure struct { FieldPath string `json:"fieldPath,omitempty" yaml:"fieldPath,omitempty"` // KeywordLocation is the relative path to the JsonSchema keyword that failed validation + // This will be empty if the validation failure did not originate from JSON Schema validation KeywordLocation string `json:"keywordLocation,omitempty" yaml:"keywordLocation,omitempty"` // AbsoluteKeywordLocation is the absolute path to the validation failure as exposed by the jsonschema library. + // This will be empty if the validation failure did not originate from JSON Schema validation AbsoluteKeywordLocation string `json:"absoluteKeywordLocation,omitempty" yaml:"absoluteKeywordLocation,omitempty"` // Line is the line number where the violation occurred. This may a local line number From 5e582420f13215f8090e7f46706d3de11a05e366 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Wed, 15 Oct 2025 10:36:44 -0700 Subject: [PATCH 03/25] add ReferenceExample back --- errors/validation_error.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/errors/validation_error.go b/errors/validation_error.go index f695e68..5c83a6d 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -49,6 +49,9 @@ type SchemaValidationFailure struct { // ReferenceObject is the object that failed schema validation ReferenceObject string `json:"referenceObject,omitempty" yaml:"referenceObject,omitempty"` + // ReferenceExample is an example object generated from the schema that was referenced in the validation failure. + ReferenceExample string `json:"referenceExample,omitempty" yaml:"referenceExample,omitempty"` + // The original jsonschema.ValidationError object, if the schema failure originated from the jsonschema library. OriginalJsonSchemaError *jsonschema.ValidationError `json:"-" yaml:"-"` From 6c03ca51c1591cee2a9282c69c5fd210b6cfd419 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Wed, 5 Nov 2025 17:11:13 -0800 Subject: [PATCH 04/25] Remove SchemaValidationFailure from schema pre-validation errors --- schema_validation/validate_schema.go | 69 +++++++------------ .../validate_schema_openapi_test.go | 12 ++-- schema_validation/validate_schema_test.go | 48 ++++++++++++- 3 files changed, 76 insertions(+), 53 deletions(-) diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index f20b92b..427f052 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -133,22 +133,15 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload renderedSchema, e = schema.RenderInline() if e != nil { // schema cannot be rendered, so it's not valid! - violation := &liberrors.SchemaValidationFailure{ - Reason: e.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", e.Error()), - SpecLine: schema.GoLow().GetRootNode().Line, - SpecCol: schema.GoLow().GetRootNode().Column, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: liberrors.HowToFixInvalidSchema, - Context: string(renderedSchema), + ValidationType: helpers.RequestBodyValidation, + ValidationSubType: helpers.Schema, + Message: "schema does not pass validation", + Reason: fmt.Sprintf("The schema cannot be decoded: %s", e.Error()), + SpecLine: schema.GoLow().GetRootNode().Line, + SpecCol: schema.GoLow().GetRootNode().Column, + HowToFix: liberrors.HowToFixInvalidSchema, + Context: string(renderedSchema), }) s.lock.Unlock() return false, validationErrors @@ -163,12 +156,6 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload if err != nil { // cannot decode the request body, so it's not valid - violation := &liberrors.SchemaValidationFailure{ - Reason: err.Error(), - Location: "unavailable", - ReferenceSchema: string(renderedSchema), - ReferenceObject: string(payload), - } line := 1 col := 0 if schema.GoLow().Type.KeyNode != nil { @@ -176,15 +163,14 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload col = schema.GoLow().Type.KeyNode.Column } 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: line, - SpecCol: col, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: liberrors.HowToFixInvalidSchema, - Context: string(renderedSchema), + ValidationType: helpers.RequestBodyValidation, + ValidationSubType: helpers.Schema, + Message: "schema does not pass validation", + Reason: fmt.Sprintf("The schema cannot be decoded: %s", err.Error()), + SpecLine: line, + SpecCol: col, + HowToFix: liberrors.HowToFixInvalidSchema, + Context: string(renderedSchema), }) return false, validationErrors } @@ -199,12 +185,6 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload var schemaValidationErrors []*liberrors.SchemaValidationFailure if err != nil { - violation := &liberrors.SchemaValidationFailure{ - Reason: err.Error(), - Location: "schema compilation", - ReferenceSchema: string(renderedSchema), - ReferenceObject: string(payload), - } line := 1 col := 0 if schema.GoLow().Type.KeyNode != nil { @@ -212,15 +192,14 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload col = schema.GoLow().Type.KeyNode.Column } validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: helpers.Schema, - ValidationSubType: helpers.Schema, - Message: "schema compilation failed", - Reason: fmt.Sprintf("Schema compilation failed: %s", err.Error()), - SpecLine: line, - SpecCol: col, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: liberrors.HowToFixInvalidSchema, - Context: string(renderedSchema), + ValidationType: helpers.Schema, + ValidationSubType: helpers.Schema, + Message: "schema compilation failed", + Reason: fmt.Sprintf("Schema compilation failed: %s", err.Error()), + SpecLine: line, + SpecCol: col, + HowToFix: liberrors.HowToFixInvalidSchema, + Context: string(renderedSchema), }) return false, validationErrors } diff --git a/schema_validation/validate_schema_openapi_test.go b/schema_validation/validate_schema_openapi_test.go index 7ab3d32..54c01ea 100644 --- a/schema_validation/validate_schema_openapi_test.go +++ b/schema_validation/validate_schema_openapi_test.go @@ -345,13 +345,13 @@ components: foundCompilationError := false for _, err := range errors { - if err.SchemaValidationErrors != nil { - for _, schErr := range err.SchemaValidationErrors { - if schErr.Location == "unavailable" && schErr.Reason == "schema render failure, circular reference: `#/components/schemas/b`" { - foundCompilationError = true - } - } + if err.Message == "schema does not pass validation" && + err.Reason != "" && + (err.Reason == "The schema cannot be decoded: schema render failure, circular reference: `#/components/schemas/b`" || + err.Reason == "The schema cannot be decoded: schema render failure, circular reference: `#/components/schemas/Node`") { + foundCompilationError = true } + assert.Nil(t, err.SchemaValidationErrors, "Rendering errors should not have SchemaValidationErrors") } assert.True(t, foundCompilationError, "Should have schema compilation error for circular references") }) diff --git a/schema_validation/validate_schema_test.go b/schema_validation/validate_schema_test.go index 7c7229f..7bf6303 100644 --- a/schema_validation/validate_schema_test.go +++ b/schema_validation/validate_schema_test.go @@ -524,8 +524,52 @@ paths: assert.False(t, valid) assert.Len(t, errors, 1) - assert.Equal(t, "schema does not pass validation", errors[0].Message) - assert.Equal(t, "invalid character '}' looking for beginning of object key string", errors[0].SchemaValidationErrors[0].Reason) + assert.Contains(t, errors[0].Reason, "invalid character '}' looking for beginning of object key string") + assert.Nil(t, errors[0].SchemaValidationErrors) +} + +func TestValidateSchema_CompilationFailure(t *testing.T) { + // Test that schema compilation failure doesn't create SchemaValidationErrors + // This uses an extremely complex regex that might fail compilation in some regex engines + spec := `openapi: 3.1.0 +paths: + /test: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + password: + type: string + pattern: '(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}(?:(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,})*'` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + sch := m.Model.Paths.PathItems.GetOrZero("/test").Post.RequestBody.Content.GetOrZero("application/json").Schema + + // create a schema validator with strict regex engine that might fail on complex patterns + v := NewSchemaValidator() + + // Try to validate - if compilation fails, we should get an error without SchemaValidationErrors + validData := `{"password": "ValidPass123!"}` + valid, errors := v.ValidateSchemaString(sch.Schema(), validData) + + // This test is environment-dependent - compilation might succeed or fail depending on regex engine + // If it fails, we want to ensure SchemaValidationErrors is nil + if !valid && len(errors) > 0 { + for _, err := range errors { + if err.Message == "schema compilation failed" { + // Compilation failure should NOT have SchemaValidationErrors + assert.Nil(t, err.SchemaValidationErrors, "Schema compilation errors should not have SchemaValidationErrors") + t.Logf("Schema compilation failed as expected: %s", err.Reason) + } + } + } else { + t.Skip("Regex engine handled the complex pattern - skipping compilation failure test") + } } //// https://github.com/pb33f/libopenapi-validator/issues/26 From dbc56eb21d9d6c6319828aa199b9af6f6348d613 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Wed, 5 Nov 2025 17:27:01 -0800 Subject: [PATCH 05/25] Remove SchemaValidationFailure from document compilation errors --- schema_validation/validate_document.go | 22 ++++++++------------- schema_validation/validate_document_test.go | 4 ++-- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index d17cf0e..788f5d6 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -40,21 +40,15 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo jsch, err := helpers.NewCompiledSchema("schema", []byte(loadedSchema), options) if err != nil { // schema compilation failed, return validation error instead of panicking - violation := &liberrors.SchemaValidationFailure{ - Reason: fmt.Sprintf("failed to compile OpenAPI schema: %s", err.Error()), - Location: "schema compilation", - ReferenceSchema: loadedSchema, - } validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: "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, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: "check the OpenAPI schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: loadedSchema, + ValidationType: "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 } diff --git a/schema_validation/validate_document_test.go b/schema_validation/validate_document_test.go index eff930f..b6104f6 100644 --- a/schema_validation/validate_document_test.go +++ b/schema_validation/validate_document_test.go @@ -132,8 +132,8 @@ func TestValidateDocument_CompilationFailure(t *testing.T) { valid, errors := ValidateOpenAPIDocument(doc) assert.False(t, valid) assert.Len(t, errors, 1) - assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "failed to compile OpenAPI schema") + assert.Contains(t, errors[0].Reason, "The OpenAPI schema failed to compile") + assert.Nil(t, errors[0].SchemaValidationErrors, "Compilation errors should not have SchemaValidationErrors") } func TestValidateSchema_ValidateLicenseIdentifier(t *testing.T) { From f0fc5b5b72f88ee3c566e2ae848f71cef4ca699f Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Wed, 5 Nov 2025 19:05:43 -0800 Subject: [PATCH 06/25] Parameters: add KeywordLocation when formatting JSON schema errors, remove SchemaValidationFailure from when json schema compilation fails --- parameters/validate_parameter.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index fa43069..2f5d7d3 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -127,23 +127,17 @@ func ValidateParameterSchema( jsch, err := helpers.NewCompiledSchema(name, jsonSchema, validationOptions) if err != nil { // schema compilation failed, return validation error instead of panicking - violation := &errors.SchemaValidationFailure{ - Reason: fmt.Sprintf("failed to compile JSON schema: %s", err.Error()), - Location: "schema compilation", - ReferenceSchema: string(jsonSchema), - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: validationType, ValidationSubType: subValType, Message: fmt.Sprintf("%s '%s' failed schema compilation", entity, name), Reason: fmt.Sprintf("%s '%s' schema compilation failed: %s", reasonEntity, name, err.Error()), - SpecLine: 1, - SpecCol: 0, - ParameterName: name, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: "check the parameter schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: string(jsonSchema), + SpecLine: 1, + SpecCol: 0, + ParameterName: name, + HowToFix: "check the parameter schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", + Context: string(jsonSchema), }) return validationErrors } @@ -227,10 +221,12 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val fail := &errors.SchemaValidationFailure{ Reason: errMsg, - Location: er.KeywordLocation, + Location: er.KeywordLocation, // DEPRECATED FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + AbsoluteKeywordLocation: er.AbsoluteKeywordLocation, OriginalJsonSchemaError: scErrs, } if schema != nil { From 3e80f7597f3bd994b8af4df33f6d5ccc2400fa2a Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 7 Nov 2025 15:19:46 -0800 Subject: [PATCH 07/25] Remove AbsoluteKeywordLocation field - never populated due to schema inlining AbsoluteKeywordLocation was never populated because libopenapi's RenderInline() method resolves and inlines all $ref references before schemas reach the JSON Schema validator. Since the validator never encounters $ref pointers, this field remained empty in all cases and served no purpose. Removed from: - SchemaValidationFailure struct definition - All instantiation sites (schema_validation, parameters, requests) - Improved KeywordLocation documentation with JSON Pointer reference --- .gitignore | 2 ++ errors/validation_error.go | 8 ++------ parameters/validate_parameter.go | 1 - schema_validation/validate_document.go | 19 +++++++++---------- schema_validation/validate_schema.go | 1 - 5 files changed, 13 insertions(+), 18 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00a9078 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Working memory and debug artifacts +working-memory/ \ No newline at end of file diff --git a/errors/validation_error.go b/errors/validation_error.go index 5c83a6d..020f8bd 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -25,14 +25,10 @@ type SchemaValidationFailure struct { // FieldPath is the JSONPath representation of the field location that failed validation (e.g., "$.user.email") FieldPath string `json:"fieldPath,omitempty" yaml:"fieldPath,omitempty"` - // KeywordLocation is the relative path to the JsonSchema keyword that failed validation - // This will be empty if the validation failure did not originate from JSON Schema validation + // KeywordLocation is the JSON Pointer (RFC 6901) path to the schema keyword that failed validation + // (e.g., "/properties/age/minimum") KeywordLocation string `json:"keywordLocation,omitempty" yaml:"keywordLocation,omitempty"` - // AbsoluteKeywordLocation is the absolute path to the validation failure as exposed by the jsonschema library. - // This will be empty if the validation failure did not originate from JSON Schema validation - AbsoluteKeywordLocation string `json:"absoluteKeywordLocation,omitempty" yaml:"absoluteKeywordLocation,omitempty"` - // Line is the line number where the violation occurred. This may a local line number // if the validation is a schema (only schemas are validated locally, so the line number will be relative to // the Context object held by the ValidationError object). diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 2f5d7d3..2885369 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -226,7 +226,6 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), KeywordLocation: er.KeywordLocation, - AbsoluteKeywordLocation: er.AbsoluteKeywordLocation, OriginalJsonSchemaError: scErrs, } if schema != nil { diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index 788f5d6..9282bd5 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -77,16 +77,15 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo // locate the violated property in the schema located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) - violation := &liberrors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.InstanceLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - AbsoluteKeywordLocation: er.AbsoluteKeywordLocation, - OriginalJsonSchemaError: jk, - } + violation := &liberrors.SchemaValidationFailure{ + Reason: errMsg, + Location: er.InstanceLocation, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index 427f052..b2def79 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -285,7 +285,6 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), KeywordLocation: er.KeywordLocation, - AbsoluteKeywordLocation: er.AbsoluteKeywordLocation, ReferenceSchema: string(renderedSchema), ReferenceObject: referenceObject, OriginalJsonSchemaError: jk, From a21d5ab7d0e1a737d1346c93a5fa6e834adc93d5 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 7 Nov 2025 15:21:21 -0800 Subject: [PATCH 08/25] Request body validation: remove SchemaValidationFailure from pre-validation errors, add KeywordLocation to schema violations Pre-validation errors (compilation, JSON decode, empty body) now correctly omit SchemaValidationFailure objects, as they don't represent actual schema constraint violations. Actual schema violations now include KeywordLocation (JSON Pointer path to the failing keyword) for better error context. Also fixed Location field to use er.InstanceLocation for consistency with schema_validation/validate_schema.go. --- requests/validate_body_test.go | 4 +- requests/validate_request.go | 70 +++++++++++++--------------------- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go index 6deaaf4..c6e2e98 100644 --- a/requests/validate_body_test.go +++ b/requests/validate_body_test.go @@ -1308,8 +1308,8 @@ components: assert.False(t, valid) assert.Len(t, errors, 1) - assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "invalid character '}' looking for beginning of object key string", errors[0].SchemaValidationErrors[0].Reason) + assert.Nil(t, errors[0].SchemaValidationErrors) + assert.Contains(t, errors[0].Reason, "cannot be decoded") } func TestValidateBody_SchemaNoType_Issue75(t *testing.T) { diff --git a/requests/validate_request.go b/requests/validate_request.go index 06baf3b..f20b818 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -87,22 +87,16 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V input.Version, ) if err != nil { - violation := &errors.SchemaValidationFailure{ - Reason: fmt.Sprintf("failed to compile JSON schema: %s", err.Error()), - Location: "schema compilation", - ReferenceSchema: referenceSchema, - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema, Message: fmt.Sprintf("%s request body for '%s' failed schema compilation", input.Request.Method, input.Request.URL.Path), - Reason: fmt.Sprintf("The request schema failed to compile: %s", err.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: "check the request schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: referenceSchema, + Reason: fmt.Sprintf("The request schema failed to compile: %s", err.Error()), + SpecLine: 1, + SpecCol: 0, + HowToFix: "check the request schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", + Context: referenceSchema, }) return false, validationErrors } @@ -138,23 +132,16 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V err := json.Unmarshal(requestBody, &decodedObj) if err != nil { // cannot decode the request body, so it's not valid - violation := &errors.SchemaValidationFailure{ - Reason: err.Error(), - Location: "unavailable", - ReferenceSchema: referenceSchema, - ReferenceObject: string(requestBody), - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema, Message: fmt.Sprintf("%s request body for '%s' failed to validate schema", request.Method, request.URL.Path), - Reason: fmt.Sprintf("The request body cannot be decoded: %s", err.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Reason: fmt.Sprintf("The request body cannot be decoded: %s", err.Error()), + SpecLine: 1, + SpecCol: 0, + HowToFix: errors.HowToFixInvalidSchema, + Context: referenceSchema, // attach the rendered schema to the error }) return false, validationErrors } @@ -171,22 +158,16 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V } // cannot decode the request body, so it's not valid - violation := &errors.SchemaValidationFailure{ - Reason: "request body is empty, but there is a schema defined", - ReferenceSchema: referenceSchema, - ReferenceObject: string(requestBody), - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema, Message: fmt.Sprintf("%s request body is empty for '%s'", request.Method, request.URL.Path), - Reason: "The request body is empty but there is a schema defined", - SpecLine: line, - SpecCol: col, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Reason: "The request body is empty but there is a schema defined", + SpecLine: line, + SpecCol: col, + HowToFix: errors.HowToFixInvalidSchema, + Context: referenceSchema, // attach the rendered schema to the error }) return false, validationErrors } @@ -235,16 +216,17 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V errMsg := er.Error.Kind.LocalizedString(message.NewPrinter(language.Tag{})) - violation := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.KeywordLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - ReferenceSchema: referenceSchema, - ReferenceObject: referenceObject, - OriginalJsonSchemaError: jk, - } + violation := &errors.SchemaValidationFailure{ + Reason: errMsg, + Location: er.InstanceLocation, // DEPRECATED + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + ReferenceSchema: referenceSchema, + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { From 969efb8687256babe49e1c5f8cc0f384e28f0020 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 7 Nov 2025 15:46:09 -0800 Subject: [PATCH 09/25] Response body validation: remove SchemaValidationFailure from pre-validation errors, add KeywordLocation to schema violations Pre-validation errors (compilation, missing response, IO read, JSON decode) now correctly omit SchemaValidationFailure objects, as they don't represent actual schema constraint violations. Actual schema violations now include KeywordLocation (JSON Pointer path to the failing keyword) for better error context. Also fixed Location field to use er.InstanceLocation for consistency with request validation and schema validation. --- requests/validate_request.go | 22 +++++------ responses/validate_body_test.go | 3 +- responses/validate_response.go | 67 +++++++++++---------------------- 3 files changed, 34 insertions(+), 58 deletions(-) diff --git a/requests/validate_request.go b/requests/validate_request.go index f20b818..cb89624 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -216,17 +216,17 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V errMsg := er.Error.Kind.LocalizedString(message.NewPrinter(language.Tag{})) - violation := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.InstanceLocation, // DEPRECATED - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - ReferenceSchema: referenceSchema, - ReferenceObject: referenceObject, - OriginalJsonSchemaError: jk, - } + violation := &errors.SchemaValidationFailure{ + Reason: errMsg, + Location: er.InstanceLocation, // DEPRECATED + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + ReferenceSchema: referenceSchema, + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index 5096225..9e7e75e 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1262,7 +1262,8 @@ paths: assert.False(t, valid) assert.Len(t, errors, 1) assert.Equal(t, "POST response body for '/burgers/createBurger' failed to validate schema", errors[0].Message) - assert.Equal(t, "invalid character '}' looking for beginning of object key string", errors[0].SchemaValidationErrors[0].Reason) + assert.Nil(t, errors[0].SchemaValidationErrors) + assert.Contains(t, errors[0].Reason, "cannot be decoded") } func TestValidateBody_NoContentType_Valid(t *testing.T) { diff --git a/responses/validate_response.go b/responses/validate_response.go index 6a644da..b6633e6 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -90,11 +90,6 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors input.Version, ) if err != nil { - violation := &errors.SchemaValidationFailure{ - Reason: fmt.Sprintf("failed to compile JSON schema: %s", err.Error()), - Location: "schema compilation", - ReferenceSchema: referenceSchema, - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema, @@ -102,11 +97,10 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors input.Response.StatusCode, input.Request.URL.Path), Reason: fmt.Sprintf("The response schema for status code '%d' failed to compile: %s", input.Response.StatusCode, err.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: "check the response schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: referenceSchema, + SpecLine: 1, + SpecCol: 0, + HowToFix: "check the response schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", + Context: referenceSchema, }) return false, validationErrors } @@ -129,22 +123,16 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors if response == nil || response.Body == http.NoBody { // cannot decode the response body, so it's not valid - violation := &errors.SchemaValidationFailure{ - Reason: "response is empty", - Location: "unavailable", - ReferenceSchema: referenceSchema, - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: "response", ValidationSubType: "object", Message: fmt.Sprintf("%s response object is missing for '%s'", request.Method, request.URL.Path), - Reason: "The response object is completely missing", - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: "ensure response object has been set", - Context: referenceSchema, // attach the rendered schema to the error + Reason: "The response object is completely missing", + SpecLine: 1, + SpecCol: 0, + HowToFix: "ensure response object has been set", + Context: referenceSchema, // attach the rendered schema to the error }) return false, validationErrors } @@ -152,23 +140,16 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors responseBody, ioErr := io.ReadAll(response.Body) if ioErr != nil { // cannot decode the response body, so it's not valid - violation := &errors.SchemaValidationFailure{ - Reason: ioErr.Error(), - Location: "unavailable", - ReferenceSchema: referenceSchema, - ReferenceObject: string(responseBody), - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema, Message: fmt.Sprintf("%s response body for '%s' cannot be read, it's empty or malformed", request.Method, request.URL.Path), - Reason: fmt.Sprintf("The response body cannot be decoded: %s", ioErr.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: "ensure body is not empty", - Context: referenceSchema, // attach the rendered schema to the error + Reason: fmt.Sprintf("The response body cannot be decoded: %s", ioErr.Error()), + SpecLine: 1, + SpecCol: 0, + HowToFix: "ensure body is not empty", + Context: referenceSchema, // attach the rendered schema to the error }) return false, validationErrors } @@ -183,23 +164,16 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors err := json.Unmarshal(responseBody, &decodedObj) if err != nil { // cannot decode the response body, so it's not valid - violation := &errors.SchemaValidationFailure{ - Reason: err.Error(), - Location: "unavailable", - ReferenceSchema: referenceSchema, - ReferenceObject: string(responseBody), - } validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.Schema, Message: fmt.Sprintf("%s response body for '%s' failed to validate schema", request.Method, request.URL.Path), - Reason: fmt.Sprintf("The response body cannot be decoded: %s", err.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, - HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Reason: fmt.Sprintf("The response body cannot be decoded: %s", err.Error()), + SpecLine: 1, + SpecCol: 0, + HowToFix: errors.HowToFixInvalidSchema, + Context: referenceSchema, // attach the rendered schema to the error }) return false, validationErrors } @@ -252,10 +226,11 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors violation := &errors.SchemaValidationFailure{ Reason: errMsg, - Location: er.KeywordLocation, + Location: er.InstanceLocation, // DEPRECATED FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, ReferenceSchema: referenceSchema, ReferenceObject: referenceObject, OriginalJsonSchemaError: jk, From 1f6225c869e7c084523b5f9c78b1a73f0ba43cd4 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 10:24:07 -0800 Subject: [PATCH 10/25] feat: add centralized JSON Pointer construction helpers Creates helper functions in helpers package for constructing RFC 6901-compliant JSON Pointer paths to OpenAPI specification locations. New functions: - EscapeJSONPointerSegment: Escapes ~ and / characters per RFC 6901 - ConstructParameterJSONPointer: Builds paths for parameter schemas - ConstructResponseHeaderJSONPointer: Builds paths for response headers This eliminates duplication of the escaping logic across 72+ locations in the codebase and provides a single source of truth for JSON Pointer construction. Pattern: Before: Manual escaping in each error function (3 lines of code each) After: Single function call with semantic naming Next step: Refactor all existing inline JSON Pointer construction to use these helpers. --- helpers/json_pointer.go | 40 ++++++++++ helpers/json_pointer_test.go | 150 +++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 helpers/json_pointer.go create mode 100644 helpers/json_pointer_test.go diff --git a/helpers/json_pointer.go b/helpers/json_pointer.go new file mode 100644 index 0000000..3ec390c --- /dev/null +++ b/helpers/json_pointer.go @@ -0,0 +1,40 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package helpers + +import ( + "fmt" + "strings" +) + +// EscapeJSONPointerSegment escapes a single segment for use in a JSON Pointer (RFC 6901). +// It replaces '~' with '~0' and '/' with '~1'. +func EscapeJSONPointerSegment(segment string) string { + escaped := strings.ReplaceAll(segment, "~", "~0") + escaped = strings.ReplaceAll(escaped, "/", "~1") + return escaped +} + +// ConstructParameterJSONPointer constructs a full JSON Pointer path for a parameter +// in the OpenAPI specification. +// Format: /paths/{path}/{method}/parameters/{paramName}/schema/{keyword} +// The path segment is automatically escaped according to RFC 6901. +func ConstructParameterJSONPointer(pathTemplate, method, paramName, keyword string) string { + escapedPath := EscapeJSONPointerSegment(pathTemplate) + escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading slash encoding + method = strings.ToLower(method) + return fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/%s", escapedPath, method, paramName, keyword) +} + +// ConstructResponseHeaderJSONPointer constructs a full JSON Pointer path for a response header +// in the OpenAPI specification. +// Format: /paths/{path}/{method}/responses/{statusCode}/headers/{headerName}/{keyword} +// The path segment is automatically escaped according to RFC 6901. +func ConstructResponseHeaderJSONPointer(pathTemplate, method, statusCode, headerName, keyword string) string { + escapedPath := EscapeJSONPointerSegment(pathTemplate) + escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading slash encoding + method = strings.ToLower(method) + return fmt.Sprintf("/paths/%s/%s/responses/%s/headers/%s/%s", escapedPath, method, statusCode, headerName, keyword) +} + diff --git a/helpers/json_pointer_test.go b/helpers/json_pointer_test.go new file mode 100644 index 0000000..f96eb8a --- /dev/null +++ b/helpers/json_pointer_test.go @@ -0,0 +1,150 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEscapeJSONPointerSegment(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no special characters", + input: "simple", + expected: "simple", + }, + { + name: "tilde only", + input: "some~thing", + expected: "some~0thing", + }, + { + name: "slash only", + input: "path/to/something", + expected: "path~1to~1something", + }, + { + name: "both tilde and slash", + input: "path/with~special/chars~", + expected: "path~1with~0special~1chars~0", + }, + { + name: "path template", + input: "/users/{id}", + expected: "~1users~1{id}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EscapeJSONPointerSegment(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestConstructParameterJSONPointer(t *testing.T) { + tests := []struct { + name string + pathTemplate string + method string + paramName string + keyword string + expected string + }{ + { + name: "simple path with query parameter type", + pathTemplate: "/users", + method: "GET", + paramName: "limit", + keyword: "type", + expected: "/paths/users/get/parameters/limit/schema/type", + }, + { + name: "path with parameter and enum keyword", + pathTemplate: "/users/{id}", + method: "POST", + paramName: "status", + keyword: "enum", + expected: "/paths/users~1{id}/post/parameters/status/schema/enum", + }, + { + name: "path with tilde character", + pathTemplate: "/some~path", + method: "PUT", + paramName: "value", + keyword: "format", + expected: "/paths/some~0path/put/parameters/value/schema/format", + }, + { + name: "path with multiple slashes", + pathTemplate: "/api/v1/users/{userId}/posts/{postId}", + method: "DELETE", + paramName: "filter", + keyword: "required", + expected: "/paths/api~1v1~1users~1{userId}~1posts~1{postId}/delete/parameters/filter/schema/required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConstructParameterJSONPointer(tt.pathTemplate, tt.method, tt.paramName, tt.keyword) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestConstructResponseHeaderJSONPointer(t *testing.T) { + tests := []struct { + name string + pathTemplate string + method string + statusCode string + headerName string + keyword string + expected string + }{ + { + name: "simple response header", + pathTemplate: "/health", + method: "GET", + statusCode: "200", + headerName: "X-Request-ID", + keyword: "required", + expected: "/paths/health/get/responses/200/headers/X-Request-ID/required", + }, + { + name: "path with parameter", + pathTemplate: "/users/{id}", + method: "POST", + statusCode: "201", + headerName: "Location", + keyword: "schema", + expected: "/paths/users~1{id}/post/responses/201/headers/Location/schema", + }, + { + name: "path with tilde and slash", + pathTemplate: "/some~path/to/resource", + method: "PUT", + statusCode: "204", + headerName: "ETag", + keyword: "type", + expected: "/paths/some~0path~1to~1resource/put/responses/204/headers/ETag/type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConstructResponseHeaderJSONPointer(tt.pathTemplate, tt.method, tt.statusCode, tt.headerName, tt.keyword) + assert.Equal(t, tt.expected, result) + }) + } +} + From 80d4fa77b8829d72ec7dd77a3ed3251134e26dd8 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 7 Nov 2025 16:25:33 -0800 Subject: [PATCH 11/25] Response headers: add SchemaValidationFailure with full OpenAPI path When a response header is marked as required in the OpenAPI schema and is missing from the response, this is a schema constraint violation. Added SchemaValidationFailure with full OpenAPI path context for KeywordLocation. Updated ValidateResponseHeaders signature to accept pathTemplate and statusCode to construct full JSON Pointer paths like: /paths/~1health/get/responses/200/headers/chicken-nuggets/required This makes header validation consistent with request/response body validation, which also uses full OpenAPI document paths for KeywordLocation. Note: Considered using relative paths (/header-name/required) but chose full paths for consistency with body validation patterns. Both approaches have tradeoffs documented in PR description. --- responses/validate_body.go | 2 +- responses/validate_headers.go | 16 ++++++++++++++++ responses/validate_headers_test.go | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/responses/validate_body.go b/responses/validate_body.go index fc760db..87bb53a 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -109,7 +109,7 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R if foundResponse != nil { // check for headers in the response if foundResponse.Headers != nil { - if ok, herrs := ValidateResponseHeaders(request, response, foundResponse.Headers); !ok { + if ok, herrs := ValidateResponseHeaders(request, response, foundResponse.Headers, pathFound, codeStr); !ok { validationErrors = append(validationErrors, herrs...) } } diff --git a/responses/validate_headers.go b/responses/validate_headers.go index ee284fa..2d5499c 100644 --- a/responses/validate_headers.go +++ b/responses/validate_headers.go @@ -23,6 +23,8 @@ func ValidateResponseHeaders( request *http.Request, response *http.Response, headers *orderedmap.Map[string, *v3.Header], + pathTemplate string, + statusCode string, opts ...config.Option, ) (bool, []*errors.ValidationError) { options := config.NewValidationOptions(opts...) @@ -53,6 +55,14 @@ func ValidateResponseHeaders( for name, header := range headers.FromOldest() { if header.Required { if _, ok := locatedHeaders[strings.ToLower(name)]; !ok { + // Construct full OpenAPI path for KeywordLocation + // e.g., /paths/~1health/get/responses/200/headers/chicken-nuggets/required + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + method := strings.ToLower(request.Method) + keywordLocation := fmt.Sprintf("/paths/%s/%s/responses/%s/headers/%s/required", + escapedPath, method, statusCode, name) + validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.ResponseBodyValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -63,6 +73,12 @@ func ValidateResponseHeaders( HowToFix: errors.HowToFixMissingHeader, RequestPath: request.URL.Path, RequestMethod: request.Method, + SchemaValidationErrors: []*errors.SchemaValidationFailure{{ + Reason: fmt.Sprintf("Required header '%s' is missing", name), + FieldName: name, + InstancePath: []string{name}, + KeywordLocation: keywordLocation, + }}, }) } } diff --git a/responses/validate_headers_test.go b/responses/validate_headers_test.go index feb5600..ddcf521 100644 --- a/responses/validate_headers_test.go +++ b/responses/validate_headers_test.go @@ -54,7 +54,7 @@ paths: headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers // validate! - valid, errors := ValidateResponseHeaders(request, response, headers) + valid, errors := ValidateResponseHeaders(request, response, headers, "/health", "200") assert.False(t, valid) assert.Len(t, errors, 1) @@ -76,7 +76,7 @@ paths: headers = m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers // validate! - valid, errors = ValidateResponseHeaders(request, response, headers) + valid, errors = ValidateResponseHeaders(request, response, headers, "/health", "200") assert.False(t, valid) assert.Len(t, errors, 1) @@ -125,7 +125,7 @@ paths: headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers // validate! - valid, errors := ValidateResponseHeaders(request, response, headers) + valid, errors := ValidateResponseHeaders(request, response, headers, "/health", "200") assert.True(t, valid) assert.Len(t, errors, 0) From d936a48b8767e67860cbf48aae2357fe30a74d4f Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Fri, 7 Nov 2025 18:29:44 -0800 Subject: [PATCH 12/25] Parameters: render path param schema once, pass to error functions for ReferenceSchema - Render parameter schema once in path_parameters.go instead of in each error function - Pass renderedSchema to all 8 path parameter error functions (bool, enum, integer, number, array variants) - Update Context field to use raw base.Schema (programmatic access) - Update ReferenceSchema field to use rendered JSON string (API consumers) - Use full OpenAPI JSON Pointer paths for KeywordLocation (e.g., /paths/~1users~1{id}/parameters/id/schema/type) - Serialize full schema objects for ReferenceSchema instead of just type strings - Update resolveNumber and resolveInteger helpers to accept and pass renderedSchema Note: This approach (Context=raw schema, ReferenceSchema=rendered string) will be reviewed later for consistency across the codebase --- errors/parameter_errors.go | 117 +++++++++++++++++++++++++++++--- errors/parameter_errors_test.go | 16 ++--- parameters/path_parameters.go | 56 +++++++++------ 3 files changed, 154 insertions(+), 35 deletions(-) diff --git a/errors/parameter_errors.go b/errors/parameter_errors.go index 2f9768a..4bf6754 100644 --- a/errors/parameter_errors.go +++ b/errors/parameter_errors.go @@ -507,7 +507,12 @@ func IncorrectHeaderParamArrayNumber( } } -func IncorrectPathParamBool(param *v3.Parameter, item string, sch *base.Schema) *ValidationError { +func IncorrectPathParamBool(param *v3.Parameter, item string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/type", escapedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -518,15 +523,28 @@ func IncorrectPathParamBool(param *v3.Parameter, item string, sch *base.Schema) SpecCol: param.GoLow().Schema.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid boolean", item), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectPathParamEnum(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectPathParamEnum(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/enum", escapedPath, param.Name) + var enums []string for i := range sch.Enum { enums = append(enums, fmt.Sprint(sch.Enum[i].Value)) } validEnums := strings.Join(enums, ", ") + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -537,10 +555,22 @@ func IncorrectPathParamEnum(param *v3.Parameter, ef string, sch *base.Schema) *V SpecCol: param.GoLow().Schema.Value.Schema().Enum.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidEnum, ef, validEnums), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' does not match any enum values: [%s]", ef, validEnums), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectPathParamInteger(param *v3.Parameter, item string, sch *base.Schema) *ValidationError { +func IncorrectPathParamInteger(param *v3.Parameter, item string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/type", escapedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -552,10 +582,22 @@ func IncorrectPathParamInteger(param *v3.Parameter, item string, sch *base.Schem ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidInteger, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid integer", item), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectPathParamNumber(param *v3.Parameter, item string, sch *base.Schema) *ValidationError { +func IncorrectPathParamNumber(param *v3.Parameter, item string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/type", escapedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -566,12 +608,24 @@ func IncorrectPathParamNumber(param *v3.Parameter, item string, sch *base.Schema SpecCol: param.GoLow().Schema.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid number", item), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } func IncorrectPathParamArrayNumber( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, renderedSchema string, ) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/items/type", escapedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -582,12 +636,24 @@ func IncorrectPathParamArrayNumber( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid number", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } func IncorrectPathParamArrayInteger( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, renderedSchema string, ) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/items/type", escapedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -598,12 +664,24 @@ func IncorrectPathParamArrayInteger( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid integer", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } func IncorrectPathParamArrayBoolean( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, renderedSchema string, ) *ValidationError { + // Build full OpenAPI path for KeywordLocation + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/items/type", escapedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -614,10 +692,26 @@ func IncorrectPathParamArrayBoolean( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid boolean", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func PathParameterMissing(param *v3.Parameter) *ValidationError { +func PathParameterMissing(param *v3.Parameter, pathTemplate string, actualPath string) *ValidationError { + // Build instance path showing the URL structure + actualSegments := strings.Split(strings.Trim(actualPath, "/"), "/") + + // Build keyword location with path template (JSON Pointer encoding: / becomes ~1) + encodedPath := strings.ReplaceAll(pathTemplate, "~", "~0") // Escape ~ first + encodedPath = strings.ReplaceAll(encodedPath, "/", "~1") // Then escape / + encodedPath = strings.TrimPrefix(encodedPath, "~1") // Remove leading ~1 + keywordLoc := fmt.Sprintf("/paths/%s/parameters/%s/required", encodedPath, param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationPath, @@ -627,5 +721,12 @@ func PathParameterMissing(param *v3.Parameter) *ValidationError { SpecLine: param.GoLow().Required.KeyNode.Line, SpecCol: param.GoLow().Required.KeyNode.Column, HowToFix: HowToFixMissingValue, + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Required path parameter '%s' is missing from path '%s'", param.Name, actualPath), + FieldName: param.Name, + FieldPath: "", + InstancePath: actualSegments, + KeywordLocation: keywordLoc, + }}, } } diff --git a/errors/parameter_errors_test.go b/errors/parameter_errors_test.go index 4f8304a..f76106c 100644 --- a/errors/parameter_errors_test.go +++ b/errors/parameter_errors_test.go @@ -914,7 +914,7 @@ func TestIncorrectPathParamBool(t *testing.T) { param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := IncorrectPathParamBool(param, "milky", highSchema) + err := IncorrectPathParamBool(param, "milky", highSchema, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -945,7 +945,7 @@ items: } param.GoLow().Schema.Value.Schema().Enum.KeyNode = &yaml.Node{} - err := IncorrectPathParamEnum(param, "milky", highSchema) + err := IncorrectPathParamEnum(param, "milky", highSchema, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -970,7 +970,7 @@ func TestIncorrectPathParamNumber(t *testing.T) { param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := IncorrectPathParamNumber(param, "milky", highSchema) + err := IncorrectPathParamNumber(param, "milky", highSchema, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -995,7 +995,7 @@ func TestIncorrectPathParamInteger(t *testing.T) { param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := IncorrectPathParamInteger(param, "milky", highSchema) + err := IncorrectPathParamInteger(param, "milky", highSchema, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -1021,7 +1021,7 @@ func TestIncorrectPathParamArrayNumber(t *testing.T) { param := createMockParameter() param.Name = "bubbles" - err := IncorrectPathParamArrayNumber(param, "milky", highSchema, nil) + err := IncorrectPathParamArrayNumber(param, "milky", highSchema, nil, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -1047,7 +1047,7 @@ func TestIncorrectPathParamArrayInteger(t *testing.T) { param := createMockParameter() param.Name = "bubbles" - err := IncorrectPathParamArrayInteger(param, "milky", highSchema, nil) + err := IncorrectPathParamArrayInteger(param, "milky", highSchema, nil, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -1073,7 +1073,7 @@ func TestIncorrectPathParamArrayBoolean(t *testing.T) { param := createMockParameter() param.Name = "bubbles" - err := IncorrectPathParamArrayBoolean(param, "milky", highSchema, nil) + err := IncorrectPathParamArrayBoolean(param, "milky", highSchema, nil, "/test-path", "{}") // Validate the error require.NotNil(t, err) @@ -1098,7 +1098,7 @@ func TestPathParameterMissing(t *testing.T) { param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := PathParameterMissing(param) + err := PathParameterMissing(param, "/test/{testQueryParam}", "/test/") // Validate the error require.NotNil(t, err) diff --git a/parameters/path_parameters.go b/parameters/path_parameters.go index 31f7e3c..78d8339 100644 --- a/parameters/path_parameters.go +++ b/parameters/path_parameters.go @@ -4,6 +4,7 @@ package parameters import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -129,7 +130,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p if decodedParamValue == "" { // Mandatory path parameter cannot be empty if p.Required != nil && *p.Required { - validationErrors = append(validationErrors, errors.PathParameterMissing(p)) + validationErrors = append(validationErrors, errors.PathParameterMissing(p, pathValue, request.URL.Path)) break } continue @@ -138,6 +139,14 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p // extract the schema from the parameter sch := p.Schema.Schema() + // Render schema once for ReferenceSchema field in errors + var renderedSchema string + if sch != nil { + rendered, _ := sch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedSchema = string(schemaBytes) + } + // check enum (if present) enumCheck := func(decodedValue string) { matchFound := false @@ -149,7 +158,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectPathParamEnum(p, strings.ToLower(decodedValue), sch)) + errors.IncorrectPathParamEnum(p, strings.ToLower(decodedValue), sch, pathValue, renderedSchema)) } } @@ -180,7 +189,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p case helpers.Integer: // simple use case is already handled in find param. - rawParamValue, paramValueParsed, err := v.resolveInteger(sch, p, isLabel, isMatrix, decodedParamValue) + rawParamValue, paramValueParsed, err := v.resolveInteger(sch, p, isLabel, isMatrix, decodedParamValue, pathValue, renderedSchema) if err != nil { validationErrors = append(validationErrors, err...) break @@ -203,7 +212,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p case helpers.Number: // simple use case is already handled in find param. - rawParamValue, paramValueParsed, err := v.resolveNumber(sch, p, isLabel, isMatrix, decodedParamValue) + rawParamValue, paramValueParsed, err := v.resolveNumber(sch, p, isLabel, isMatrix, decodedParamValue, pathValue, renderedSchema) if err != nil { validationErrors = append(validationErrors, err...) break @@ -228,13 +237,13 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p if isLabel && p.Style == helpers.LabelStyle { if _, err := strconv.ParseBool(decodedParamValue[1:]); err != nil { validationErrors = append(validationErrors, - errors.IncorrectPathParamBool(p, decodedParamValue[1:], sch)) + errors.IncorrectPathParamBool(p, decodedParamValue[1:], sch, pathValue, renderedSchema)) } } if isSimple { if _, err := strconv.ParseBool(decodedParamValue); err != nil { validationErrors = append(validationErrors, - errors.IncorrectPathParamBool(p, decodedParamValue, sch)) + errors.IncorrectPathParamBool(p, decodedParamValue, sch, pathValue, renderedSchema)) } } if isMatrix && p.Style == helpers.MatrixStyle { @@ -242,7 +251,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p decodedForMatrix := strings.Replace(decodedParamValue[1:], fmt.Sprintf("%s=", p.Name), "", 1) if _, err := strconv.ParseBool(decodedForMatrix); err != nil { validationErrors = append(validationErrors, - errors.IncorrectPathParamBool(p, decodedForMatrix, sch)) + errors.IncorrectPathParamBool(p, decodedForMatrix, sch, pathValue, renderedSchema)) } } case helpers.Object: @@ -290,6 +299,15 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p // extract the items schema in order to validate the array items. if sch.Items != nil && sch.Items.IsA() { iSch := sch.Items.A.Schema() + + // Render items schema once for ReferenceSchema field in array errors + var renderedItemsSchema string + if iSch != nil { + rendered, _ := iSch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedItemsSchema = string(schemaBytes) + } + for n := range iSch.Type { // determine how to explode the array var arrayValues []string @@ -317,14 +335,14 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p for pv := range arrayValues { if _, err := strconv.ParseInt(arrayValues[pv], 10, 64); err != nil { validationErrors = append(validationErrors, - errors.IncorrectPathParamArrayInteger(p, arrayValues[pv], sch, iSch)) + errors.IncorrectPathParamArrayInteger(p, arrayValues[pv], sch, iSch, pathValue, renderedItemsSchema)) } } case helpers.Number: for pv := range arrayValues { if _, err := strconv.ParseFloat(arrayValues[pv], 64); err != nil { validationErrors = append(validationErrors, - errors.IncorrectPathParamArrayNumber(p, arrayValues[pv], sch, iSch)) + errors.IncorrectPathParamArrayNumber(p, arrayValues[pv], sch, iSch, pathValue, renderedItemsSchema)) } } case helpers.Boolean: @@ -332,7 +350,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p bc := len(validationErrors) if _, err := strconv.ParseBool(arrayValues[pv]); err != nil { validationErrors = append(validationErrors, - errors.IncorrectPathParamArrayBoolean(p, arrayValues[pv], sch, iSch)) + errors.IncorrectPathParamArrayBoolean(p, arrayValues[pv], sch, iSch, pathValue, renderedItemsSchema)) continue } if len(validationErrors) == bc { @@ -340,7 +358,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p // need to catch this edge case. if arrayValues[pv] == "0" || arrayValues[pv] == "1" { validationErrors = append(validationErrors, - errors.IncorrectPathParamArrayBoolean(p, arrayValues[pv], sch, iSch)) + errors.IncorrectPathParamArrayBoolean(p, arrayValues[pv], sch, iSch, pathValue, renderedItemsSchema)) continue } } @@ -364,11 +382,11 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p return true, nil } -func (v *paramValidator) resolveNumber(sch *base.Schema, p *v3.Parameter, isLabel bool, isMatrix bool, paramValue string) (string, float64, []*errors.ValidationError) { +func (v *paramValidator) resolveNumber(sch *base.Schema, p *v3.Parameter, isLabel bool, isMatrix bool, paramValue string, pathValue string, renderedSchema string) (string, float64, []*errors.ValidationError) { if isLabel && p.Style == helpers.LabelStyle { paramValueParsed, err := strconv.ParseFloat(paramValue[1:], 64) if err != nil { - return "", 0, []*errors.ValidationError{errors.IncorrectPathParamNumber(p, paramValue[1:], sch)} + return "", 0, []*errors.ValidationError{errors.IncorrectPathParamNumber(p, paramValue[1:], sch, pathValue, renderedSchema)} } return paramValue[1:], paramValueParsed, nil } @@ -377,22 +395,22 @@ func (v *paramValidator) resolveNumber(sch *base.Schema, p *v3.Parameter, isLabe paramValue = strings.Replace(paramValue[1:], fmt.Sprintf("%s=", p.Name), "", 1) paramValueParsed, err := strconv.ParseFloat(paramValue, 64) if err != nil { - return "", 0, []*errors.ValidationError{errors.IncorrectPathParamNumber(p, paramValue[1:], sch)} + return "", 0, []*errors.ValidationError{errors.IncorrectPathParamNumber(p, paramValue[1:], sch, pathValue, renderedSchema)} } return paramValue, paramValueParsed, nil } paramValueParsed, err := strconv.ParseFloat(paramValue, 64) if err != nil { - return "", 0, []*errors.ValidationError{errors.IncorrectPathParamNumber(p, paramValue, sch)} + return "", 0, []*errors.ValidationError{errors.IncorrectPathParamNumber(p, paramValue, sch, pathValue, renderedSchema)} } return paramValue, paramValueParsed, nil } -func (v *paramValidator) resolveInteger(sch *base.Schema, p *v3.Parameter, isLabel bool, isMatrix bool, paramValue string) (string, int64, []*errors.ValidationError) { +func (v *paramValidator) resolveInteger(sch *base.Schema, p *v3.Parameter, isLabel bool, isMatrix bool, paramValue string, pathValue string, renderedSchema string) (string, int64, []*errors.ValidationError) { if isLabel && p.Style == helpers.LabelStyle { paramValueParsed, err := strconv.ParseInt(paramValue[1:], 10, 64) if err != nil { - return "", 0, []*errors.ValidationError{errors.IncorrectPathParamInteger(p, paramValue[1:], sch)} + return "", 0, []*errors.ValidationError{errors.IncorrectPathParamInteger(p, paramValue[1:], sch, pathValue, renderedSchema)} } return paramValue[1:], paramValueParsed, nil } @@ -401,13 +419,13 @@ func (v *paramValidator) resolveInteger(sch *base.Schema, p *v3.Parameter, isLab paramValue = strings.Replace(paramValue[1:], fmt.Sprintf("%s=", p.Name), "", 1) paramValueParsed, err := strconv.ParseInt(paramValue, 10, 64) if err != nil { - return "", 0, []*errors.ValidationError{errors.IncorrectPathParamInteger(p, paramValue[1:], sch)} + return "", 0, []*errors.ValidationError{errors.IncorrectPathParamInteger(p, paramValue[1:], sch, pathValue, renderedSchema)} } return paramValue, paramValueParsed, nil } paramValueParsed, err := strconv.ParseInt(paramValue, 10, 64) if err != nil { - return "", 0, []*errors.ValidationError{errors.IncorrectPathParamInteger(p, paramValue, sch)} + return "", 0, []*errors.ValidationError{errors.IncorrectPathParamInteger(p, paramValue, sch, pathValue, renderedSchema)} } return paramValue, paramValueParsed, nil } From e6e7b45aa756c9289b3a99f6bdcbbbb6adbced48 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 08:42:59 -0800 Subject: [PATCH 13/25] Makes all query params return a SchemaValidationError Changes: - Remove 'Build full OpenAPI path for KeywordLocation' comments - Remove inline comments from previous commits - Add renderedSchema parameter to QueryParameterMissing - Set ReferenceSchema field in QueryParameterMissing SchemaValidationFailure - Render schema for missing required parameters before creating error - Update tests to pass renderedSchema parameter --- errors/parameter_errors.go | 214 +++++++++++++++++++++++++---- errors/parameter_errors_test.go | 28 ++-- parameters/query_parameters.go | 56 ++++++-- parameters/validate_parameter.go | 39 +++++- parameters/validation_functions.go | 24 ++-- 5 files changed, 295 insertions(+), 66 deletions(-) diff --git a/errors/parameter_errors.go b/errors/parameter_errors.go index 4bf6754..44c6c7e 100644 --- a/errors/parameter_errors.go +++ b/errors/parameter_errors.go @@ -76,7 +76,12 @@ func InvalidDeepObject(param *v3.Parameter, qp *helpers.QueryParam) *ValidationE } } -func QueryParameterMissing(param *v3.Parameter) *ValidationError { +func QueryParameterMissing(param *v3.Parameter, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/required", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -86,6 +91,14 @@ func QueryParameterMissing(param *v3.Parameter) *ValidationError { SpecLine: param.GoLow().Required.KeyNode.Line, SpecCol: param.GoLow().Required.KeyNode.Column, HowToFix: HowToFixMissingValue, + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Required query parameter '%s' is missing", param.Name), + FieldName: param.Name, + FieldPath: "", + InstancePath: []string{}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } @@ -135,8 +148,13 @@ func IncorrectHeaderParamEnum(param *v3.Parameter, ef string, sch *base.Schema) } func IncorrectQueryParamArrayBoolean( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -147,10 +165,22 @@ func IncorrectQueryParamArrayBoolean( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid boolean", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } -func IncorrectParamArrayMaxNumItems(param *v3.Parameter, sch *base.Schema, expected, actual int64) *ValidationError { +func IncorrectParamArrayMaxNumItems(param *v3.Parameter, sch *base.Schema, expected, actual int64, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/maxItems", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -161,10 +191,22 @@ func IncorrectParamArrayMaxNumItems(param *v3.Parameter, sch *base.Schema, expec SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixInvalidMaxItems, expected), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array has %d items, but maximum is %d", actual, expected), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectParamArrayMinNumItems(param *v3.Parameter, sch *base.Schema, expected, actual int64) *ValidationError { +func IncorrectParamArrayMinNumItems(param *v3.Parameter, sch *base.Schema, expected, actual int64, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/minItems", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -175,10 +217,22 @@ func IncorrectParamArrayMinNumItems(param *v3.Parameter, sch *base.Schema, expec SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixInvalidMinItems, expected), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array has %d items, but minimum is %d", actual, expected), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectParamArrayUniqueItems(param *v3.Parameter, sch *base.Schema, duplicates string) *ValidationError { +func IncorrectParamArrayUniqueItems(param *v3.Parameter, sch *base.Schema, duplicates string, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/uniqueItems", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -188,6 +242,13 @@ func IncorrectParamArrayUniqueItems(param *v3.Parameter, sch *base.Schema, dupli SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: sch, HowToFix: "Ensure the array values are all unique", + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array contains duplicate values: %s", duplicates), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } @@ -208,8 +269,13 @@ func IncorrectCookieParamArrayBoolean( } func IncorrectQueryParamArrayInteger( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -220,12 +286,24 @@ func IncorrectQueryParamArrayInteger( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidInteger, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid integer", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } func IncorrectQueryParamArrayNumber( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -236,6 +314,13 @@ func IncorrectQueryParamArrayNumber( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid number", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } @@ -255,7 +340,12 @@ func IncorrectCookieParamArrayNumber( } } -func IncorrectParamEncodingJSON(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectParamEncodingJSON(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/content/application~1json/schema", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -266,10 +356,22 @@ func IncorrectParamEncodingJSON(param *v3.Parameter, ef string, sch *base.Schema SpecCol: param.GoLow().FindContent(helpers.JSONContentType).ValueNode.Column, Context: sch, HowToFix: HowToFixInvalidJSON, + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not valid JSON", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectQueryParamBool(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectQueryParamBool(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -281,10 +383,22 @@ func IncorrectQueryParamBool(param *v3.Parameter, ef string, sch *base.Schema) * ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid boolean", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func InvalidQueryParamInteger(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func InvalidQueryParamInteger(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -296,10 +410,22 @@ func InvalidQueryParamInteger(param *v3.Parameter, ef string, sch *base.Schema) ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidInteger, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid integer", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func InvalidQueryParamNumber(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func InvalidQueryParamNumber(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -311,15 +437,28 @@ func InvalidQueryParamNumber(param *v3.Parameter, ef string, sch *base.Schema) * ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid number", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectQueryParamEnum(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectQueryParamEnum(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { var enums []string for i := range sch.Enum { enums = append(enums, fmt.Sprint(sch.Enum[i].Value)) } validEnums := strings.Join(enums, ", ") + + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/enum", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -331,10 +470,17 @@ func IncorrectQueryParamEnum(param *v3.Parameter, ef string, sch *base.Schema) * ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidEnum, ef, validEnums), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' does not match any enum values: [%s]", ef, validEnums), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectQueryParamEnumArray(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectQueryParamEnumArray(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedItemsSchema string) *ValidationError { var enums []string // look at that model fly! for i := range param.GoLow().Schema.Value.Schema().Items.Value.A.Schema().Enum.Value { @@ -342,6 +488,12 @@ func IncorrectQueryParamEnumArray(param *v3.Parameter, ef string, sch *base.Sche fmt.Sprint(param.GoLow().Schema.Value.Schema().Items.Value.A.Schema().Enum.Value[i].Value.Value)) } validEnums := strings.Join(enums, ", ") + + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/enum", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -352,10 +504,22 @@ func IncorrectQueryParamEnumArray(param *v3.Parameter, ef string, sch *base.Sche SpecCol: param.GoLow().Schema.Value.Schema().Items.Value.A.Schema().Enum.KeyNode.Line, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidEnum, ef, validEnums), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' does not match any enum values: [%s]", ef, validEnums), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } -func IncorrectReservedValues(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectReservedValues(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/allowReserved", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationQuery, @@ -366,6 +530,13 @@ func IncorrectReservedValues(param *v3.Parameter, ef string, sch *base.Schema) * SpecCol: param.GoLow().Schema.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixReservedValues, url.QueryEscape(ef)), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' contains reserved characters but allowReserved is false", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } @@ -508,7 +679,6 @@ func IncorrectHeaderParamArrayNumber( } func IncorrectPathParamBool(param *v3.Parameter, item string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/type", escapedPath, param.Name) @@ -534,7 +704,6 @@ func IncorrectPathParamBool(param *v3.Parameter, item string, sch *base.Schema, } func IncorrectPathParamEnum(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/enum", escapedPath, param.Name) @@ -566,7 +735,6 @@ func IncorrectPathParamEnum(param *v3.Parameter, ef string, sch *base.Schema, pa } func IncorrectPathParamInteger(param *v3.Parameter, item string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/type", escapedPath, param.Name) @@ -593,7 +761,6 @@ func IncorrectPathParamInteger(param *v3.Parameter, item string, sch *base.Schem } func IncorrectPathParamNumber(param *v3.Parameter, item string, sch *base.Schema, pathTemplate string, renderedSchema string) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/type", escapedPath, param.Name) @@ -621,7 +788,6 @@ func IncorrectPathParamNumber(param *v3.Parameter, item string, sch *base.Schema func IncorrectPathParamArrayNumber( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, renderedSchema string, ) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/items/type", escapedPath, param.Name) @@ -649,7 +815,6 @@ func IncorrectPathParamArrayNumber( func IncorrectPathParamArrayInteger( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, renderedSchema string, ) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/items/type", escapedPath, param.Name) @@ -677,7 +842,6 @@ func IncorrectPathParamArrayInteger( func IncorrectPathParamArrayBoolean( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, renderedSchema string, ) *ValidationError { - // Build full OpenAPI path for KeywordLocation escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") keywordLocation := fmt.Sprintf("/paths/%s/parameters/%s/schema/items/type", escapedPath, param.Name) @@ -703,13 +867,11 @@ func IncorrectPathParamArrayBoolean( } func PathParameterMissing(param *v3.Parameter, pathTemplate string, actualPath string) *ValidationError { - // Build instance path showing the URL structure actualSegments := strings.Split(strings.Trim(actualPath, "/"), "/") - // Build keyword location with path template (JSON Pointer encoding: / becomes ~1) - encodedPath := strings.ReplaceAll(pathTemplate, "~", "~0") // Escape ~ first - encodedPath = strings.ReplaceAll(encodedPath, "/", "~1") // Then escape / - encodedPath = strings.TrimPrefix(encodedPath, "~1") // Remove leading ~1 + encodedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + encodedPath = strings.ReplaceAll(encodedPath, "/", "~1") + encodedPath = strings.TrimPrefix(encodedPath, "~1") keywordLoc := fmt.Sprintf("/paths/%s/parameters/%s/required", encodedPath, param.Name) return &ValidationError{ diff --git a/errors/parameter_errors_test.go b/errors/parameter_errors_test.go index f76106c..afc953a 100644 --- a/errors/parameter_errors_test.go +++ b/errors/parameter_errors_test.go @@ -119,7 +119,7 @@ func TestQueryParameterMissing(t *testing.T) { param := createMockParameterWithSchema() // Call the function - err := QueryParameterMissing(param) + err := QueryParameterMissing(param, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -223,7 +223,7 @@ func TestIncorrectQueryParamArrayBoolean(t *testing.T) { schema := base.NewSchema(s) // Call the function with an invalid boolean value in the array - err := IncorrectQueryParamArrayBoolean(param, "notBoolean", schema, schema.Items.A.Schema()) + err := IncorrectQueryParamArrayBoolean(param, "notBoolean", schema, schema.Items.A.Schema(), "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -375,7 +375,7 @@ func TestIncorrectQueryParamArrayInteger(t *testing.T) { itemsSchema := base.NewSchema(baseSchema.Items.Value.A.Schema()) // Call the function with an invalid number value in the array - err := IncorrectQueryParamArrayInteger(param, "notNumber", s, itemsSchema) + err := IncorrectQueryParamArrayInteger(param, "notNumber", s, itemsSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -394,7 +394,7 @@ func TestIncorrectQueryParamArrayNumber(t *testing.T) { itemsSchema := base.NewSchema(baseSchema.Items.Value.A.Schema()) // Call the function with an invalid number value in the array - err := IncorrectQueryParamArrayNumber(param, "notNumber", s, itemsSchema) + err := IncorrectQueryParamArrayNumber(param, "notNumber", s, itemsSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -531,7 +531,7 @@ func TestIncorrectParamEncodingJSON(t *testing.T) { baseSchema := createMockLowBaseSchema() // Call the function with an invalid JSON value - err := IncorrectParamEncodingJSON(param, "invalidJSON", base.NewSchema(baseSchema)) + err := IncorrectParamEncodingJSON(param, "invalidJSON", base.NewSchema(baseSchema), "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -560,7 +560,7 @@ func TestIncorrectQueryParamBool(t *testing.T) { }) // Call the function with an invalid boolean value - err := IncorrectQueryParamBool(param, "notBoolean", base.NewSchema(baseSchema)) + err := IncorrectQueryParamBool(param, "notBoolean", base.NewSchema(baseSchema), "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -576,7 +576,7 @@ func TestInvalidQueryParamNumber(t *testing.T) { baseSchema := createMockLowBaseSchema() // Call the function with an invalid number value - err := InvalidQueryParamNumber(param, "notNumber", base.NewSchema(baseSchema)) + err := InvalidQueryParamNumber(param, "notNumber", base.NewSchema(baseSchema), "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -592,7 +592,7 @@ func TestInvalidQueryParamInteger(t *testing.T) { baseSchema := createMockLowBaseSchema() // Call the function with an invalid number value - err := InvalidQueryParamInteger(param, "notNumber", base.NewSchema(baseSchema)) + err := InvalidQueryParamInteger(param, "notNumber", base.NewSchema(baseSchema), "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -617,7 +617,7 @@ func TestIncorrectQueryParamEnum(t *testing.T) { param.GoLow().Schema.Value.Schema().Enum.KeyNode = &yaml.Node{} // Call the function with an invalid enum value - err := IncorrectQueryParamEnum(param, "invalidEnum", highSchema) + err := IncorrectQueryParamEnum(param, "invalidEnum", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -646,7 +646,7 @@ func TestIncorrectQueryParamEnumArray(t *testing.T) { } // Call the function with an invalid enum value - err := IncorrectQueryParamEnumArray(param, "invalidEnum", highSchema) + err := IncorrectQueryParamEnumArray(param, "invalidEnum", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -669,7 +669,7 @@ func TestIncorrectReservedValues(t *testing.T) { param := createMockParameter() param.Name = "borked::?^&*" - err := IncorrectReservedValues(param, "borked::?^&*", highSchema) + err := IncorrectReservedValues(param, "borked::?^&*", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -1124,7 +1124,7 @@ items: param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := IncorrectParamArrayMaxNumItems(param, param.Schema.Schema(), 10, 25) + err := IncorrectParamArrayMaxNumItems(param, param.Schema.Schema(), 10, 25, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -1150,7 +1150,7 @@ items: param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := IncorrectParamArrayMinNumItems(param, param.Schema.Schema(), 10, 5) + err := IncorrectParamArrayMinNumItems(param, param.Schema.Schema(), 10, 5, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -1176,7 +1176,7 @@ items: param.Schema = base.CreateSchemaProxy(highSchema) param.GoLow().Schema.KeyNode = &yaml.Node{} - err := IncorrectParamArrayUniqueItems(param, param.Schema.Schema(), "fish, cake") + err := IncorrectParamArrayUniqueItems(param, param.Schema.Schema(), "fish, cake", "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) diff --git a/parameters/query_parameters.go b/parameters/query_parameters.go index 888cbc8..91fe90c 100644 --- a/parameters/query_parameters.go +++ b/parameters/query_parameters.go @@ -70,6 +70,9 @@ func (v *paramValidator) ValidateQueryParamsWithPathItem(request *http.Request, } } + // Get operation from request method (lowercase for JSON Pointer) + operation := strings.ToLower(request.Method) + // look through the params for the query key doneLooking: for p := range params { @@ -101,6 +104,15 @@ doneLooking: break } } + + // Render schema once for ReferenceSchema field in errors + var renderedSchema string + if sch != nil { + rendered, _ := sch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedSchema = string(schemaBytes) + } + pType := sch.Type // for each param, check each type @@ -113,34 +125,34 @@ doneLooking: if !params[p].AllowReserved { if rxRxp.MatchString(ef) && params[p].IsExploded() { validationErrors = append(validationErrors, - errors.IncorrectReservedValues(params[p], ef, sch)) + errors.IncorrectReservedValues(params[p], ef, sch, pathValue, operation, renderedSchema)) } } for _, ty := range pType { switch ty { case helpers.String: - validationErrors = append(validationErrors, v.validateSimpleParam(sch, ef, ef, params[p])...) + validationErrors = append(validationErrors, v.validateSimpleParam(sch, ef, ef, params[p], pathValue, operation, renderedSchema)...) case helpers.Integer: efF, err := strconv.ParseInt(ef, 10, 64) if err != nil { validationErrors = append(validationErrors, - errors.InvalidQueryParamInteger(params[p], ef, sch)) + errors.InvalidQueryParamInteger(params[p], ef, sch, pathValue, operation, renderedSchema)) break } - validationErrors = append(validationErrors, v.validateSimpleParam(sch, ef, efF, params[p])...) + validationErrors = append(validationErrors, v.validateSimpleParam(sch, ef, efF, params[p], pathValue, operation, renderedSchema)...) case helpers.Number: efF, err := strconv.ParseFloat(ef, 64) if err != nil { validationErrors = append(validationErrors, - errors.InvalidQueryParamNumber(params[p], ef, sch)) + errors.InvalidQueryParamNumber(params[p], ef, sch, pathValue, operation, renderedSchema)) break } - validationErrors = append(validationErrors, v.validateSimpleParam(sch, ef, efF, params[p])...) + validationErrors = append(validationErrors, v.validateSimpleParam(sch, ef, efF, params[p], pathValue, operation, renderedSchema)...) case helpers.Boolean: if _, err := strconv.ParseBool(ef); err != nil { validationErrors = append(validationErrors, - errors.IncorrectQueryParamBool(params[p], ef, sch)) + errors.IncorrectQueryParamBool(params[p], ef, sch, pathValue, operation, renderedSchema)) } case helpers.Object: @@ -165,7 +177,7 @@ doneLooking: encodedObj = make(map[string]interface{}) if err := json.Unmarshal([]byte(ef), &encodedParams); err != nil { validationErrors = append(validationErrors, - errors.IncorrectParamEncodingJSON(params[p], ef, sch)) + errors.IncorrectParamEncodingJSON(params[p], ef, sch, pathValue, operation, renderedSchema)) break skipValues } encodedObj[params[p].Name] = encodedParams @@ -195,7 +207,7 @@ doneLooking: // only check if items is a schema, not a boolean if sch.Items != nil && sch.Items.IsA() { validationErrors = append(validationErrors, - ValidateQueryArray(sch, params[p], ef, contentWrapped, v.options)...) + ValidateQueryArray(sch, params[p], ef, contentWrapped, v.options, pathValue, operation, renderedSchema)...) } } } @@ -225,7 +237,23 @@ doneLooking: } // if there is no match, check if the param is required or not. if params[p].Required != nil && *params[p].Required { - validationErrors = append(validationErrors, errors.QueryParameterMissing(params[p])) + // Render schema for missing parameter + var sch *base.Schema + if params[p].Schema != nil { + sch = params[p].Schema.Schema() + } else { + for pair := orderedmap.First(params[p].Content); pair != nil; pair = pair.Next() { + sch = pair.Value().Schema.Schema() + break + } + } + var renderedSchema string + if sch != nil { + rendered, _ := sch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedSchema = string(schemaBytes) + } + validationErrors = append(validationErrors, errors.QueryParameterMissing(params[p], pathValue, operation, renderedSchema)) } } } @@ -239,7 +267,7 @@ doneLooking: return true, nil } -func (v *paramValidator) validateSimpleParam(sch *base.Schema, rawParam string, parsedParam any, parameter *v3.Parameter) (validationErrors []*errors.ValidationError) { +func (v *paramValidator) validateSimpleParam(sch *base.Schema, rawParam string, parsedParam any, parameter *v3.Parameter, pathTemplate string, operation string, renderedSchema string) (validationErrors []*errors.ValidationError) { // check if the param is within an enum if sch.Enum != nil { matchFound := false @@ -250,11 +278,11 @@ func (v *paramValidator) validateSimpleParam(sch *base.Schema, rawParam string, } } if !matchFound { - return []*errors.ValidationError{errors.IncorrectQueryParamEnum(parameter, rawParam, sch)} + return []*errors.ValidationError{errors.IncorrectQueryParamEnum(parameter, rawParam, sch, pathTemplate, operation, renderedSchema)} } } - return ValidateSingleParameterSchema( + return ValidateSingleParameterSchemaWithPath( sch, parsedParam, "Query parameter", @@ -263,5 +291,7 @@ func (v *paramValidator) validateSimpleParam(sch *base.Schema, rawParam string, helpers.ParameterValidation, helpers.ParameterValidationQuery, v.options, + pathTemplate, + operation, ) } diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 2885369..df5ae8b 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -33,6 +33,21 @@ func ValidateSingleParameterSchema( validationType string, subValType string, o *config.ValidationOptions, +) (validationErrors []*errors.ValidationError) { + return ValidateSingleParameterSchemaWithPath(schema, rawObject, entity, reasonEntity, name, validationType, subValType, o, "", "") +} + +func ValidateSingleParameterSchemaWithPath( + schema *base.Schema, + rawObject any, + entity string, + reasonEntity string, + name string, + validationType string, + subValType string, + o *config.ValidationOptions, + pathTemplate string, + operation string, ) (validationErrors []*errors.ValidationError) { // Get the JSON Schema for the parameter definition. jsonSchema, err := buildJsonRender(schema) @@ -50,7 +65,7 @@ func ValidateSingleParameterSchema( scErrs := jsch.Validate(rawObject) var werras *jsonschema.ValidationError if stdError.As(scErrs, &werras) { - validationErrors = formatJsonSchemaValidationError(schema, werras, entity, reasonEntity, name, validationType, subValType) + validationErrors = formatJsonSchemaValidationError(schema, werras, entity, reasonEntity, name, validationType, subValType, pathTemplate, operation) } return validationErrors } @@ -183,7 +198,7 @@ func ValidateParameterSchema( } var werras *jsonschema.ValidationError if stdError.As(scErrs, &werras) { - validationErrors = formatJsonSchemaValidationError(schema, werras, entity, reasonEntity, name, validationType, subValType) + validationErrors = formatJsonSchemaValidationError(schema, werras, entity, reasonEntity, name, validationType, subValType, "", "") } // if there are no validationErrors, check that the supplied value is even JSON @@ -207,7 +222,7 @@ func ValidateParameterSchema( return validationErrors } -func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.ValidationError, entity string, reasonEntity string, name string, validationType string, subValType string) (validationErrors []*errors.ValidationError) { +func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.ValidationError, entity string, reasonEntity string, name string, validationType string, subValType string, pathTemplate string, operation string) (validationErrors []*errors.ValidationError) { // flatten the validationErrors schFlatErrs := scErrs.BasicOutput().Errors var schemaValidationErrors []*errors.SchemaValidationFailure @@ -219,19 +234,33 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val continue // ignore this error, it's not useful } + // Construct full OpenAPI path for KeywordLocation if pathTemplate and operation are provided + keywordLocation := er.KeywordLocation + if pathTemplate != "" && operation != "" && validationType == helpers.ParameterValidation { + // Build full OpenAPI path: /paths/{escapedPath}/{operation}/parameters/{paramName}/schema{relativeKeywordLocation} + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading ~1 + + // er.KeywordLocation is relative to the schema (e.g., "/minLength" or "/enum") + // Prepend the full OpenAPI path + keywordLocation = fmt.Sprintf("/paths/%s/%s/parameters/%s/schema%s", escapedPath, strings.ToLower(operation), name, er.KeywordLocation) + } + fail := &errors.SchemaValidationFailure{ Reason: errMsg, Location: er.KeywordLocation, // DEPRECATED FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, + KeywordLocation: keywordLocation, OriginalJsonSchemaError: scErrs, } if schema != nil { rendered, err := schema.RenderInline() if err == nil && rendered != nil { - fail.ReferenceSchema = string(rendered) + renderedBytes, _ := json.Marshal(rendered) + fail.ReferenceSchema = string(renderedBytes) } } schemaValidationErrors = append(schemaValidationErrors, fail) diff --git a/parameters/validation_functions.go b/parameters/validation_functions.go index f1080a5..84eeced 100644 --- a/parameters/validation_functions.go +++ b/parameters/validation_functions.go @@ -4,6 +4,7 @@ package parameters import ( + "encoding/json" "fmt" "slices" "strconv" @@ -99,11 +100,18 @@ func ValidateHeaderArray( // ValidateQueryArray will validate a query parameter that is an array func ValidateQueryArray( - sch *base.Schema, param *v3.Parameter, ef string, contentWrapped bool, validationOptions *config.ValidationOptions, + sch *base.Schema, param *v3.Parameter, ef string, contentWrapped bool, validationOptions *config.ValidationOptions, pathTemplate string, operation string, renderedSchema string, ) []*errors.ValidationError { var validationErrors []*errors.ValidationError itemsSchema := sch.Items.A.Schema() + var renderedItemsSchema string + if itemsSchema != nil { + rendered, _ := itemsSchema.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedItemsSchema = string(schemaBytes) + } + // check for an exploded bit on the schema. // if it's exploded, then we need to check each item in the array // if it's not exploded, then we need to check the whole array as a string @@ -141,7 +149,7 @@ func ValidateQueryArray( } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectQueryParamEnumArray(param, item, sch)) + errors.IncorrectQueryParamEnumArray(param, item, sch, pathTemplate, operation, renderedItemsSchema)) } } } @@ -165,7 +173,7 @@ func ValidateQueryArray( case helpers.Integer: if _, err := strconv.ParseInt(item, 10, 64); err != nil { validationErrors = append(validationErrors, - errors.IncorrectQueryParamArrayInteger(param, item, sch, itemsSchema)) + errors.IncorrectQueryParamArrayInteger(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) break } // will it blend? @@ -173,7 +181,7 @@ func ValidateQueryArray( case helpers.Number: if _, err := strconv.ParseFloat(item, 64); err != nil { validationErrors = append(validationErrors, - errors.IncorrectQueryParamArrayNumber(param, item, sch, itemsSchema)) + errors.IncorrectQueryParamArrayNumber(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) break } // will it blend? @@ -182,7 +190,7 @@ func ValidateQueryArray( case helpers.Boolean: if _, err := strconv.ParseBool(item); err != nil { validationErrors = append(validationErrors, - errors.IncorrectQueryParamArrayBoolean(param, item, sch, itemsSchema)) + errors.IncorrectQueryParamArrayBoolean(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) } case helpers.Object: validationErrors = append(validationErrors, @@ -207,14 +215,14 @@ func ValidateQueryArray( if sch.MaxItems != nil { if len(items) > int(*sch.MaxItems) { validationErrors = append(validationErrors, - errors.IncorrectParamArrayMaxNumItems(param, sch, *sch.MaxItems, int64(len(items)))) + errors.IncorrectParamArrayMaxNumItems(param, sch, *sch.MaxItems, int64(len(items)), pathTemplate, operation, renderedSchema)) } } if sch.MinItems != nil { if len(items) < int(*sch.MinItems) { validationErrors = append(validationErrors, - errors.IncorrectParamArrayMinNumItems(param, sch, *sch.MinItems, int64(len(items)))) + errors.IncorrectParamArrayMinNumItems(param, sch, *sch.MinItems, int64(len(items)), pathTemplate, operation, renderedSchema)) } } @@ -222,7 +230,7 @@ func ValidateQueryArray( if sch.UniqueItems != nil { if *sch.UniqueItems && !uniqueItems { validationErrors = append(validationErrors, - errors.IncorrectParamArrayUniqueItems(param, sch, strings.Join(duplicates, ", "))) + errors.IncorrectParamArrayUniqueItems(param, sch, strings.Join(duplicates, ", "), pathTemplate, operation, renderedSchema)) } } return validationErrors From b93b2913fc8e214c86861fd2acf54b273191f0e9 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 09:15:07 -0800 Subject: [PATCH 14/25] feat: add SchemaValidationFailure context to header parameter errors Adds full OpenAPI context to all 7 header parameter error functions: - HeaderParameterMissing - HeaderParameterCannotBeDecoded (now includes SchemaValidationFailure) - IncorrectHeaderParamEnum - InvalidHeaderParamInteger - InvalidHeaderParamNumber - IncorrectHeaderParamBool - IncorrectHeaderParamArrayBoolean - IncorrectHeaderParamArrayNumber All errors now include: - KeywordLocation: Full JSON Pointer from OpenAPI root (e.g., /paths/{path}/{method}/parameters/{name}/schema/type) - ReferenceSchema: Rendered schema as JSON string - Context: Raw base.Schema object - Proper FieldName and InstancePath Updated ValidateHeaderArray to accept and pass path/operation/schema context. Updated all test cases to pass new required parameters. --- errors/parameter_errors.go | 199 +++++++++++++++++++++++++++-- errors/parameter_errors_test.go | 28 ++-- parameters/header_parameters.go | 39 ++++-- parameters/validation_functions.go | 32 +++-- 4 files changed, 252 insertions(+), 46 deletions(-) diff --git a/errors/parameter_errors.go b/errors/parameter_errors.go index 44c6c7e..9bbcee8 100644 --- a/errors/parameter_errors.go +++ b/errors/parameter_errors.go @@ -102,7 +102,12 @@ func QueryParameterMissing(param *v3.Parameter, pathTemplate string, operation s } } -func HeaderParameterMissing(param *v3.Parameter) *ValidationError { +func HeaderParameterMissing(param *v3.Parameter, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/required", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -112,10 +117,23 @@ func HeaderParameterMissing(param *v3.Parameter) *ValidationError { SpecLine: param.GoLow().Required.KeyNode.Line, SpecCol: param.GoLow().Required.KeyNode.Column, HowToFix: HowToFixMissingValue, + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Required header parameter '%s' is missing", param.Name), + FieldName: param.Name, + FieldPath: "", + InstancePath: []string{}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func HeaderParameterCannotBeDecoded(param *v3.Parameter, val string) *ValidationError { +func HeaderParameterCannotBeDecoded(param *v3.Parameter, val string, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -125,15 +143,28 @@ func HeaderParameterCannotBeDecoded(param *v3.Parameter, val string) *Validation SpecLine: param.GoLow().Schema.Value.Schema().Type.KeyNode.Line, SpecCol: param.GoLow().Schema.Value.Schema().Type.KeyNode.Line, HowToFix: HowToFixInvalidEncoding, + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Header value '%s' cannot be decoded as object (malformed encoding)", val), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectHeaderParamEnum(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectHeaderParamEnum(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { var enums []string for i := range sch.Enum { enums = append(enums, fmt.Sprint(sch.Enum[i].Value)) } validEnums := strings.Join(enums, ", ") + + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/enum", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -144,6 +175,13 @@ func IncorrectHeaderParamEnum(param *v3.Parameter, ef string, sch *base.Schema) SpecCol: param.GoLow().Schema.Value.Schema().Enum.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidEnum, ef, validEnums), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' does not match any enum values: [%s]", ef, validEnums), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } @@ -253,8 +291,13 @@ func IncorrectParamArrayUniqueItems(param *v3.Parameter, sch *base.Schema, dupli } func IncorrectCookieParamArrayBoolean( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationCookie, @@ -265,6 +308,13 @@ func IncorrectCookieParamArrayBoolean( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid boolean", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } @@ -325,8 +375,13 @@ func IncorrectQueryParamArrayNumber( } func IncorrectCookieParamArrayNumber( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationCookie, @@ -337,6 +392,13 @@ func IncorrectCookieParamArrayNumber( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid number", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } @@ -540,7 +602,12 @@ func IncorrectReservedValues(param *v3.Parameter, ef string, sch *base.Schema, p } } -func InvalidHeaderParamInteger(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func InvalidHeaderParamInteger(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -552,10 +619,22 @@ func InvalidHeaderParamInteger(param *v3.Parameter, ef string, sch *base.Schema) ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidInteger, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid integer", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func InvalidHeaderParamNumber(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func InvalidHeaderParamNumber(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -567,10 +646,22 @@ func InvalidHeaderParamNumber(param *v3.Parameter, ef string, sch *base.Schema) ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid number", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func InvalidCookieParamInteger(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func InvalidCookieParamInteger(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationCookie, @@ -581,10 +672,22 @@ func InvalidCookieParamInteger(param *v3.Parameter, ef string, sch *base.Schema) SpecCol: param.GoLow().Schema.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidInteger, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid integer", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func InvalidCookieParamNumber(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func InvalidCookieParamNumber(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationCookie, @@ -595,10 +698,22 @@ func InvalidCookieParamNumber(param *v3.Parameter, ef string, sch *base.Schema) SpecCol: param.GoLow().Schema.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid number", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectHeaderParamBool(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectHeaderParamBool(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -610,10 +725,22 @@ func IncorrectHeaderParamBool(param *v3.Parameter, ef string, sch *base.Schema) ParameterName: param.Name, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid boolean", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectCookieParamBool(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectCookieParamBool(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationCookie, @@ -624,15 +751,28 @@ func IncorrectCookieParamBool(param *v3.Parameter, ef string, sch *base.Schema) SpecCol: param.GoLow().Schema.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, ef), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' is not a valid boolean", ef), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } -func IncorrectCookieParamEnum(param *v3.Parameter, ef string, sch *base.Schema) *ValidationError { +func IncorrectCookieParamEnum(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { var enums []string for i := range sch.Enum { enums = append(enums, fmt.Sprint(sch.Enum[i].Value)) } validEnums := strings.Join(enums, ", ") + + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/enum", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationCookie, @@ -643,12 +783,24 @@ func IncorrectCookieParamEnum(param *v3.Parameter, ef string, sch *base.Schema) SpecCol: param.GoLow().Schema.Value.Schema().Enum.KeyNode.Column, Context: sch, HowToFix: fmt.Sprintf(HowToFixParamInvalidEnum, ef, validEnums), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Value '%s' does not match any enum values: [%s]", ef, validEnums), + FieldName: param.Name, + InstancePath: []string{param.Name}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedSchema, + }}, } } func IncorrectHeaderParamArrayBoolean( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -659,12 +811,24 @@ func IncorrectHeaderParamArrayBoolean( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidBoolean, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid boolean", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } func IncorrectHeaderParamArrayNumber( - param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, + param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { + escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") + escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") + escapedPath = strings.TrimPrefix(escapedPath, "~1") + keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + return &ValidationError{ ValidationType: helpers.ParameterValidation, ValidationSubType: helpers.ParameterValidationHeader, @@ -675,6 +839,13 @@ func IncorrectHeaderParamArrayNumber( SpecCol: sch.Items.A.GoLow().Schema().Type.KeyNode.Column, Context: itemsSchema, HowToFix: fmt.Sprintf(HowToFixParamInvalidNumber, item), + SchemaValidationErrors: []*SchemaValidationFailure{{ + Reason: fmt.Sprintf("Array item '%s' is not a valid number", item), + FieldName: param.Name, + InstancePath: []string{param.Name, "[item]"}, + KeywordLocation: keywordLocation, + ReferenceSchema: renderedItemsSchema, + }}, } } diff --git a/errors/parameter_errors_test.go b/errors/parameter_errors_test.go index afc953a..4fb5b9b 100644 --- a/errors/parameter_errors_test.go +++ b/errors/parameter_errors_test.go @@ -134,7 +134,7 @@ func TestHeaderParameterMissing(t *testing.T) { param := createMockParameterWithSchema() // Call the function - err := HeaderParameterMissing(param) + err := HeaderParameterMissing(param, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -150,7 +150,7 @@ func TestHeaderParameterCannotBeDecoded(t *testing.T) { val := "malformed_header_value" // Call the function - err := HeaderParameterCannotBeDecoded(param, val) + err := HeaderParameterCannotBeDecoded(param, val, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -187,7 +187,7 @@ func TestIncorrectHeaderParamEnum(t *testing.T) { schema := base.NewSchema(s) // Call the function with an invalid enum value - err := IncorrectHeaderParamEnum(param, "invalidEnum", schema) + err := IncorrectHeaderParamEnum(param, "invalidEnum", schema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -314,7 +314,7 @@ func TestIncorrectCookieParamArrayBoolean(t *testing.T) { itemsSchema := base.NewSchema(baseSchema.Items.Value.A.Schema()) // Call the function with an invalid boolean value in the array - err := IncorrectCookieParamArrayBoolean(param, "notBoolean", s, itemsSchema) + err := IncorrectCookieParamArrayBoolean(param, "notBoolean", s, itemsSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -455,7 +455,7 @@ func TestIncorrectCookieParamArrayNumber(t *testing.T) { itemsSchema := base.NewSchema(baseSchema.Items.Value.A.Schema()) // Call the function with an invalid number value in the cookie array - err := IncorrectCookieParamArrayNumber(param, "notNumber", s, itemsSchema) + err := IncorrectCookieParamArrayNumber(param, "notNumber", s, itemsSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -692,7 +692,7 @@ func TestInvalidHeaderParamInteger(t *testing.T) { param := createMockParameter() param.Name = "bunny" - err := InvalidHeaderParamInteger(param, "bunmy", highSchema) + err := InvalidHeaderParamInteger(param, "bunmy", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -715,7 +715,7 @@ func TestInvalidHeaderParamNumber(t *testing.T) { param := createMockParameter() param.Name = "bunny" - err := InvalidHeaderParamNumber(param, "bunmy", highSchema) + err := InvalidHeaderParamNumber(param, "bunmy", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -738,7 +738,7 @@ func TestInvalidCookieParamNumber(t *testing.T) { param := createMockParameter() param.Name = "cookies" - err := InvalidCookieParamNumber(param, "milky", highSchema) + err := InvalidCookieParamNumber(param, "milky", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -761,7 +761,7 @@ func TestInvalidCookieParamInteger(t *testing.T) { param := createMockParameter() param.Name = "cookies" - err := InvalidCookieParamInteger(param, "milky", highSchema) + err := InvalidCookieParamInteger(param, "milky", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -784,7 +784,7 @@ func TestIncorrectHeaderParamBool(t *testing.T) { param := createMockParameter() param.Name = "cookies" - err := IncorrectHeaderParamBool(param, "milky", highSchema) + err := IncorrectHeaderParamBool(param, "milky", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -807,7 +807,7 @@ func TestIncorrectCookieParamBool(t *testing.T) { param := createMockParameter() param.Name = "cookies" - err := IncorrectCookieParamBool(param, "milky", highSchema) + err := IncorrectCookieParamBool(param, "milky", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -837,7 +837,7 @@ items: } param.GoLow().Schema.Value.Schema().Enum.KeyNode = &yaml.Node{} - err := IncorrectCookieParamEnum(param, "milky", highSchema) + err := IncorrectCookieParamEnum(param, "milky", highSchema, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -863,7 +863,7 @@ func TestIncorrectHeaderParamArrayBoolean(t *testing.T) { param := createMockParameter() param.Name = "bubbles" - err := IncorrectHeaderParamArrayBoolean(param, "milky", highSchema, nil) + err := IncorrectHeaderParamArrayBoolean(param, "milky", highSchema, nil, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) @@ -889,7 +889,7 @@ func TestIncorrectHeaderParamArrayNumber(t *testing.T) { param := createMockParameter() param.Name = "bubbles" - err := IncorrectHeaderParamArrayNumber(param, "milky", highSchema, nil) + err := IncorrectHeaderParamArrayNumber(param, "milky", highSchema, nil, "/test-path", "get", "{}") // Validate the error require.NotNil(t, err) diff --git a/parameters/header_parameters.go b/parameters/header_parameters.go index a4c56a1..e370bb2 100644 --- a/parameters/header_parameters.go +++ b/parameters/header_parameters.go @@ -4,6 +4,7 @@ package parameters import ( + "encoding/json" "fmt" "net/http" "strconv" @@ -47,6 +48,7 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, var validationErrors []*errors.ValidationError seenHeaders := make(map[string]bool) + operation := strings.ToLower(request.Method) for _, p := range params { if p.In == helpers.Header { @@ -57,6 +59,15 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, if p.Schema != nil { sch = p.Schema.Schema() } + + // Render schema once for ReferenceSchema field in errors + var renderedSchema string + if sch != nil { + rendered, _ := sch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedSchema = string(schemaBytes) + } + pType := sch.Type for _, ty := range pType { @@ -64,7 +75,7 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, case helpers.Integer: if _, err := strconv.ParseInt(param, 10, 64); err != nil { validationErrors = append(validationErrors, - errors.InvalidHeaderParamInteger(p, strings.ToLower(param), sch)) + errors.InvalidHeaderParamInteger(p, strings.ToLower(param), sch, pathValue, operation, renderedSchema)) break } // check if the param is within the enum @@ -78,14 +89,14 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(param), sch)) + errors.IncorrectHeaderParamEnum(p, strings.ToLower(param), sch, pathValue, operation, renderedSchema)) } } case helpers.Number: if _, err := strconv.ParseFloat(param, 64); err != nil { validationErrors = append(validationErrors, - errors.InvalidHeaderParamNumber(p, strings.ToLower(param), sch)) + errors.InvalidHeaderParamNumber(p, strings.ToLower(param), sch, pathValue, operation, renderedSchema)) break } // check if the param is within the enum @@ -99,14 +110,14 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(param), sch)) + errors.IncorrectHeaderParamEnum(p, strings.ToLower(param), sch, pathValue, operation, renderedSchema)) } } case helpers.Boolean: if _, err := strconv.ParseBool(param); err != nil { validationErrors = append(validationErrors, - errors.IncorrectHeaderParamBool(p, strings.ToLower(param), sch)) + errors.IncorrectHeaderParamBool(p, strings.ToLower(param), sch, pathValue, operation, renderedSchema)) } case helpers.Object: @@ -124,7 +135,7 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, if len(encodedObj) == 0 { validationErrors = append(validationErrors, - errors.HeaderParameterCannotBeDecoded(p, strings.ToLower(param))) + errors.HeaderParameterCannotBeDecoded(p, strings.ToLower(param), pathValue, operation, renderedSchema)) break } @@ -145,7 +156,7 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, if !p.IsExploded() { // only unexploded arrays are supported for cookie params if sch.Items.IsA() { validationErrors = append(validationErrors, - ValidateHeaderArray(sch, p, param)...) + ValidateHeaderArray(sch, p, param, pathValue, operation, renderedSchema)...) } } @@ -163,7 +174,7 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectHeaderParamEnum(p, strings.ToLower(param), sch)) + errors.IncorrectHeaderParamEnum(p, strings.ToLower(param), sch, pathValue, operation, renderedSchema)) } } } @@ -177,7 +188,17 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, } } else { if p.Required != nil && *p.Required { - validationErrors = append(validationErrors, errors.HeaderParameterMissing(p)) + // Render schema for missing required parameter + var renderedSchema string + if p.Schema != nil { + sch := p.Schema.Schema() + if sch != nil { + rendered, _ := sch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedSchema = string(schemaBytes) + } + } + validationErrors = append(validationErrors, errors.HeaderParameterMissing(p, pathValue, operation, renderedSchema)) } } } diff --git a/parameters/validation_functions.go b/parameters/validation_functions.go index 84eeced..f931588 100644 --- a/parameters/validation_functions.go +++ b/parameters/validation_functions.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/pb33f/libopenapi/datamodel/high/base" - "github.com/pb33f/libopenapi/datamodel/high/v3" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/errors" @@ -20,11 +20,18 @@ import ( // ValidateCookieArray will validate a cookie parameter that is an array func ValidateCookieArray( - sch *base.Schema, param *v3.Parameter, value string, + sch *base.Schema, param *v3.Parameter, value string, pathTemplate string, operation string, renderedSchema string, ) []*errors.ValidationError { var validationErrors []*errors.ValidationError itemsSchema := sch.Items.A.Schema() + var renderedItemsSchema string + if itemsSchema != nil { + rendered, _ := itemsSchema.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedItemsSchema = string(schemaBytes) + } + // header arrays can only be encoded as CSV items := helpers.ExplodeQueryValue(value, helpers.DefaultDelimited) @@ -36,18 +43,18 @@ func ValidateCookieArray( case helpers.Integer, helpers.Number: if _, err := strconv.ParseFloat(item, 64); err != nil { validationErrors = append(validationErrors, - errors.IncorrectCookieParamArrayNumber(param, item, sch, itemsSchema)) + errors.IncorrectCookieParamArrayNumber(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) } case helpers.Boolean: if _, err := strconv.ParseBool(item); err != nil { validationErrors = append(validationErrors, - errors.IncorrectCookieParamArrayBoolean(param, item, sch, itemsSchema)) + errors.IncorrectCookieParamArrayBoolean(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) break } // check for edge-cases "0" and "1" which can also be parsed into valid booleans if item == "0" || item == "1" { validationErrors = append(validationErrors, - errors.IncorrectCookieParamArrayBoolean(param, item, sch, itemsSchema)) + errors.IncorrectCookieParamArrayBoolean(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) } case helpers.String: // do nothing for now. @@ -60,11 +67,18 @@ func ValidateCookieArray( // ValidateHeaderArray will validate a header parameter that is an array func ValidateHeaderArray( - sch *base.Schema, param *v3.Parameter, value string, + sch *base.Schema, param *v3.Parameter, value string, pathTemplate string, operation string, renderedSchema string, ) []*errors.ValidationError { var validationErrors []*errors.ValidationError itemsSchema := sch.Items.A.Schema() + var renderedItemsSchema string + if itemsSchema != nil { + rendered, _ := itemsSchema.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedItemsSchema = string(schemaBytes) + } + // header arrays can only be encoded as CSV items := helpers.ExplodeQueryValue(value, helpers.DefaultDelimited) @@ -76,18 +90,18 @@ func ValidateHeaderArray( case helpers.Integer, helpers.Number: if _, err := strconv.ParseFloat(item, 64); err != nil { validationErrors = append(validationErrors, - errors.IncorrectHeaderParamArrayNumber(param, item, sch, itemsSchema)) + errors.IncorrectHeaderParamArrayNumber(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) } case helpers.Boolean: if _, err := strconv.ParseBool(item); err != nil { validationErrors = append(validationErrors, - errors.IncorrectHeaderParamArrayBoolean(param, item, sch, itemsSchema)) + errors.IncorrectHeaderParamArrayBoolean(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) break } // check for edge-cases "0" and "1" which can also be parsed into valid booleans if item == "0" || item == "1" { validationErrors = append(validationErrors, - errors.IncorrectHeaderParamArrayBoolean(param, item, sch, itemsSchema)) + errors.IncorrectHeaderParamArrayBoolean(param, item, sch, itemsSchema, pathTemplate, operation, renderedItemsSchema)) } case helpers.String: // do nothing for now. From 0b989f3cc8d55a67403aa2e5c953a3fc2f9675a1 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 09:15:15 -0800 Subject: [PATCH 15/25] feat: add SchemaValidationFailure context to cookie parameter errors Adds full OpenAPI context to all 6 cookie parameter error functions: - InvalidCookieParamInteger - InvalidCookieParamNumber - IncorrectCookieParamBool - IncorrectCookieParamEnum - IncorrectCookieParamArrayBoolean - IncorrectCookieParamArrayNumber All errors now include: - KeywordLocation: Full JSON Pointer from OpenAPI root (e.g., /paths/{path}/{method}/parameters/{name}/schema/type) - ReferenceSchema: Rendered schema as JSON string - Context: Raw base.Schema object - Proper FieldName and InstancePath Updated ValidateCookieArray to accept and pass path/operation/schema context. Updated all test cases to pass new required parameters. This completes consistent SchemaValidationFailure population across all parameter types (path, query, header, cookie). --- parameters/cookie_parameters.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/parameters/cookie_parameters.go b/parameters/cookie_parameters.go index 8f74b9f..cd85f44 100644 --- a/parameters/cookie_parameters.go +++ b/parameters/cookie_parameters.go @@ -4,6 +4,7 @@ package parameters import ( + "encoding/json" "fmt" "net/http" "strconv" @@ -43,6 +44,7 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, // extract params for the operation params := helpers.ExtractParamsForOperation(request, pathItem) var validationErrors []*errors.ValidationError + operation := strings.ToLower(request.Method) for _, p := range params { if p.In == helpers.Cookie { for _, cookie := range request.Cookies() { @@ -52,6 +54,15 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, if p.Schema != nil { sch = p.Schema.Schema() } + + // Render schema once for ReferenceSchema field in errors + var renderedSchema string + if sch != nil { + rendered, _ := sch.RenderInline() + schemaBytes, _ := json.Marshal(rendered) + renderedSchema = string(schemaBytes) + } + pType := sch.Type for _, ty := range pType { @@ -59,7 +70,7 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, case helpers.Integer: if _, err := strconv.ParseInt(cookie.Value, 10, 64); err != nil { validationErrors = append(validationErrors, - errors.InvalidCookieParamInteger(p, strings.ToLower(cookie.Value), sch)) + errors.InvalidCookieParamInteger(p, strings.ToLower(cookie.Value), sch, pathValue, operation, renderedSchema)) break } // check if enum is in range @@ -73,13 +84,13 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) + errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch, pathValue, operation, renderedSchema)) } } case helpers.Number: if _, err := strconv.ParseFloat(cookie.Value, 64); err != nil { validationErrors = append(validationErrors, - errors.InvalidCookieParamNumber(p, strings.ToLower(cookie.Value), sch)) + errors.InvalidCookieParamNumber(p, strings.ToLower(cookie.Value), sch, pathValue, operation, renderedSchema)) break } // check if enum is in range @@ -93,13 +104,13 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) + errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch, pathValue, operation, renderedSchema)) } } case helpers.Boolean: if _, err := strconv.ParseBool(cookie.Value); err != nil { validationErrors = append(validationErrors, - errors.IncorrectCookieParamBool(p, strings.ToLower(cookie.Value), sch)) + errors.IncorrectCookieParamBool(p, strings.ToLower(cookie.Value), sch, pathValue, operation, renderedSchema)) } case helpers.Object: if !p.IsExploded() { @@ -125,7 +136,7 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, // only check if items is a schema, not a boolean if sch.Items.IsA() { validationErrors = append(validationErrors, - ValidateCookieArray(sch, p, cookie.Value)...) + ValidateCookieArray(sch, p, cookie.Value, pathValue, operation, renderedSchema)...) } } @@ -143,7 +154,7 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, } if !matchFound { validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) + errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch, pathValue, operation, renderedSchema)) } } } From aa3b06bffe36fc84439eb0bdb1e678620961339d Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 09:19:51 -0800 Subject: [PATCH 16/25] refactor: remove deprecated Location field from SchemaValidationFailure Removed the deprecated Location field entirely from SchemaValidationFailure struct and updated all code and tests to use KeywordLocation instead. Changes: - Removed Location field from SchemaValidationFailure struct - Updated Error() method to use FieldPath instead of Location - Removed .Location assignment in schema_validation/validate_document.go - Updated all test assertions to use KeywordLocation instead of Location - Updated tests to reflect that schema compilation errors do not have SchemaValidationFailure objects (they were removed in earlier commits) This completes the transition to the new error reporting model where: - KeywordLocation: Full JSON Pointer to schema keyword (for all schema violations) - FieldPath: JSONPath to the failing instance (for body validation) - InstancePath: Structured path segments to failing instance - Location field: Removed entirely --- errors/validation_error.go | 9 +++-- parameters/query_parameters_test.go | 2 +- parameters/validate_parameter_test.go | 37 ++++++++++----------- requests/validate_request.go | 9 +++-- responses/validate_body_test.go | 17 ++++------ responses/validate_response.go | 31 +++++++++-------- schema_validation/validate_document.go | 5 ++- schema_validation/validate_document_test.go | 8 ++--- validator_test.go | 2 +- 9 files changed, 53 insertions(+), 67 deletions(-) diff --git a/errors/validation_error.go b/errors/validation_error.go index 020f8bd..685232b 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -50,15 +50,14 @@ type SchemaValidationFailure struct { // The original jsonschema.ValidationError object, if the schema failure originated from the jsonschema library. OriginalJsonSchemaError *jsonschema.ValidationError `json:"-" yaml:"-"` - - // DEPRECATED in favor of explicit use of FieldPath & InstancePath - // Location is the XPath-like location of the validation failure - Location string `json:"location,omitempty" yaml:"location,omitempty"` } // Error returns a string representation of the error func (s *SchemaValidationFailure) Error() string { - return fmt.Sprintf("Reason: %s, Location: %s", s.Reason, s.Location) + if s.FieldPath != "" { + return fmt.Sprintf("Reason: %s, FieldPath: %s", s.Reason, s.FieldPath) + } + return fmt.Sprintf("Reason: %s", s.Reason) } // ValidationError is a struct that contains all the information about a validation error. diff --git a/parameters/query_parameters_test.go b/parameters/query_parameters_test.go index 137969c..8165995 100644 --- a/parameters/query_parameters_test.go +++ b/parameters/query_parameters_test.go @@ -1729,7 +1729,7 @@ paths: assert.Equal(t, "The query parameter (which is an array) 'fishy' is defined as an object, "+ "however it failed to pass a schema validation", errors[0].Reason) assert.Equal(t, "missing properties 'vinegar', 'chips'", errors[0].SchemaValidationErrors[0].Reason) - assert.Equal(t, "/required", errors[0].SchemaValidationErrors[0].Location) + assert.Equal(t, "/required", errors[0].SchemaValidationErrors[0].KeywordLocation) } func TestNewValidator_QueryParamValidTypeObjectPropType_Invalid(t *testing.T) { diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index e076b88..3ed2a83 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -2,6 +2,7 @@ package parameters import ( "net/http" + "strings" "sync" "testing" @@ -264,7 +265,7 @@ func TestUnifiedErrorFormatWithFormatValidation(t *testing.T) { // verify unified error format - SchemaValidationErrors should be populated assert.Len(t, valErrs[0].SchemaValidationErrors, 1) assert.Contains(t, valErrs[0].SchemaValidationErrors[0].Reason, "is not valid email") - assert.Equal(t, "/format", valErrs[0].SchemaValidationErrors[0].Location) + assert.Equal(t, "/format", valErrs[0].SchemaValidationErrors[0].KeywordLocation) assert.NotEmpty(t, valErrs[0].SchemaValidationErrors[0].ReferenceSchema) } @@ -467,16 +468,13 @@ func TestComplexRegexSchemaCompilationError(t *testing.T) { found := false for _, err := range valErrs { if err.ParameterName == "complexParam" && - err.SchemaValidationErrors != nil && - len(err.SchemaValidationErrors) > 0 { - for _, schemaErr := range err.SchemaValidationErrors { - if schemaErr.Location == "schema compilation" && - schemaErr.Reason != "" { - found = true - assert.Contains(t, schemaErr.Reason, "failed to compile JSON schema") - assert.Contains(t, err.HowToFix, "complex regex patterns") - break - } + (err.SchemaValidationErrors == nil || len(err.SchemaValidationErrors) == 0) { + // Schema compilation errors don't have SchemaValidationFailure objects + if strings.Contains(err.Reason, "failed to compile JSON schema") { + found = true + assert.Contains(t, err.Reason, "failed to compile JSON schema") + assert.Contains(t, err.HowToFix, "complex regex patterns") + break } } } @@ -555,15 +553,14 @@ func TestValidateParameterSchema_SchemaCompilationFailure(t *testing.T) { for _, validationError := range validationErrors { if validationError.ParameterName == "failParam" && validationError.ValidationSubType == helpers.ParameterValidationQuery && - validationError.SchemaValidationErrors != nil { - for _, schemaErr := range validationError.SchemaValidationErrors { - if schemaErr.Location == "schema compilation" { - assert.Contains(t, schemaErr.Reason, "failed to compile JSON schema") - assert.Contains(t, validationError.HowToFix, "complex regex patterns") - assert.Equal(t, "Query parameter 'failParam' failed schema compilation", validationError.Message) - found = true - break - } + (validationError.SchemaValidationErrors == nil || len(validationError.SchemaValidationErrors) == 0) { + // Schema compilation errors don't have SchemaValidationFailure objects + if strings.Contains(validationError.Reason, "failed to compile JSON schema") { + assert.Contains(t, validationError.Reason, "failed to compile JSON schema") + assert.Contains(t, validationError.HowToFix, "complex regex patterns") + assert.Equal(t, "Query parameter 'failParam' failed schema compilation", validationError.Message) + found = true + break } } } diff --git a/requests/validate_request.go b/requests/validate_request.go index cb89624..e2e38d2 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -96,7 +96,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V SpecLine: 1, SpecCol: 0, HowToFix: "check the request schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: referenceSchema, + Context: input.Schema, }) return false, validationErrors } @@ -141,7 +141,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V SpecLine: 1, SpecCol: 0, HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) return false, validationErrors } @@ -167,7 +167,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V SpecLine: line, SpecCol: col, HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) return false, validationErrors } @@ -218,7 +218,6 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V violation := &errors.SchemaValidationFailure{ Reason: errMsg, - Location: er.InstanceLocation, // DEPRECATED FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), @@ -266,7 +265,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V SpecCol: col, SchemaValidationErrors: schemaValidationErrors, HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) } if len(validationErrors) > 0 { diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index 9e7e75e..c6cc842 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1488,16 +1488,13 @@ paths: found := false for _, err := range validationErrors { if err.ValidationSubType == helpers.Schema && - err.SchemaValidationErrors != nil && - len(err.SchemaValidationErrors) > 0 { - for _, schemaErr := range err.SchemaValidationErrors { - if schemaErr.Location == "schema compilation" && - schemaErr.Reason != "" { - found = true - assert.Contains(t, schemaErr.Reason, "failed to compile JSON schema") - assert.Contains(t, err.HowToFix, "complex regex patterns") - break - } + (err.SchemaValidationErrors == nil || len(err.SchemaValidationErrors) == 0) { + // Schema compilation errors don't have SchemaValidationFailure objects + if strings.Contains(err.Reason, "failed to compile JSON schema") { + found = true + assert.Contains(t, err.Reason, "failed to compile JSON schema") + assert.Contains(t, err.HowToFix, "complex regex patterns") + break } } } diff --git a/responses/validate_response.go b/responses/validate_response.go index b6633e6..4345879 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -100,7 +100,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors SpecLine: 1, SpecCol: 0, HowToFix: "check the response schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: referenceSchema, + Context: input.Schema, }) return false, validationErrors } @@ -132,7 +132,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors SpecLine: 1, SpecCol: 0, HowToFix: "ensure response object has been set", - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) return false, validationErrors } @@ -149,7 +149,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors SpecLine: 1, SpecCol: 0, HowToFix: "ensure body is not empty", - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) return false, validationErrors } @@ -173,7 +173,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors SpecLine: 1, SpecCol: 0, HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) return false, validationErrors } @@ -224,17 +224,16 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors referenceObject = string(responseBody) } - violation := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.InstanceLocation, // DEPRECATED - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - ReferenceSchema: referenceSchema, - ReferenceObject: referenceObject, - OriginalJsonSchemaError: jk, - } + violation := &errors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + ReferenceSchema: referenceSchema, + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { @@ -274,7 +273,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors SpecCol: col, SchemaValidationErrors: schemaValidationErrors, HowToFix: errors.HowToFixInvalidSchema, - Context: referenceSchema, // attach the rendered schema to the error + Context: schema, }) } if len(validationErrors) > 0 { diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index 9282bd5..9bdb528 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -75,11 +75,10 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo } if errMsg != "" { - // locate the violated property in the schema - located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) + // locate the violated property in the schema + located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) violation := &liberrors.SchemaValidationFailure{ Reason: errMsg, - Location: er.InstanceLocation, FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), diff --git a/schema_validation/validate_document_test.go b/schema_validation/validate_document_test.go index b6104f6..c18a3cf 100644 --- a/schema_validation/validate_document_test.go +++ b/schema_validation/validate_document_test.go @@ -115,12 +115,8 @@ func TestValidateDocument_SchemaCompilationFailure(t *testing.T) { assert.Equal(t, 1, validationError.SpecLine) assert.Equal(t, 0, validationError.SpecCol) - // Verify schema validation errors - assert.NotEmpty(t, validationError.SchemaValidationErrors) - schemaErr := validationError.SchemaValidationErrors[0] - assert.Equal(t, "schema compilation", schemaErr.Location) - assert.Contains(t, schemaErr.Reason, "failed to compile OpenAPI schema") - assert.Equal(t, malformedSchema, schemaErr.ReferenceSchema) + // Schema compilation errors don't have SchemaValidationFailure objects + assert.Empty(t, validationError.SchemaValidationErrors) } // TestValidateDocument_CompilationFailure tests the actual ValidateOpenAPIDocument function diff --git a/validator_test.go b/validator_test.go index 08b22f4..c2392f3 100644 --- a/validator_test.go +++ b/validator_test.go @@ -313,7 +313,7 @@ paths: assert.Equal(t, "POST request body for '/burgers/createBurger' failed to validate schema", errors[0].Message) require.Len(t, errors[0].SchemaValidationErrors, 1) require.NotNil(t, errors[0].SchemaValidationErrors[0]) - assert.Equal(t, "/properties/name/format", errors[0].SchemaValidationErrors[0].Location) + assert.Equal(t, "/properties/name/format", errors[0].SchemaValidationErrors[0].KeywordLocation) assert.Equal(t, "'big mac' is not valid capital: expected first latter to be uppercase", errors[0].SchemaValidationErrors[0].Reason) } From 5ff89990afca257a88b515813ccf9af0de62fb31 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 10:27:05 -0800 Subject: [PATCH 17/25] refactor: use centralized JSON Pointer helpers across codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces all manual JSON Pointer construction with calls to the new helper functions, eliminating 72+ instances of duplicated escaping logic. Changes: - errors/parameter_errors.go: Replaced all manual escaping with helpers.ConstructParameterJSONPointer() calls - All 35 parameter error functions now use helper - Handles type, enum, items/type, items/enum, maxItems, minItems, uniqueItems - responses/validate_headers.go: Replaced manual escaping with helpers.ConstructResponseHeaderJSONPointer() - errors/validation_error_test.go: Updated tests to use FieldPath instead of deprecated Location field Benefits: - Single source of truth for JSON Pointer construction - Reduced code duplication (3 lines → 1 line per usage) - More maintainable and less error-prone - Semantic function names make intent clearer Each function call reduced from: escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") escapedPath = strings.TrimPrefix(escapedPath, "~1") keywordLocation := fmt.Sprintf("/paths/%s/%s/...", escapedPath, ...) To: keywordLocation := helpers.ConstructParameterJSONPointer(path, method, param, keyword) --- errors/parameter_errors.go | 125 ++++---------------- errors/validation_error_test.go | 14 +-- helpers/json_pointer.go | 1 + parameters/validate_parameter.go | 17 ++- responses/validate_headers.go | 8 +- schema_validation/validate_document_test.go | 1 - schema_validation/validate_schema.go | 21 ++-- 7 files changed, 52 insertions(+), 135 deletions(-) diff --git a/errors/parameter_errors.go b/errors/parameter_errors.go index 9bbcee8..1c298fd 100644 --- a/errors/parameter_errors.go +++ b/errors/parameter_errors.go @@ -77,10 +77,7 @@ func InvalidDeepObject(param *v3.Parameter, qp *helpers.QueryParam) *ValidationE } func QueryParameterMissing(param *v3.Parameter, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/required", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "required") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -129,10 +126,7 @@ func HeaderParameterMissing(param *v3.Parameter, pathTemplate string, operation } func HeaderParameterCannotBeDecoded(param *v3.Parameter, val string, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -160,10 +154,7 @@ func IncorrectHeaderParamEnum(param *v3.Parameter, ef string, sch *base.Schema, } validEnums := strings.Join(enums, ", ") - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/enum", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "enum") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -188,10 +179,7 @@ func IncorrectHeaderParamEnum(param *v3.Parameter, ef string, sch *base.Schema, func IncorrectQueryParamArrayBoolean( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -214,10 +202,7 @@ func IncorrectQueryParamArrayBoolean( } func IncorrectParamArrayMaxNumItems(param *v3.Parameter, sch *base.Schema, expected, actual int64, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/maxItems", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "maxItems") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -240,10 +225,7 @@ func IncorrectParamArrayMaxNumItems(param *v3.Parameter, sch *base.Schema, expec } func IncorrectParamArrayMinNumItems(param *v3.Parameter, sch *base.Schema, expected, actual int64, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/minItems", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "minItems") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -266,10 +248,7 @@ func IncorrectParamArrayMinNumItems(param *v3.Parameter, sch *base.Schema, expec } func IncorrectParamArrayUniqueItems(param *v3.Parameter, sch *base.Schema, duplicates string, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/uniqueItems", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "uniqueItems") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -293,10 +272,7 @@ func IncorrectParamArrayUniqueItems(param *v3.Parameter, sch *base.Schema, dupli func IncorrectCookieParamArrayBoolean( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -321,10 +297,7 @@ func IncorrectCookieParamArrayBoolean( func IncorrectQueryParamArrayInteger( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -349,10 +322,7 @@ func IncorrectQueryParamArrayInteger( func IncorrectQueryParamArrayNumber( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -377,10 +347,7 @@ func IncorrectQueryParamArrayNumber( func IncorrectCookieParamArrayNumber( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -429,10 +396,7 @@ func IncorrectParamEncodingJSON(param *v3.Parameter, ef string, sch *base.Schema } func IncorrectQueryParamBool(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -456,10 +420,7 @@ func IncorrectQueryParamBool(param *v3.Parameter, ef string, sch *base.Schema, p } func InvalidQueryParamInteger(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -483,10 +444,7 @@ func InvalidQueryParamInteger(param *v3.Parameter, ef string, sch *base.Schema, } func InvalidQueryParamNumber(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -516,10 +474,7 @@ func IncorrectQueryParamEnum(param *v3.Parameter, ef string, sch *base.Schema, p } validEnums := strings.Join(enums, ", ") - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/enum", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "enum") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -551,10 +506,7 @@ func IncorrectQueryParamEnumArray(param *v3.Parameter, ef string, sch *base.Sche } validEnums := strings.Join(enums, ", ") - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/enum", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/enum") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -603,10 +555,7 @@ func IncorrectReservedValues(param *v3.Parameter, ef string, sch *base.Schema, p } func InvalidHeaderParamInteger(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -630,10 +579,7 @@ func InvalidHeaderParamInteger(param *v3.Parameter, ef string, sch *base.Schema, } func InvalidHeaderParamNumber(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -657,10 +603,7 @@ func InvalidHeaderParamNumber(param *v3.Parameter, ef string, sch *base.Schema, } func InvalidCookieParamInteger(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -683,10 +626,7 @@ func InvalidCookieParamInteger(param *v3.Parameter, ef string, sch *base.Schema, } func InvalidCookieParamNumber(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -709,10 +649,7 @@ func InvalidCookieParamNumber(param *v3.Parameter, ef string, sch *base.Schema, } func IncorrectHeaderParamBool(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -736,10 +673,7 @@ func IncorrectHeaderParamBool(param *v3.Parameter, ef string, sch *base.Schema, } func IncorrectCookieParamBool(param *v3.Parameter, ef string, sch *base.Schema, pathTemplate string, operation string, renderedSchema string) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -768,10 +702,7 @@ func IncorrectCookieParamEnum(param *v3.Parameter, ef string, sch *base.Schema, } validEnums := strings.Join(enums, ", ") - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/enum", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "enum") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -796,10 +727,7 @@ func IncorrectCookieParamEnum(param *v3.Parameter, ef string, sch *base.Schema, func IncorrectHeaderParamArrayBoolean( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, @@ -824,10 +752,7 @@ func IncorrectHeaderParamArrayBoolean( func IncorrectHeaderParamArrayNumber( param *v3.Parameter, item string, sch *base.Schema, itemsSchema *base.Schema, pathTemplate string, operation string, renderedItemsSchema string, ) *ValidationError { - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") - keywordLocation := fmt.Sprintf("/paths/%s/%s/parameters/%s/schema/items/type", escapedPath, strings.ToLower(operation), param.Name) + keywordLocation := helpers.ConstructParameterJSONPointer(pathTemplate, operation, param.Name, "items/type") return &ValidationError{ ValidationType: helpers.ParameterValidation, diff --git a/errors/validation_error_test.go b/errors/validation_error_test.go index 749dedf..c68ca4c 100644 --- a/errors/validation_error_test.go +++ b/errors/validation_error_test.go @@ -13,11 +13,11 @@ import ( func TestSchemaValidationFailure_Error(t *testing.T) { // Test the Error method of SchemaValidationFailure s := &SchemaValidationFailure{ - Reason: "Invalid type", - Location: "/path/to/property", + Reason: "Invalid type", + FieldPath: "$.path.to.property", } - expectedError := "Reason: Invalid type, Location: /path/to/property" + expectedError := "Reason: Invalid type, FieldPath: $.path.to.property" require.Equal(t, expectedError, s.Error()) } @@ -48,8 +48,8 @@ func TestValidationError_Error_WithSpecLineAndColumn(t *testing.T) { func TestValidationError_Error_WithSchemaValidationErrors(t *testing.T) { // Test the Error method of ValidationError with SchemaValidationErrors schemaError := &SchemaValidationFailure{ - Reason: "Invalid enum value", - Location: "/path/to/enum", + Reason: "Invalid enum value", + FieldPath: "$.path.to.enum", } v := &ValidationError{ Message: "Enum validation failed", @@ -64,8 +64,8 @@ func TestValidationError_Error_WithSchemaValidationErrors(t *testing.T) { func TestValidationError_Error_WithSchemaValidationErrors_AndSpecLineColumn(t *testing.T) { // Test the Error method of ValidationError with SchemaValidationErrors and SpecLine and SpecCol schemaError := &SchemaValidationFailure{ - Reason: "Invalid enum value", - Location: "/path/to/enum", + Reason: "Invalid enum value", + FieldPath: "$.path.to.enum", } v := &ValidationError{ Message: "Enum validation failed", diff --git a/helpers/json_pointer.go b/helpers/json_pointer.go index 3ec390c..a7bccac 100644 --- a/helpers/json_pointer.go +++ b/helpers/json_pointer.go @@ -20,6 +20,7 @@ func EscapeJSONPointerSegment(segment string) string { // in the OpenAPI specification. // Format: /paths/{path}/{method}/parameters/{paramName}/schema/{keyword} // The path segment is automatically escaped according to RFC 6901. +// The keyword can be a simple keyword like "type" or a nested path like "items/type". func ConstructParameterJSONPointer(pathTemplate, method, paramName, keyword string) string { escapedPath := EscapeJSONPointerSegment(pathTemplate) escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading slash encoding diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index df5ae8b..ed7d099 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -247,15 +247,14 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val keywordLocation = fmt.Sprintf("/paths/%s/%s/parameters/%s/schema%s", escapedPath, strings.ToLower(operation), name, er.KeywordLocation) } - fail := &errors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.KeywordLocation, // DEPRECATED - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: keywordLocation, - OriginalJsonSchemaError: scErrs, - } + fail := &errors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: keywordLocation, + OriginalJsonSchemaError: scErrs, + } if schema != nil { rendered, err := schema.RenderInline() if err == nil && rendered != nil { diff --git a/responses/validate_headers.go b/responses/validate_headers.go index 2d5499c..8546781 100644 --- a/responses/validate_headers.go +++ b/responses/validate_headers.go @@ -55,13 +55,7 @@ func ValidateResponseHeaders( for name, header := range headers.FromOldest() { if header.Required { if _, ok := locatedHeaders[strings.ToLower(name)]; !ok { - // Construct full OpenAPI path for KeywordLocation - // e.g., /paths/~1health/get/responses/200/headers/chicken-nuggets/required - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - method := strings.ToLower(request.Method) - keywordLocation := fmt.Sprintf("/paths/%s/%s/responses/%s/headers/%s/required", - escapedPath, method, statusCode, name) + keywordLocation := helpers.ConstructResponseHeaderJSONPointer(pathTemplate, request.Method, statusCode, name, "required") validationErrors = append(validationErrors, &errors.ValidationError{ ValidationType: helpers.ResponseBodyValidation, diff --git a/schema_validation/validate_document_test.go b/schema_validation/validate_document_test.go index c18a3cf..6968bb6 100644 --- a/schema_validation/validate_document_test.go +++ b/schema_validation/validate_document_test.go @@ -64,7 +64,6 @@ func validateOpenAPIDocumentWithMalformedSchema(loadedSchema string, decodedDocu // schema compilation failed, return validation error instead of panicking violation := &liberrors.SchemaValidationFailure{ Reason: fmt.Sprintf("failed to compile OpenAPI schema: %s", err.Error()), - Location: "schema compilation", ReferenceSchema: loadedSchema, } validationErrors = append(validationErrors, &liberrors.ValidationError{ diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index b2def79..b6df654 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -278,17 +278,16 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, referenceObject = string(payload) } - violation := &liberrors.SchemaValidationFailure{ - Reason: errMsg, - Location: er.InstanceLocation, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - ReferenceSchema: string(renderedSchema), - ReferenceObject: referenceObject, - OriginalJsonSchemaError: jk, - } + violation := &liberrors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + ReferenceSchema: string(renderedSchema), + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { line := located.Line From 577592583f5b1c3fd7e4f263f18d5404e0306208 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 13:24:49 -0800 Subject: [PATCH 18/25] fix lint and test --- .gitignore | 2 -- parameters/path_parameters_test.go | 24 ++++++++++----------- parameters/validate_parameter_test.go | 7 +++--- requests/validate_body.go | 4 +++- responses/validate_headers.go | 8 +++++-- schema_validation/validate_document_test.go | 22 ++++++++----------- 6 files changed, 34 insertions(+), 33 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 00a9078..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Working memory and debug artifacts -working-memory/ \ No newline at end of file diff --git a/parameters/path_parameters_test.go b/parameters/path_parameters_test.go index a12a82b..02f2068 100644 --- a/parameters/path_parameters_test.go +++ b/parameters/path_parameters_test.go @@ -440,7 +440,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: minimum: got 1, want 10, Location: /minimum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: minimum: got 1, want 10", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_SimpleEncodedPath_MinimumInteger(t *testing.T) { @@ -495,7 +495,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: maximum: got 11, want 10, Location: /maximum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: maximum: got 11, want 10", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_SimpleEncodedPath_MaximumInteger(t *testing.T) { @@ -577,7 +577,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: minimum: got 1.3, want 10.2, Location: /minimum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: minimum: got 1.3, want 10.2", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_SimpleEncodedPath_MinimumNumber(t *testing.T) { @@ -632,7 +632,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: maximum: got 11.2, want 10.2, Location: /maximum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: maximum: got 11.2, want 10.2", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_SimpleEncodedPath_MaximumNumber(t *testing.T) { @@ -741,7 +741,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: minimum: got 3, want 10, Location: /minimum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: minimum: got 3, want 10", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_LabelEncodedPath_MaximumIntegerViolation(t *testing.T) { @@ -771,7 +771,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: maximum: got 32, want 10, Location: /maximum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: maximum: got 32, want 10", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_LabelEncodedPath_InvalidBoolean(t *testing.T) { @@ -1278,7 +1278,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: minimum: got 3, want 5, Location: /minimum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: minimum: got 3, want 5", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_MatrixEncodedPath_MaximumIntegerViolation(t *testing.T) { @@ -1308,7 +1308,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: maximum: got 30, want 5, Location: /maximum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: maximum: got 30, want 5", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_MatrixEncodedPath_InvalidNumber(t *testing.T) { @@ -1365,7 +1365,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: minimum: got 3, want 5, Location: /minimum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: minimum: got 3, want 5", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_MatrixEncodedPath_MaximumNumberViolation(t *testing.T) { @@ -1395,7 +1395,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: maximum: got 30, want 5, Location: /maximum", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: maximum: got 30, want 5", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_MatrixEncodedPath_ValidPrimitiveBoolean(t *testing.T) { @@ -1796,7 +1796,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: minLength: got 3, want 4, Location: /minLength", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: minLength: got 3, want 4", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_PathParamStringMaxLengthViolation(t *testing.T) { @@ -1825,7 +1825,7 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "Path parameter 'burgerId' failed to validate", errors[0].Message) assert.Len(t, errors[0].SchemaValidationErrors, 1) - assert.Equal(t, "Reason: maxLength: got 3, want 1, Location: /maxLength", errors[0].SchemaValidationErrors[0].Error()) + assert.Equal(t, "Reason: maxLength: got 3, want 1", errors[0].SchemaValidationErrors[0].Error()) } func TestNewValidator_PathParamIntegerEnumValid(t *testing.T) { diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index 3ed2a83..bb3e4d3 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -265,7 +265,7 @@ func TestUnifiedErrorFormatWithFormatValidation(t *testing.T) { // verify unified error format - SchemaValidationErrors should be populated assert.Len(t, valErrs[0].SchemaValidationErrors, 1) assert.Contains(t, valErrs[0].SchemaValidationErrors[0].Reason, "is not valid email") - assert.Equal(t, "/format", valErrs[0].SchemaValidationErrors[0].KeywordLocation) + assert.Equal(t, "/paths/test/get/parameters/email_param/schema/format", valErrs[0].SchemaValidationErrors[0].KeywordLocation) assert.NotEmpty(t, valErrs[0].SchemaValidationErrors[0].ReferenceSchema) } @@ -320,8 +320,9 @@ func TestParameterNameFieldPopulation(t *testing.T) { assert.Equal(t, "integer_param", valErrs[0].ParameterName) assert.Equal(t, "Query parameter 'integer_param' is not a valid integer", valErrs[0].Message) - // basic type errors should NOT have SchemaValidationErrors (no JSONSchema validation occurred) - assert.Empty(t, valErrs[0].SchemaValidationErrors) + // basic type errors SHOULD have SchemaValidationErrors because we know the parameter schema + assert.Len(t, valErrs[0].SchemaValidationErrors, 1) + assert.Equal(t, "integer_param", valErrs[0].SchemaValidationErrors[0].FieldName) } func TestHeaderSchemaStringNoJSON(t *testing.T) { diff --git a/requests/validate_body.go b/requests/validate_body.go index 6e9c13a..748bea7 100644 --- a/requests/validate_body.go +++ b/requests/validate_body.go @@ -99,7 +99,9 @@ func (v *requestBodyValidator) extractContentType(contentType string, operation return mediaType, true } ctMediaRange := strings.SplitN(ct, "/", 2) - for s, mediaTypeValue := range operation.RequestBody.Content.FromOldest() { + for contentPair := operation.RequestBody.Content.First(); contentPair != nil; contentPair = contentPair.Next() { + s := contentPair.Key() + mediaTypeValue := contentPair.Value() opMediaRange := strings.SplitN(s, "/", 2) if (opMediaRange[0] == "*" || opMediaRange[0] == ctMediaRange[0]) && (opMediaRange[1] == "*" || opMediaRange[1] == ctMediaRange[1]) { diff --git a/responses/validate_headers.go b/responses/validate_headers.go index 8546781..8c40064 100644 --- a/responses/validate_headers.go +++ b/responses/validate_headers.go @@ -40,7 +40,9 @@ func ValidateResponseHeaders( // iterate through the response headers for name, v := range response.Header { // check if the model is in the spec - for k, header := range headers.FromOldest() { + for pair := headers.First(); pair != nil; pair = pair.Next() { + k := pair.Key() + header := pair.Value() if strings.EqualFold(k, name) { locatedHeaders[strings.ToLower(name)] = headerPair{ name: k, @@ -52,7 +54,9 @@ func ValidateResponseHeaders( } // determine if any required headers are missing from the response - for name, header := range headers.FromOldest() { + for pair := headers.First(); pair != nil; pair = pair.Next() { + name := pair.Key() + header := pair.Value() if header.Required { if _, ok := locatedHeaders[strings.ToLower(name)]; !ok { keywordLocation := helpers.ConstructResponseHeaderJSONPointer(pathTemplate, request.Method, statusCode, name, "required") diff --git a/schema_validation/validate_document_test.go b/schema_validation/validate_document_test.go index 6968bb6..90a4797 100644 --- a/schema_validation/validate_document_test.go +++ b/schema_validation/validate_document_test.go @@ -62,20 +62,16 @@ func validateOpenAPIDocumentWithMalformedSchema(loadedSchema string, decodedDocu _, err := helpers.NewCompiledSchema("schema", []byte(loadedSchema), options) if err != nil { // schema compilation failed, return validation error instead of panicking - violation := &liberrors.SchemaValidationFailure{ - Reason: fmt.Sprintf("failed to compile OpenAPI schema: %s", err.Error()), - ReferenceSchema: loadedSchema, - } + // NO SchemaValidationFailure for pre-validation errors like compilation failures validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: "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, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: "check the OpenAPI schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: loadedSchema, + ValidationType: "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 } From a825714897e8f24f7b604fa21921e5348f4179c3 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 13:46:57 -0800 Subject: [PATCH 19/25] WIP: Add upstream files and fix Location field references - Added missing files from upstream: property_locator.go, xml_validator.go, etc. - Fixed Location field references (removed in our changes) - Fixed enrichSchemaValidationFailure signature to remove location parameter - Added OriginalError field back for backwards compatibility - Fixed XML validation API compatibility with goxml2json - Added missing dependency: github.com/basgys/goxml2json This commit prepares for merging origin/main into our branch. --- errors/validation_error.go | 6 + go.mod | 4 +- go.sum | 6 + schema_validation/property_locator.go | 306 ++++++ schema_validation/property_locator_test.go | 1097 ++++++++++++++++++++ schema_validation/validate_xml.go | 166 +++ schema_validation/validate_xml_test.go | 999 ++++++++++++++++++ schema_validation/xml_validator.go | 59 ++ 8 files changed, 2642 insertions(+), 1 deletion(-) create mode 100644 schema_validation/property_locator.go create mode 100644 schema_validation/property_locator_test.go create mode 100644 schema_validation/validate_xml.go create mode 100644 schema_validation/validate_xml_test.go create mode 100644 schema_validation/xml_validator.go diff --git a/errors/validation_error.go b/errors/validation_error.go index 685232b..f0a4919 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -50,6 +50,12 @@ type SchemaValidationFailure struct { // The original jsonschema.ValidationError object, if the schema failure originated from the jsonschema library. OriginalJsonSchemaError *jsonschema.ValidationError `json:"-" yaml:"-"` + + // OriginalError is an alias for OriginalJsonSchemaError for backwards compatibility + OriginalError *jsonschema.ValidationError `json:"-" yaml:"-"` + + // Context is the raw schema object that failed validation (for programmatic access) + Context interface{} `json:"-" yaml:"-"` } // Error returns a string representation of the error diff --git a/go.mod b/go.mod index 458ef7f..6455b7e 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,16 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v4 v4.0.0-rc.2 - golang.org/x/text v0.30.0 + golang.org/x/text v0.31.0 ) require ( github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/basgys/goxml2json v1.1.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4a85e31..492b6c9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw= +github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -26,8 +28,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/schema_validation/property_locator.go b/schema_validation/property_locator.go new file mode 100644 index 0000000..dce4377 --- /dev/null +++ b/schema_validation/property_locator.go @@ -0,0 +1,306 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "regexp" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + "go.yaml.in/yaml/v4" + + liberrors "github.com/pb33f/libopenapi-validator/errors" + "github.com/pb33f/libopenapi-validator/helpers" +) + +// PropertyNameInfo contains extracted information about a property name validation error +type PropertyNameInfo struct { + PropertyName string // The property name that violated validation (e.g., "$defs-atmVolatility_type") + ParentLocation []string // The path to the parent containing the property (e.g., ["components", "schemas"]) + EnhancedReason string // A more detailed error message with context + Pattern string // The pattern that was violated, if applicable +} + +var ( + // invalidPropertyNameRegex matches errors like: "invalid propertyName 'X'" + invalidPropertyNameRegex = regexp.MustCompile(`invalid propertyName '([^']+)'`) + + // patternMismatchRegex matches errors like: "'X' does not match pattern 'Y'" + patternMismatchRegex = regexp.MustCompile(`'([^']+)' does not match pattern '([^']+)'`) +) + +// extractPropertyNameFromError extracts property name information from a jsonschema.ValidationError +// when BasicOutput doesn't provide useful InstanceLocation. +// This handles Priority 1 (invalid propertyName) and Priority 2 (pattern mismatch) cases. +// +// Returns PropertyNameInfo with extracted details, or nil if no relevant information found. +// Note: ValidationError.Error() includes all cause information in the formatted string, +// so we only need to check the root error message. +func extractPropertyNameFromError(ve *jsonschema.ValidationError) *PropertyNameInfo { + if ve == nil { + return nil + } + + // Check error message for patterns (Error() includes all cause information) + return checkErrorForPropertyInfo(ve) +} + +// checkErrorForPropertyInfo examines a single ValidationError for property name patterns. +// This is extracted as a separate function to avoid duplication and improve testability. +func checkErrorForPropertyInfo(ve *jsonschema.ValidationError) *PropertyNameInfo { + errMsg := ve.Error() + return checkErrorMessageForPropertyInfo(errMsg, ve.InstanceLocation, ve) +} + +// checkErrorMessageForPropertyInfo extracts property name info from an error message string. +// This is separated to improve testability while keeping validation error traversal logic intact. +func checkErrorMessageForPropertyInfo(errMsg string, instanceLocation []string, ve *jsonschema.ValidationError) *PropertyNameInfo { + // Check for "invalid propertyName 'X'" first (most specific error message) + if matches := invalidPropertyNameRegex.FindStringSubmatch(errMsg); len(matches) > 1 { + propertyName := matches[1] + info := &PropertyNameInfo{ + PropertyName: propertyName, + ParentLocation: instanceLocation, + } + + // try to extract pattern information from deeper causes if available + var pattern string + if ve != nil { + pattern = extractPatternFromCauses(ve) + } + + if pattern != "" { + info.Pattern = pattern + info.EnhancedReason = buildEnhancedReason(propertyName, pattern) + } else { + info.EnhancedReason = "invalid propertyName '" + propertyName + "'" + } + + return info + } + + // Check for "'X' does not match pattern 'Y'" as fallback (pattern violation) + if matches := patternMismatchRegex.FindStringSubmatch(errMsg); len(matches) > 2 { + return &PropertyNameInfo{ + PropertyName: matches[1], + ParentLocation: instanceLocation, + Pattern: matches[2], + EnhancedReason: buildEnhancedReason(matches[1], matches[2]), + } + } + + return nil +} + +// extractPatternFromCauses looks through error causes to find pattern violation details. +// Since ValidationError.Error() includes all cause information, we check the formatted error string. +func extractPatternFromCauses(ve *jsonschema.ValidationError) string { + if ve == nil { + return "" + } + + // Check the error message which includes all cause information + errMsg := ve.Error() + if matches := patternMismatchRegex.FindStringSubmatch(errMsg); len(matches) > 2 { + return matches[2] + } + + return "" +} + +// buildEnhancedReason constructs a detailed error message with property name and pattern +func buildEnhancedReason(propertyName, pattern string) string { + var buf strings.Builder + buf.Grow(len(propertyName) + len(pattern) + 50) // pre-allocate to avoid reallocation + buf.WriteString("invalid propertyName '") + buf.WriteString(propertyName) + buf.WriteString("': does not match pattern '") + buf.WriteString(pattern) + buf.WriteString("'") + return buf.String() +} + +// findPropertyKeyNodeInYAML searches the YAML tree for a property key node at a specific location. +// It first navigates to the parent location, then searches for the property name as a map key. +// +// Parameters: +// - rootNode: The root YAML node to search from +// - propertyName: The property key to find (e.g., "$defs-atmVolatility_type") +// - parentPath: Path segments to the parent (e.g., ["components", "schemas"]) +// +// Returns the YAML node for the property key, or nil if not found. +func findPropertyKeyNodeInYAML(rootNode *yaml.Node, propertyName string, parentPath []string) *yaml.Node { + if rootNode == nil || propertyName == "" { + return nil + } + + // Navigate to parent location first + currentNode := rootNode + for _, segment := range parentPath { + currentNode = navigateToYAMLChild(currentNode, segment) + if currentNode == nil { + return nil + } + } + + // Search for the property name as a map key + return findMapKeyNode(currentNode, propertyName) +} + +// navigateToYAMLChild navigates from a parent node to a child by name. +// Handles both document root navigation and map content navigation. +func navigateToYAMLChild(parent *yaml.Node, childName string) *yaml.Node { + if parent == nil { + return nil + } + + // If parent is a document node, navigate to its content + if parent.Kind == yaml.DocumentNode && len(parent.Content) > 0 { + parent = parent.Content[0] + } + + // Navigate through mapping node + if parent.Kind == yaml.MappingNode { + return findMapKeyValue(parent, childName) + } + + return nil +} + +// findMapKeyValue searches a mapping node for a key and returns its value node +func findMapKeyValue(mappingNode *yaml.Node, keyName string) *yaml.Node { + if mappingNode.Kind != yaml.MappingNode { + return nil + } + + // mapping nodes have key-value pairs: [key1, value1, key2, value2, ...] + for i := 0; i < len(mappingNode.Content); i += 2 { + keyNode := mappingNode.Content[i] + if keyNode.Value == keyName { + // return the value node (i+1) + if i+1 < len(mappingNode.Content) { + return mappingNode.Content[i+1] + } + } + } + + return nil +} + +// findMapKeyNode searches a mapping node for a key and returns the key node itself (not the value) +func findMapKeyNode(mappingNode *yaml.Node, keyName string) *yaml.Node { + if mappingNode == nil { + return nil + } + + // if it's a document node, unwrap to content + if mappingNode.Kind == yaml.DocumentNode && len(mappingNode.Content) > 0 { + mappingNode = mappingNode.Content[0] + } + + if mappingNode.Kind != yaml.MappingNode { + return nil + } + + // mapping nodes have key-value pairs: [key1, value1, key2, value2, ...] + for i := 0; i < len(mappingNode.Content); i += 2 { + keyNode := mappingNode.Content[i] + if keyNode.Value == keyName { + return keyNode // contains line/column metadata for error reporting + } + } + + return nil +} + +// applyPropertyNameFallback attempts to enrich a violation with property name information +// when the primary location method fails. Returns true if enrichment was applied. +func applyPropertyNameFallback( + propertyInfo *PropertyNameInfo, + rootNode *yaml.Node, + violation *liberrors.SchemaValidationFailure, +) bool { + if propertyInfo == nil { + return false + } + + return enrichSchemaValidationFailure( + propertyInfo, + rootNode, + &violation.Line, + &violation.Column, + &violation.Reason, + &violation.FieldName, + &violation.FieldPath, + &violation.InstancePath, + ) +} + +// enrichSchemaValidationFailure attempts to enhance a SchemaValidationFailure with better +// location information by searching the YAML tree when the standard location is empty. +// +// This function: +// 1. searches YAML tree for the property key in various locations +// 2. updates Line, Column, Reason, and other fields if found +// +// Returns true if enrichment was performed, false otherwise. +func enrichSchemaValidationFailure( + failure *PropertyNameInfo, + rootNode *yaml.Node, + line *int, + column *int, + reason *string, + fieldName *string, + fieldPath *string, + instancePath *[]string, +) bool { + if failure == nil { + return false + } + + // search for the property key in the YAML tree with multiple fallback locations + // since InstanceLocation may be empty for property name errors + var foundNode *yaml.Node + + // try with the provided parent location first + if len(failure.ParentLocation) > 0 { + foundNode = findPropertyKeyNodeInYAML(rootNode, failure.PropertyName, failure.ParentLocation) + } + + // common fallback locations for OpenAPI property name errors + if foundNode == nil { + foundNode = findPropertyKeyNodeInYAML(rootNode, failure.PropertyName, []string{"components", "schemas"}) + } + if foundNode == nil { + foundNode = findPropertyKeyNodeInYAML(rootNode, failure.PropertyName, []string{"components"}) + } + if foundNode == nil { + foundNode = findPropertyKeyNodeInYAML(rootNode, failure.PropertyName, []string{}) + } + + if foundNode == nil { + return false + } + + // populate location metadata from YAML node + *line = foundNode.Line + *column = foundNode.Column + + if failure.EnhancedReason != "" { + *reason = failure.EnhancedReason + } + + *fieldName = failure.PropertyName + + // construct JSONPath from parent location segments + if len(failure.ParentLocation) > 0 { + *fieldPath = helpers.ExtractJSONPathFromStringLocation("/" + strings.Join(failure.ParentLocation, "/") + "/" + failure.PropertyName) + *instancePath = failure.ParentLocation + } else { + *fieldPath = helpers.ExtractJSONPathFromStringLocation("/" + failure.PropertyName) + *instancePath = []string{} + } + + return true +} diff --git a/schema_validation/property_locator_test.go b/schema_validation/property_locator_test.go new file mode 100644 index 0000000..88b64e4 --- /dev/null +++ b/schema_validation/property_locator_test.go @@ -0,0 +1,1097 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "testing" + + "github.com/pb33f/libopenapi" + "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" +) + +func TestExtractPropertyNameFromError_InvalidPropertyName(t *testing.T) { + // We can't easily create a complete ValidationError without the jsonschema library internals, + // so we test the regex patterns separately in TestCheckErrorForPropertyInfo_* + // This test verifies that nil is returned for nil input + info := extractPropertyNameFromError(nil) + assert.Nil(t, info) +} + +func TestCheckErrorForPropertyInfo_InvalidPropertyName(t *testing.T) { + // Test the regex patterns that power property name extraction + // We test the regexes directly since we can't easily create proper ValidationError objects + testCases := []struct { + name string + errorMsg string + expectedProp string + shouldMatch bool + }{ + { + name: "Simple invalid property name", + errorMsg: "invalid propertyName '$defs-atmVolatility_type'", + expectedProp: "$defs-atmVolatility_type", + shouldMatch: true, + }, + { + name: "Property name with special chars", + errorMsg: "invalid propertyName '$ref-test_value'", + expectedProp: "$ref-test_value", + shouldMatch: true, + }, + { + name: "Property name with @", + errorMsg: "invalid propertyName '@invalid'", + expectedProp: "@invalid", + shouldMatch: true, + }, + { + name: "No match - different error", + errorMsg: "some other validation error", + shouldMatch: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Test the invalidPropertyNameRegex + matches := invalidPropertyNameRegex.FindStringSubmatch(tc.errorMsg) + if tc.shouldMatch { + assert.Len(t, matches, 2) + assert.Equal(t, tc.expectedProp, matches[1]) + } else { + assert.Len(t, matches, 0) + } + }) + } +} + +func TestCheckErrorForPropertyInfo_PatternMismatch(t *testing.T) { + testCases := []struct { + name string + errorMsg string + expectedValue string + expectedPattern string + }{ + { + name: "Standard pattern mismatch", + errorMsg: "'$defs-atmVolatility_type' does not match pattern '^[a-zA-Z0-9._-]+$'", + expectedValue: "$defs-atmVolatility_type", + expectedPattern: "^[a-zA-Z0-9._-]+$", + }, + { + name: "Complex pattern", + errorMsg: "'invalid@value' does not match pattern '^[a-zA-Z]+$'", + expectedValue: "invalid@value", + expectedPattern: "^[a-zA-Z]+$", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + matches := patternMismatchRegex.FindStringSubmatch(tc.errorMsg) + assert.Len(t, matches, 3) + assert.Equal(t, tc.expectedValue, matches[1]) + assert.Equal(t, tc.expectedPattern, matches[2]) + }) + } +} + +func TestCheckErrorMessageForPropertyInfo_InvalidPropertyNameWithPattern(t *testing.T) { + // Test the invalidPropertyName pattern WITH pattern extraction via real ValidationError + spec := `openapi: 3.1.0 +info: + title: Test With Pattern + version: 1.0.0 +components: + schemas: + $with-pattern: + type: object` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + _, errors := ValidateOpenAPIDocument(doc) + + if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { + sve := errors[0].SchemaValidationErrors[0] + if sve.OriginalError != nil { + // Test extractPatternFromCauses directly with the real error + pattern := extractPatternFromCauses(sve.OriginalError) + assert.NotEmpty(t, pattern, "Should extract pattern from ValidationError") + + // Also test the info extraction + info := checkErrorForPropertyInfo(sve.OriginalError) + assert.NotNil(t, info) + assert.Equal(t, "$with-pattern", info.PropertyName) + assert.NotEmpty(t, info.Pattern, "Pattern should be extracted from causes") + } + } +} + +func TestExtractPatternFromCauses_ErrorWithoutPattern(t *testing.T) { + // Test extractPatternFromCauses when Error() doesn't match the pattern regex + // We need a ValidationError whose Error() doesn't contain the pattern format + // Since we can't easily create one, we test that the function returns "" for non-matching messages + + // Create a spec with a validation error that won't have pattern information + spec := `openapi: 3.0.0 +info: + title: Test Without Pattern Info + version: 1.0.0 + contact: + invalid: this is not a valid contact` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + _, errors := ValidateOpenAPIDocument(doc) + + if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { + for _, sve := range errors[0].SchemaValidationErrors { + if sve.OriginalError != nil { + // Call extractPatternFromCauses - may return empty string for errors without pattern + pattern := extractPatternFromCauses(sve.OriginalError) + // Pattern might be empty for non-property-name errors (covering line 108) + _ = pattern + } + } + } +} + +func TestCheckErrorMessageForPropertyInfo_InvalidPropertyNameNoPattern(t *testing.T) { + // Test the invalidPropertyName pattern WITHOUT pattern extraction (ve = nil) + // This tests the else branch at line 84-86 + errMsg := "invalid propertyName '$no-pattern-test'" + instanceLoc := []string{"components"} + + info := checkErrorMessageForPropertyInfo(errMsg, instanceLoc, nil) + assert.NotNil(t, info) + assert.Equal(t, "$no-pattern-test", info.PropertyName) + assert.Equal(t, "invalid propertyName '$no-pattern-test'", info.EnhancedReason) + assert.Empty(t, info.Pattern, "Pattern should be empty when ve is nil") +} + +func TestCheckErrorMessageForPropertyInfo_PatternMismatchDirect(t *testing.T) { + // Test the patternMismatchRegex branch (line 92-99) + errMsg := "'$invalid' does not match pattern '^[a-zA-Z0-9._-]+$'" + instanceLoc := []string{"test"} + + info := checkErrorMessageForPropertyInfo(errMsg, instanceLoc, nil) + assert.NotNil(t, info) + assert.Equal(t, "$invalid", info.PropertyName) + assert.Equal(t, "^[a-zA-Z0-9._-]+$", info.Pattern) + assert.Contains(t, info.EnhancedReason, "does not match pattern") +} + +func TestCheckErrorMessageForPropertyInfo_NoMatch(t *testing.T) { + // Test when no patterns match (returns nil at line 101) + errMsg := "some completely different error message" + instanceLoc := []string{} + + info := checkErrorMessageForPropertyInfo(errMsg, instanceLoc, nil) + assert.Nil(t, info, "Should return nil when no patterns match") +} + +func TestBuildEnhancedReason(t *testing.T) { + testCases := []struct { + name string + propertyName string + pattern string + expected string + }{ + { + name: "Standard case", + propertyName: "$defs-test", + pattern: "^[a-zA-Z0-9._-]+$", + expected: "invalid propertyName '$defs-test': does not match pattern '^[a-zA-Z0-9._-]+$'", + }, + { + name: "Empty pattern", + propertyName: "test", + pattern: "", + expected: "invalid propertyName 'test': does not match pattern ''", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := buildEnhancedReason(tc.propertyName, tc.pattern) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestExtractPatternFromCauses_Nil(t *testing.T) { + // Test nil input + pattern := extractPatternFromCauses(nil) + assert.Empty(t, pattern) +} + +func TestExtractPatternFromCauses_WithRealError(t *testing.T) { + // Test pattern extraction with a real ValidationError from ValidateOpenAPIDocument + spec := `openapi: 3.1.0 +info: + title: Test Pattern Extraction + version: 1.0.0 +components: + schemas: + $pattern-test: + type: object` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + _, errors := ValidateOpenAPIDocument(doc) + + if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { + sve := errors[0].SchemaValidationErrors[0] + if sve.OriginalError != nil { + // Test pattern extraction + pattern := extractPatternFromCauses(sve.OriginalError) + assert.NotEmpty(t, pattern, "Should extract pattern from error") + assert.Equal(t, "^[a-zA-Z0-9._-]+$", pattern) + } + } +} + +func TestExtractPatternFromCauses_NoMatch(t *testing.T) { + // Test the return "" path when error message doesn't contain pattern (line 108) + // We use checkErrorMessageForPropertyInfo which internally calls extractPatternFromCauses + errMsg := "invalid propertyName '$test'" // Has property name but NO pattern in message + instanceLoc := []string{} + + // When ve is nil, extractPatternFromCauses won't be called with pattern info + // But we can test the "no pattern found" path with a different error message + info := checkErrorMessageForPropertyInfo(errMsg, instanceLoc, nil) + assert.NotNil(t, info) + // Should have property name but no pattern since ve=nil prevents extraction + assert.Equal(t, "$test", info.PropertyName) + assert.Empty(t, info.Pattern, "Pattern should be empty when not in message and ve=nil") + + // Also verify the regex doesn't match + testMsg := "some error without pattern" + matches := patternMismatchRegex.FindStringSubmatch(testMsg) + assert.Len(t, matches, 0, "Should not match error without pattern") +} + +func TestExtractPropertyNameFromError_Nil(t *testing.T) { + info := extractPropertyNameFromError(nil) + assert.Nil(t, info) +} + +func TestExtractPropertyNameFromError_DirectExtraction(t *testing.T) { + // Test that extractPropertyNameFromError works by checking the root error message + // (which includes all cause information from jsonschema library) + spec := `openapi: 3.1.0 +info: + title: Test Direct Extraction + version: 1.0.0 +components: + schemas: + $direct-test: + type: object` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + _, errors := ValidateOpenAPIDocument(doc) + if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { + sve := errors[0].SchemaValidationErrors[0] + if sve.OriginalError != nil { + // Test extraction from root error + info := extractPropertyNameFromError(sve.OriginalError) + assert.NotNil(t, info, "Should extract property name from root error") + assert.Equal(t, "$direct-test", info.PropertyName) + assert.NotEmpty(t, info.EnhancedReason) + + // Test extractPatternFromCauses on the root error + pattern := extractPatternFromCauses(sve.OriginalError) + assert.NotEmpty(t, pattern, "Should extract pattern from error message") + assert.Equal(t, "^[a-zA-Z0-9._-]+$", pattern) + } + } +} + +func TestExtractPropertyNameFromError_ReturnNilPath(t *testing.T) { + // Test the "return nil" path at line 54 when no patterns match and no causes have info + // We use a real validation error from a different type of violation + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /test: + get: + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - missingField + properties: + otherField: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + // This creates a valid OpenAPI spec, so we get no validation errors + // But we can use it to test the nil return path + valid, errors := ValidateOpenAPIDocument(doc) + + if valid { + // No errors - good, this tests that we handle valid specs + assert.True(t, valid) + } else if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { + // If there are errors, test extraction (might not find property name info) + sve := errors[0].SchemaValidationErrors[0] + if sve.OriginalError != nil { + info := extractPropertyNameFromError(sve.OriginalError) + // Info might be nil for non-property-name errors + _ = info + } + } +} + +func TestFindPropertyKeyNodeInYAML_Success(t *testing.T) { + yamlContent := ` +components: + schemas: + $defs-atmVolatility_type: + type: object + properties: + value: + type: string +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + // Find the problematic property name + foundNode := findPropertyKeyNodeInYAML(rootNode.Content[0], "$defs-atmVolatility_type", []string{"components", "schemas"}) + assert.NotNil(t, foundNode) + assert.Equal(t, "$defs-atmVolatility_type", foundNode.Value) + assert.Greater(t, foundNode.Line, 0) + assert.Greater(t, foundNode.Column, 0) +} + +func TestFindPropertyKeyNodeInYAML_NotFound(t *testing.T) { + yamlContent := ` +components: + schemas: + ValidSchema: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + foundNode := findPropertyKeyNodeInYAML(rootNode.Content[0], "NonExistent", []string{"components", "schemas"}) + assert.Nil(t, foundNode) +} + +func TestFindPropertyKeyNodeInYAML_InvalidParentPath(t *testing.T) { + yamlContent := ` +components: + schemas: + TestSchema: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + foundNode := findPropertyKeyNodeInYAML(rootNode.Content[0], "TestSchema", []string{"invalid", "path"}) + assert.Nil(t, foundNode) +} + +func TestFindPropertyKeyNodeInYAML_NilRootNode(t *testing.T) { + foundNode := findPropertyKeyNodeInYAML(nil, "test", []string{"components"}) + assert.Nil(t, foundNode) +} + +func TestFindPropertyKeyNodeInYAML_EmptyPropertyName(t *testing.T) { + yamlContent := ` +components: + schemas: + TestSchema: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + foundNode := findPropertyKeyNodeInYAML(rootNode.Content[0], "", []string{"components", "schemas"}) + assert.Nil(t, foundNode) +} + +func TestFindPropertyKeyNodeInYAML_EmptyParentPath(t *testing.T) { + yamlContent := ` +TestProperty: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + foundNode := findPropertyKeyNodeInYAML(rootNode.Content[0], "TestProperty", []string{}) + assert.NotNil(t, foundNode) + assert.Equal(t, "TestProperty", foundNode.Value) +} + +func TestNavigateToYAMLChild_Success(t *testing.T) { + yamlContent := ` +components: + schemas: + TestSchema: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + // Navigate to components + child := navigateToYAMLChild(rootNode.Content[0], "components") + assert.NotNil(t, child) + assert.Equal(t, yaml.MappingNode, child.Kind) +} + +func TestNavigateToYAMLChild_NotFound(t *testing.T) { + yamlContent := ` +components: + schemas: + TestSchema: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + child := navigateToYAMLChild(rootNode.Content[0], "nonexistent") + assert.Nil(t, child) +} + +func TestNavigateToYAMLChild_NilParent(t *testing.T) { + child := navigateToYAMLChild(nil, "test") + assert.Nil(t, child) +} + +func TestNavigateToYAMLChild_DocumentNode(t *testing.T) { + yamlContent := ` +test: + value: 123 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + // rootNode itself is a DocumentNode + child := navigateToYAMLChild(&rootNode, "test") + assert.NotNil(t, child) +} + +func TestNavigateToYAMLChild_NonMappingNode(t *testing.T) { + yamlContent := ` +- item1 +- item2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + // Try to navigate a sequence node as if it were a map + child := navigateToYAMLChild(rootNode.Content[0], "test") + assert.Nil(t, child) +} + +func TestFindMapKeyValue_Success(t *testing.T) { + yamlContent := ` +key1: value1 +key2: value2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + valueNode := findMapKeyValue(rootNode.Content[0], "key1") + assert.NotNil(t, valueNode) + assert.Equal(t, "value1", valueNode.Value) +} + +func TestFindMapKeyValue_NotFound(t *testing.T) { + yamlContent := ` +key1: value1 +key2: value2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + valueNode := findMapKeyValue(rootNode.Content[0], "key3") + assert.Nil(t, valueNode) +} + +func TestFindMapKeyValue_NonMappingNode(t *testing.T) { + yamlContent := ` +- item1 +- item2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + valueNode := findMapKeyValue(rootNode.Content[0], "test") + assert.Nil(t, valueNode) +} + +func TestFindMapKeyNode_Success(t *testing.T) { + yamlContent := ` +key1: value1 +key2: value2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + keyNode := findMapKeyNode(rootNode.Content[0], "key1") + assert.NotNil(t, keyNode) + assert.Equal(t, "key1", keyNode.Value) +} + +func TestFindMapKeyNode_NotFound(t *testing.T) { + yamlContent := ` +key1: value1 +key2: value2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + keyNode := findMapKeyNode(rootNode.Content[0], "key3") + assert.Nil(t, keyNode) +} + +func TestFindMapKeyNode_NilNode(t *testing.T) { + keyNode := findMapKeyNode(nil, "test") + assert.Nil(t, keyNode) +} + +func TestFindMapKeyNode_DocumentNode(t *testing.T) { + yamlContent := ` +test: value +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + // Pass the document node itself + keyNode := findMapKeyNode(&rootNode, "test") + assert.NotNil(t, keyNode) + assert.Equal(t, "test", keyNode.Value) +} + +func TestFindMapKeyNode_NonMappingNode(t *testing.T) { + yamlContent := ` +- item1 +- item2 +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + keyNode := findMapKeyNode(rootNode.Content[0], "test") + assert.Nil(t, keyNode) +} + +func TestEnrichSchemaValidationFailure_Success(t *testing.T) { + yamlContent := ` +components: + schemas: + $defs-atmVolatility_type: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + failure := &PropertyNameInfo{ + PropertyName: "$defs-atmVolatility_type", + ParentLocation: []string{"components", "schemas"}, + EnhancedReason: "invalid propertyName '$defs-atmVolatility_type': does not match pattern '^[a-zA-Z0-9._-]+$'", + Pattern: "^[a-zA-Z0-9._-]+$", + } + + var line, column int + var reason, fieldName, fieldPath string + var instancePath []string + + enriched := enrichSchemaValidationFailure( + failure, + rootNode.Content[0], + &line, + &column, + &reason, + &fieldName, + &fieldPath, + &instancePath, + ) + + assert.True(t, enriched) + assert.Greater(t, line, 0) + assert.Greater(t, column, 0) + assert.Equal(t, "invalid propertyName '$defs-atmVolatility_type': does not match pattern '^[a-zA-Z0-9._-]+$'", reason) + assert.Equal(t, "$defs-atmVolatility_type", fieldName) + assert.Contains(t, fieldPath, "$defs-atmVolatility_type") + assert.Equal(t, []string{"components", "schemas"}, instancePath) +} + +func TestEnrichSchemaValidationFailure_NilFailure(t *testing.T) { + yamlContent := ` +test: value +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + var line, column int + var reason, fieldName, fieldPath string + var instancePath []string + + enriched := enrichSchemaValidationFailure( + nil, + rootNode.Content[0], + &line, + &column, + &reason, + &fieldName, + &fieldPath, + &instancePath, + ) + + assert.False(t, enriched) + assert.Equal(t, 0, line) + assert.Equal(t, 0, column) +} + +func TestEnrichSchemaValidationFailure_PropertyNotFound(t *testing.T) { + yamlContent := ` +components: + schemas: + ValidSchema: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + failure := &PropertyNameInfo{ + PropertyName: "NonExistent", + ParentLocation: []string{"components", "schemas"}, + EnhancedReason: "test reason", + } + + var line, column int + var reason, fieldName, fieldPath string + var instancePath []string + + enriched := enrichSchemaValidationFailure( + failure, + rootNode.Content[0], + &line, + &column, + &reason, + &fieldName, + &fieldPath, + &instancePath, + ) + + assert.False(t, enriched) + assert.Equal(t, 0, line) + assert.Equal(t, 0, column) +} + +func TestEnrichSchemaValidationFailure_EmptyParentLocation(t *testing.T) { + yamlContent := ` +$defs-test: + type: object +` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rootNode) + assert.NoError(t, err) + + failure := &PropertyNameInfo{ + PropertyName: "$defs-test", + ParentLocation: []string{}, + EnhancedReason: "test reason", + } + + var line, column int + var reason, fieldName, fieldPath string + var instancePath []string + + enriched := enrichSchemaValidationFailure( + failure, + rootNode.Content[0], + &line, + &column, + &reason, + &fieldName, + &fieldPath, + &instancePath, + ) + + assert.True(t, enriched) + assert.Greater(t, line, 0) + assert.Equal(t, "test reason", reason) + assert.Equal(t, "$defs-test", fieldName) + assert.Equal(t, []string{}, instancePath) +} + +func TestCheckErrorForPropertyInfo_NoMatch(t *testing.T) { + // checkErrorForPropertyInfo calls ve.Error() which requires a properly initialized ValidationError. + // We can't easily create one without the jsonschema library internals. + // The regex patterns are tested separately in TestCheckErrorForPropertyInfo_* tests above. + // This test is redundant with TestExtractPropertyNameFromError_NoCauses + t.Skip("Skipping as we cannot create a proper ValidationError without internal state") +} + +// TestPropertyLocator_Integration_InvalidPropertyName tests the full flow from ValidateOpenAPIDocument +// through the property locator functions. This provides coverage for extractPropertyNameFromError +// and checkErrorForPropertyInfo which require real ValidationError objects from jsonschema. +func TestPropertyLocator_Integration_InvalidPropertyName(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test Invalid Property Name + version: 1.0.0 +components: + schemas: + $defs-atmVolatility_type: + type: object + properties: + value: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + // Before integration - validate without our fallback logic + // This just verifies the test scenario triggers a validation error + valid, errors := ValidateOpenAPIDocument(doc) + assert.False(t, valid) + assert.Len(t, errors, 1) + + // The validator should find the error + assert.Len(t, errors[0].SchemaValidationErrors, 1) + sve := errors[0].SchemaValidationErrors[0] + + // After integration, the fallback logic should populate Line and Column + assert.Greater(t, sve.Line, 0, "Line should be populated by fallback logic") + assert.Greater(t, sve.Column, 0, "Column should be populated by fallback logic") + + // Verify the enhanced error message includes the property name and pattern + assert.Contains(t, sve.Reason, "$defs-atmVolatility_type", "Reason should include property name") + assert.Contains(t, sve.Reason, "does not match pattern", "Reason should include pattern info") + + // Verify additional fields are populated + assert.Equal(t, "$defs-atmVolatility_type", sve.FieldName, "FieldName should be extracted") + assert.Contains(t, sve.FieldPath, "$defs-atmVolatility_type", "FieldPath should include property name") + + // Original validation check that extractPropertyNameFromError works + assert.NotNil(t, sve.OriginalError, "OriginalError should be populated") + + info := extractPropertyNameFromError(sve.OriginalError) + // This should successfully extract the property name + assert.NotNil(t, info, "Should extract property name info from error") + assert.Equal(t, "$defs-atmVolatility_type", info.PropertyName) + assert.Contains(t, info.EnhancedReason, "$defs-atmVolatility_type") + assert.Contains(t, info.EnhancedReason, "does not match pattern") + assert.NotEmpty(t, info.Pattern, "Pattern should be extracted") + + // Explicitly test checkErrorForPropertyInfo with the root error and causes + // to ensure coverage of different code paths + rootInfo := checkErrorForPropertyInfo(sve.OriginalError) + if rootInfo == nil && len(sve.OriginalError.Causes) > 0 { + // Check first cause + causeInfo := checkErrorForPropertyInfo(sve.OriginalError.Causes[0]) + _ = causeInfo + } + + // Explicitly test extractPatternFromCauses to exercise recursive pattern extraction + pattern := extractPatternFromCauses(sve.OriginalError) + if pattern != "" { + assert.Equal(t, "^[a-zA-Z0-9._-]+$", pattern) + } + + // Verify we can find it in the YAML + docInfo := doc.GetSpecInfo() + + // The parent location might be empty or have "components", "schemas" depending on how + // the error was structured. Let's try different combinations. + foundNode := findPropertyKeyNodeInYAML(docInfo.RootNode.Content[0], info.PropertyName, []string{"components", "schemas"}) + if foundNode == nil { + // Try without parent location + foundNode = findPropertyKeyNodeInYAML(docInfo.RootNode.Content[0], info.PropertyName, []string{}) + } + if foundNode == nil { + // Try with just components + foundNode = findPropertyKeyNodeInYAML(docInfo.RootNode.Content[0], info.PropertyName, []string{"components"}) + } + + assert.NotNil(t, foundNode, "Should find property key in YAML tree") + if foundNode != nil { + assert.Greater(t, foundNode.Line, 0) + assert.Equal(t, "$defs-atmVolatility_type", foundNode.Value) + } +} + +// TestPropertyLocator_Integration_MultipleInvalidSchemas tests extraction with multiple invalid property names +func TestPropertyLocator_Integration_MultipleInvalidSchemas(t *testing.T) { + // Multiple invalid property names to test recursive cause traversal + spec := `openapi: 3.1.0 +info: + title: Test Multiple Invalid Names + version: 1.0.0 +components: + schemas: + $first-invalid: + type: object + $second-invalid: + type: string + parameters: + $param-invalid: + name: test + in: query + schema: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + valid, errors := ValidateOpenAPIDocument(doc) + assert.False(t, valid) + + // Should find multiple errors + assert.Greater(t, len(errors), 0) + if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { + // Each error should have property info extracted + foundCount := 0 + patternExtractedCount := 0 + noPatternCount := 0 + + for _, sve := range errors[0].SchemaValidationErrors { + if sve.OriginalError != nil { + info := extractPropertyNameFromError(sve.OriginalError) + if info != nil { + foundCount++ + assert.NotEmpty(t, info.PropertyName) + + // Test both branches of pattern extraction + if info.Pattern != "" { + patternExtractedCount++ + assert.NotEmpty(t, info.EnhancedReason) + assert.Contains(t, info.EnhancedReason, "does not match pattern") + } else { + // This covers the else branch in checkErrorForPropertyInfo (line 74-76) + noPatternCount++ + assert.Contains(t, info.EnhancedReason, "invalid propertyName") + } + + // Test extractPatternFromCauses coverage + pattern := extractPatternFromCauses(sve.OriginalError) + // Pattern may or may not be found depending on error structure + _ = pattern + } + } + } + // At least one error should have property info extracted + assert.Greater(t, foundCount, 0, "Should extract property info from at least one error") + } +} + +// TestValidateOpenAPIDocument_Issue726_InvalidPropertyName tests the fix for GitHub issue #726 +// (https://github.com/daveshanley/vacuum/issues/726) +// +// Issue: Invalid spec (not valid against OAS 3 schema) reports errors at line 0:0 +// +// The problem was that when an OpenAPI document contained invalid property names +// (e.g., starting with '$' which doesn't match the required pattern '^[a-zA-Z0-9._-]+$'), +// the validator would correctly identify the error but report it at location 0:0 +// instead of the actual line number where the invalid property was defined. +// +// This test verifies that after the fix, the validator: +// 1. Correctly identifies the invalid property name +// 2. Reports the actual line number (not 0:0) +// 3. Provides an enhanced error message with the property name and pattern +// 4. Populates all relevant fields (FieldName, FieldPath, etc.) +func TestValidateOpenAPIDocument_Issue726_InvalidPropertyName(t *testing.T) { + // This spec has an invalid schema name: $defs-atmVolatility_type + // The '$' at the beginning violates the OpenAPI pattern: ^[a-zA-Z0-9._-]+$ + spec := `openapi: 3.1.0 +info: + title: Test Spec with Invalid Property Name + version: 1.0.0 +components: + schemas: + $defs-atmVolatility_type: + type: object + properties: + volatility: + type: number` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + // Validate the document + valid, errors := ValidateOpenAPIDocument(doc) + + // Should not be valid due to invalid property name + assert.False(t, valid, "Document should not be valid") + assert.Len(t, errors, 1, "Should have exactly one validation error") + + // Check the validation error structure + assert.Len(t, errors[0].SchemaValidationErrors, 1, "Should have exactly one schema validation error") + + sve := errors[0].SchemaValidationErrors[0] + + // CRITICAL: Line and Column should NOT be 0 (this was the bug) + assert.Greater(t, sve.Line, 0, "Line should be greater than 0 (bug fix verification)") + assert.Greater(t, sve.Column, 0, "Column should be greater than 0 (bug fix verification)") + + // The line should point to where $defs-atmVolatility_type is defined (line 7 in this spec) + assert.Equal(t, 7, sve.Line, "Line should point to the invalid property name") + + // Verify the enhanced error message includes the property name and pattern + assert.Contains(t, sve.Reason, "$defs-atmVolatility_type", + "Reason should include the invalid property name") + assert.Contains(t, sve.Reason, "does not match pattern", + "Reason should explain the pattern mismatch") + assert.Contains(t, sve.Reason, "^[a-zA-Z0-9._-]+$", + "Reason should include the required pattern") + + // Verify additional fields are populated correctly + assert.Equal(t, "$defs-atmVolatility_type", sve.FieldName, + "FieldName should be extracted from the error") + assert.Contains(t, sve.FieldPath, "$defs-atmVolatility_type", + "FieldPath should include the property name") + + // Verify OriginalError is preserved for debugging + assert.NotNil(t, sve.OriginalError, "OriginalError should be populated for debugging") +} + +// TestValidateOpenAPIDocument_Issue726_MultipleInvalidPropertyNames tests that the fix +// works correctly when there are multiple invalid property names in the same document. +func TestValidateOpenAPIDocument_Issue726_MultipleInvalidPropertyNames(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test Spec with Multiple Invalid Property Names + version: 1.0.0 +components: + schemas: + $invalid-name-1: + type: object + properties: + field1: + type: string + $invalid-name-2: + type: object + properties: + field2: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + valid, errors := ValidateOpenAPIDocument(doc) + + assert.False(t, valid) + assert.Len(t, errors, 1) + + // Should have errors for both invalid property names + assert.GreaterOrEqual(t, len(errors[0].SchemaValidationErrors), 1, + "Should have at least one schema validation error") + + // Check that all errors have valid line numbers (not 0) + for i, sve := range errors[0].SchemaValidationErrors { + assert.Greater(t, sve.Line, 0, + "Error %d: Line should be greater than 0", i) + } +} + +// TestValidateOpenAPIDocument_Issue726_ValidPropertyNames is a negative test that verifies +// the fix doesn't break validation of valid specs. +func TestValidateOpenAPIDocument_Issue726_ValidPropertyNames(t *testing.T) { + // This spec has valid schema names + spec := `openapi: 3.1.0 +info: + title: Test Spec with Valid Property Names + version: 1.0.0 +components: + schemas: + ValidSchemaName: + type: object + properties: + field1: + type: string + AnotherValidName: + type: object + properties: + field2: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + valid, errors := ValidateOpenAPIDocument(doc) + + // Should be valid + assert.True(t, valid, "Document with valid property names should be valid") + assert.Len(t, errors, 0, "Should have no validation errors") +} + +// TestValidateOpenAPIDocument_Issue726_BackwardCompatibility ensures that the fix +// doesn't break existing error reporting for errors that already had line numbers. +func TestValidateOpenAPIDocument_Issue726_BackwardCompatibility(t *testing.T) { + // This spec has a different type of validation error (missing required field) + // to ensure the fix doesn't break other validation errors + spec := `openapi: 3.1.0 +info: + title: Test Spec` + // version is required but missing + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + valid, errors := ValidateOpenAPIDocument(doc) + + // Should not be valid + assert.False(t, valid) + assert.Greater(t, len(errors), 0) + + // All errors should have valid line numbers + for _, verr := range errors { + for i, sve := range verr.SchemaValidationErrors { + // Line might be 0 for some error types, but that's okay - we're just + // checking that the fix didn't break existing error reporting + assert.GreaterOrEqual(t, sve.Line, 0, + "Error %d: Line should not be negative", i) + } + } +} diff --git a/schema_validation/validate_xml.go b/schema_validation/validate_xml.go new file mode 100644 index 0000000..32be620 --- /dev/null +++ b/schema_validation/validate_xml.go @@ -0,0 +1,166 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" + + xj "github.com/basgys/goxml2json" + + liberrors "github.com/pb33f/libopenapi-validator/errors" + "github.com/pb33f/libopenapi-validator/helpers" +) + +func (x *xmlValidator) validateXMLWithVersion(schema *base.Schema, xmlString string, log *slog.Logger, version float32) (bool, []*liberrors.ValidationError) { + var validationErrors []*liberrors.ValidationError + + if schema == nil { + log.Info("schema is empty and cannot be validated") + return false, validationErrors + } + + // parse xml and transform to json structure matching schema + transformedJSON, err := transformXMLToSchemaJSON(xmlString, schema) + if err != nil { + // XML parsing is a pre-validation error - no SchemaValidationFailure + validationErrors = append(validationErrors, &liberrors.ValidationError{ + ValidationType: helpers.RequestBodyValidation, + ValidationSubType: helpers.Schema, + Message: "xml example is malformed", + Reason: fmt.Sprintf("failed to parse xml: %s", err.Error()), + HowToFix: "ensure xml is well-formed and matches schema structure", + }) + return false, validationErrors + } + + // validate transformed json against schema using existing validator + return x.schemaValidator.validateSchemaWithVersion(schema, nil, transformedJSON, log, version) +} + +// transformXMLToSchemaJSON converts xml to json structure matching openapi schema. +// applies xml object transformations: name, attribute, wrapped. +func transformXMLToSchemaJSON(xmlString string, schema *base.Schema) (interface{}, error) { + if xmlString == "" { + return nil, fmt.Errorf("empty xml content") + } + + // parse xml using goxml2json + jsonBuf, err := xj.Convert(strings.NewReader(xmlString)) + if err != nil { + return nil, fmt.Errorf("malformed xml: %w", err) + } + + // decode to interface{} + var rawJSON interface{} + if err := json.Unmarshal(jsonBuf.Bytes(), &rawJSON); err != nil { + return nil, fmt.Errorf("failed to decode json: %w", err) + } + + // apply openapi xml object transformations + transformed := applyXMLTransformations(rawJSON, schema) + return transformed, nil +} + +// applyXMLTransformations applies openapi xml object rules to match json schema. +// handles xml.name (root unwrapping), xml.attribute (dash prefix), xml.wrapped (array unwrapping). +func applyXMLTransformations(data interface{}, schema *base.Schema) interface{} { + if schema == nil { + return data + } + + // unwrap root element if xml.name is set on schema + if schema.XML != nil && schema.XML.Name != "" { + if dataMap, ok := data.(map[string]interface{}); ok { + if wrapped, exists := dataMap[schema.XML.Name]; exists { + data = wrapped + } + } + } + + // transform properties based on their xml configurations + if dataMap, ok := data.(map[string]interface{}); ok { + if schema.Properties == nil || schema.Properties.Len() == 0 { + return data + } + + transformed := make(map[string]interface{}, schema.Properties.Len()) + + for pair := schema.Properties.First(); pair != nil; pair = pair.Next() { + propName := pair.Key() + propSchemaProxy := pair.Value() + propSchema := propSchemaProxy.Schema() + if propSchema == nil { + continue + } + + // determine xml element name (defaults to property name) + xmlName := propName + if propSchema.XML != nil && propSchema.XML.Name != "" { + xmlName = propSchema.XML.Name + } + + // handle xml.attribute: true - attributes are prefixed with dash + if propSchema.XML != nil && propSchema.XML.Attribute { + attrKey := "-" + xmlName + if val, exists := dataMap[attrKey]; exists { + transformed[propName] = val + continue + } + } + + // handle regular elements + if val, exists := dataMap[xmlName]; exists { + // handle wrapped arrays: unwrap container element + if len(propSchema.Type) > 0 && propSchema.Type[0] == "array" && + propSchema.XML != nil && propSchema.XML.Wrapped { + val = unwrapArrayElement(val, propSchema) + } + + transformed[propName] = val + } + } + + return transformed + } + + return data +} + +// unwrapArrayElement removes wrapping element from xml arrays when xml.wrapped is true. +// example: {"items": {"item": [...]}} becomes [...] +func unwrapArrayElement(val interface{}, propSchema *base.Schema) interface{} { + wrapMap, ok := val.(map[string]interface{}) + if !ok { + return val + } + + // determine item element name + itemName := "item" + if propSchema.Items != nil && propSchema.Items.A != nil { + itemSchema := propSchema.Items.A.Schema() + if itemSchema != nil && itemSchema.XML != nil && itemSchema.XML.Name != "" { + itemName = itemSchema.XML.Name + } + } + + // unwrap: look for item element inside wrapper + if unwrapped, exists := wrapMap[itemName]; exists { + return unwrapped + } + + return val +} + +// IsXMLContentType checks if a media type string represents xml content. +func IsXMLContentType(mediaType string) bool { + mt := strings.ToLower(strings.TrimSpace(mediaType)) + return strings.HasPrefix(mt, "application/xml") || + strings.HasPrefix(mt, "text/xml") || + strings.HasSuffix(mt, "+xml") +} diff --git a/schema_validation/validate_xml_test.go b/schema_validation/validate_xml_test.go new file mode 100644 index 0000000..1d3faad --- /dev/null +++ b/schema_validation/validate_xml_test.go @@ -0,0 +1,999 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "testing" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/stretchr/testify/assert" +) + +func TestValidateXML_Issue346_BasicXMLWithName(t *testing.T) { + spec := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /pet: + get: + responses: + '200': + description: success + content: + application/xml: + schema: + type: object + properties: + nice: + type: string + xml: + name: Cat + example: "true"` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + valid, validationErrors := validator.ValidateXMLString(schema, "true") + + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_MalformedXML(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /pet: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + xml: + name: Cat` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // empty xml should fail + valid, validationErrors := validator.ValidateXMLString(schema, "") + + assert.False(t, valid) + assert.NotEmpty(t, validationErrors) + assert.Contains(t, validationErrors[0].Reason, "empty xml") +} + +func TestValidateXML_WithAttributes(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /pet: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + id: + type: integer + xml: + attribute: true + name: + type: string + xml: + name: Cat` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + valid, validationErrors := validator.ValidateXMLString(schema, `Fluffy`) + + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_TypeValidation(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /pet: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + age: + type: integer + xml: + name: Cat` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pet").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid integer + valid, validationErrors := validator.ValidateXMLString(schema, "5") + assert.True(t, valid) + assert.Len(t, validationErrors, 0) + + // invalid - string instead of integer + valid, validationErrors = validator.ValidateXMLString(schema, "not-a-number") + assert.False(t, valid) + assert.NotEmpty(t, validationErrors) +} + +func TestValidateXML_WrappedArray(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /pets: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + pets: + type: array + xml: + wrapped: true + items: + type: object + properties: + name: + type: string + age: + type: integer + xml: + name: pet + xml: + name: Pets` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/pets").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid wrapped array + validXML := `Fluffy3Spot5` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) + + // invalid - wrong type in array item + invalidXML := `Fluffynot-a-number` + valid, validationErrors = validator.ValidateXMLString(schema, invalidXML) + assert.False(t, valid) + assert.NotEmpty(t, validationErrors) +} + +func TestValidateXML_MultiplePropertiesWithCustomNames(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /user: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + userId: + type: integer + xml: + name: id + userName: + type: string + xml: + name: username + userEmail: + type: string + xml: + name: email + xml: + name: User` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/user").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid xml with custom element names + validXML := `42johndoejohn@example.com` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_MixedAttributesAndElements(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /book: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + id: + type: integer + xml: + attribute: true + isbn: + type: string + xml: + attribute: true + title: + type: string + author: + type: string + price: + type: number + xml: + name: Book` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/book").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid xml with both attributes and elements + validXML := `Go ProgrammingJohn Doe29.99` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_NestedObjects(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /order: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + orderId: + type: integer + customer: + type: object + properties: + name: + type: string + address: + type: object + properties: + street: + type: string + city: + type: string + xml: + name: Order` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/order").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid nested xml + validXML := `123Jane Doe
123 Main StSpringfield
` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_TypeCoercion(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /data: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + intValue: + type: integer + floatValue: + type: number + stringValue: + type: string + boolValue: + type: string + xml: + name: Data` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/data").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // goxml2json should coerce numeric strings to numbers + validXML := `423.14hellotrue` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_SchemaViolations(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /product: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + required: + - productId + - name + properties: + productId: + type: integer + name: + type: string + description: + type: string + xml: + name: Product` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/product").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // missing required property 'name' + invalidXML := `123` + valid, validationErrors := validator.ValidateXMLString(schema, invalidXML) + assert.False(t, valid) + assert.NotEmpty(t, validationErrors) + + // valid - all required properties present + validXML := `123Widget` + valid, validationErrors = validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) + + // valid with optional property + validXML = `123WidgetA useful widget` + valid, validationErrors = validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_ComplexRealWorld_SOAP(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /api: + post: + responses: + '200': + content: + application/soap+xml: + schema: + type: object + properties: + status: + type: string + requestId: + type: string + xml: + attribute: true + timestamp: + type: integer + data: + type: object + properties: + value: + type: string + xml: + name: Response` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/api").Post.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/soap+xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid soap-like xml + validXML := `success1699372800result` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_EmptyAndWhitespace(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /test: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + value: + type: string + xml: + name: Test` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/test").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid xml with whitespace + validXML := ` + hello + ` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) + + // valid xml with empty element + validXML = `` + valid, validationErrors = validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_WithNamespace(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /message: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + subject: + type: string + body: + type: string + xml: + name: Message` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/message").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid xml with namespace (goxml2json strips namespace prefixes) + validXML := `HelloWorld` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_PropertyMismatch(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /config: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + required: + - enabled + - maxRetries + properties: + enabled: + type: boolean + maxRetries: + type: integer + xml: + name: Config` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/config").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // xml has wrong element names (should be 'enabled' and 'maxRetries') + // this should fail because required properties are missing + invalidXML := `true5` + valid, validationErrors := validator.ValidateXMLString(schema, invalidXML) + assert.False(t, valid) + assert.NotEmpty(t, validationErrors) +} + +func TestValidateXML_AttributeTypeMismatch(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /item: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + id: + type: integer + xml: + attribute: true + quantity: + type: integer + xml: + attribute: true + name: + type: string + xml: + name: Item` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/item").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid - attributes are integers + validXML := `Widget` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) + + // invalid - attribute is not an integer + invalidXML := `Widget` + valid, validationErrors = validator.ValidateXMLString(schema, invalidXML) + assert.False(t, valid) + assert.NotEmpty(t, validationErrors) +} + +func TestValidateXML_FloatPrecision(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /measurement: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + temperature: + type: number + humidity: + type: number + pressure: + type: number + xml: + name: Measurement` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/measurement").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // valid xml with float values + validXML := `23.45665.21013.25` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) + + // valid - integers are acceptable for number type + validXML = `23651013` + valid, validationErrors = validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_Version30_WithNullable(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /item: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + value: + type: string + nullable: true + xml: + name: Item` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/item").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // test with version 3.0 - should allow nullable keyword + valid, validationErrors := validator.ValidateXMLStringWithVersion(schema, "test", 3.0) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_NilSchema(t *testing.T) { + validator := NewXMLValidator() + + // test with nil schema - should return false with empty errors + valid, validationErrors := validator.ValidateXMLString(nil, "value") + assert.False(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_NilSchemaInTransformation(t *testing.T) { + // directly test applyXMLTransformations with nil schema (line 94) + result := applyXMLTransformations(map[string]interface{}{"test": "value"}, nil) + assert.NotNil(t, result) + assert.Equal(t, map[string]interface{}{"test": "value"}, result) +} + +func TestValidateXML_TransformWithNilPropertySchemaProxy(t *testing.T) { + // directly test applyXMLTransformations when a property schema proxy returns nil (line 119) + // this can happen with circular refs or unresolved refs in edge cases + + // create a schema with properties but we'll simulate a nil schema scenario + // by testing the transformation directly + data := map[string]interface{}{ + "test": "value", + } + + // schema with properties but no XML config - tests property iteration + schema := &base.Schema{ + Properties: nil, // will trigger line 109 early return + } + + result := applyXMLTransformations(data, schema) + assert.Equal(t, data, result) +} + +func TestValidateXML_NoProperties(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /empty: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + xml: + name: Empty` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/empty").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // schema with no properties should still validate + valid, validationErrors := validator.ValidateXMLString(schema, "value") + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_PrimitiveValue(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /simple: + get: + responses: + '200': + content: + application/xml: + schema: + type: string + xml: + name: Value` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/simple").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // primitive value (non-object) should work + valid, validationErrors := validator.ValidateXMLString(schema, "hello world") + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_ArrayNotWrapped(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /items: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + items: + type: array + items: + type: string + xml: + name: Items` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/items").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // array without wrapped - items are direct siblings + validXML := `onetwothree` + valid, validationErrors := validator.ValidateXMLString(schema, validXML) + assert.True(t, valid) + assert.Len(t, validationErrors, 0) +} + +func TestValidateXML_WrappedArrayWithWrongItemName(t *testing.T) { + spec := `openapi: 3.0.0 +paths: + /collection: + get: + responses: + '200': + content: + application/xml: + schema: + type: object + properties: + data: + type: array + xml: + wrapped: true + items: + type: object + properties: + value: + type: string + xml: + name: record + xml: + name: Collection` + + doc, err := libopenapi.NewDocument([]byte(spec)) + assert.NoError(t, err) + + v3Doc, err := doc.BuildV3Model() + assert.NoError(t, err) + + schema := v3Doc.Model.Paths.PathItems.GetOrZero("/collection").Get.Responses.Codes.GetOrZero("200"). + Content.GetOrZero("application/xml").Schema.Schema() + + validator := NewXMLValidator() + + // wrapper contains items with wrong name (item instead of record) + // this tests the fallback path where unwrapped element is not found + xmlWithWrongItemName := `test` + valid, validationErrors := validator.ValidateXMLString(schema, xmlWithWrongItemName) + + // it should still process (might fail schema validation but won't crash) + _ = valid + assert.NotNil(t, validationErrors) +} + +func TestValidateXML_DirectArrayValue(t *testing.T) { + // test unwrapArrayElement with non-map value (line 160) + schema := &base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{ + A: &base.SchemaProxy{}, + }, + XML: &base.XML{ + Wrapped: true, + }, + } + + // when val is already an array (not a map), it should return as-is + arrayVal := []interface{}{"one", "two", "three"} + result := unwrapArrayElement(arrayVal, schema) + assert.Equal(t, arrayVal, result) +} + +func TestValidateXML_UnwrapArrayElementMissingItem(t *testing.T) { + // test unwrapArrayElement when wrapper map doesn't contain expected item (line 177) + schema := &base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{ + A: &base.SchemaProxy{}, + }, + XML: &base.XML{ + Wrapped: true, + }, + } + + // wrapper map contains wrong key - should return map as-is (line 177) + wrapperMap := map[string]interface{}{"wrongKey": []interface{}{"one", "two"}} + result := unwrapArrayElement(wrapperMap, schema) + assert.Equal(t, wrapperMap, result) +} + +func TestTransformXMLToSchemaJSON_EmptyString(t *testing.T) { + // test empty string error path (line 68) + schema := &base.Schema{} + _, err := transformXMLToSchemaJSON("", schema) + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty xml") +} + +func TestApplyXMLTransformations_NoXMLName(t *testing.T) { + // test schema without xml.name - data stays wrapped + schema := &base.Schema{ + Properties: nil, + } + data := map[string]interface{}{"Cat": map[string]interface{}{"nice": "true"}} + result := applyXMLTransformations(data, schema) + assert.Equal(t, data, result) +} + +func TestIsXMLContentType(t *testing.T) { + tests := []struct { + name string + contentType string + expected bool + }{ + {"application/xml", "application/xml", true}, + {"text/xml", "text/xml", true}, + {"application/soap+xml", "application/soap+xml", true}, + {"application/json", "application/json", false}, + {"text/plain", "text/plain", false}, + {"with whitespace", " application/xml ", true}, + {"mixed case", "APPLICATION/XML", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsXMLContentType(tt.contentType) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/schema_validation/xml_validator.go b/schema_validation/xml_validator.go new file mode 100644 index 0000000..f9b2ef6 --- /dev/null +++ b/schema_validation/xml_validator.go @@ -0,0 +1,59 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "log/slog" + "os" + + "github.com/pb33f/libopenapi/datamodel/high/base" + + "github.com/pb33f/libopenapi-validator/config" + liberrors "github.com/pb33f/libopenapi-validator/errors" +) + +// XMLValidator is an interface that defines methods for validating XML against OpenAPI schemas. +// There are 2 methods for validating XML: +// +// ValidateXMLString validates an XML string against a schema, applying OpenAPI xml object transformations. +// ValidateXMLStringWithVersion - version-aware XML validation that allows OpenAPI 3.0 keywords when version is specified. +type XMLValidator interface { + // ValidateXMLString validates an XML string against an OpenAPI schema, applying xml object transformations. + // Uses OpenAPI 3.1+ validation by default (strict JSON Schema compliance). + ValidateXMLString(schema *base.Schema, xmlString string) (bool, []*liberrors.ValidationError) + + // ValidateXMLStringWithVersion validates an XML string with version-specific rules. + // When version is 3.0, OpenAPI 3.0-specific keywords like 'nullable' are allowed and processed. + // When version is 3.1+, OpenAPI 3.0-specific keywords like 'nullable' will cause validation to fail. + ValidateXMLStringWithVersion(schema *base.Schema, xmlString string, version float32) (bool, []*liberrors.ValidationError) +} + +type xmlValidator struct { + schemaValidator *schemaValidator + logger *slog.Logger +} + +// NewXMLValidatorWithLogger creates a new XMLValidator instance with a custom logger. +func NewXMLValidatorWithLogger(logger *slog.Logger, opts ...config.Option) XMLValidator { + options := config.NewValidationOptions(opts...) + // Create an internal schema validator for JSON validation after XML transformation + sv := &schemaValidator{options: options, logger: logger} + return &xmlValidator{schemaValidator: sv, logger: logger} +} + +// NewXMLValidator creates a new XMLValidator instance with default logging configuration. +func NewXMLValidator(opts ...config.Option) XMLValidator { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelError, + })) + return NewXMLValidatorWithLogger(logger, opts...) +} + +func (x *xmlValidator) ValidateXMLString(schema *base.Schema, xmlString string) (bool, []*liberrors.ValidationError) { + return x.validateXMLWithVersion(schema, xmlString, x.logger, 3.1) +} + +func (x *xmlValidator) ValidateXMLStringWithVersion(schema *base.Schema, xmlString string, version float32) (bool, []*liberrors.ValidationError) { + return x.validateXMLWithVersion(schema, xmlString, x.logger, version) +} From fabe24d591c505041eb995c1e55aae91988b9161 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 14:15:21 -0800 Subject: [PATCH 20/25] Fix upstream merge conflicts and update dependencies - Update goxml2json to v1.1.1-0.20231018121955-e66ee54ceaad (matches upstream) - Restore WithTypeConverter call in validate_xml.go for numeric type conversion - Update property_locator_test.go to use OriginalJsonSchemaError (our convention) --- go.mod | 2 +- go.sum | 46 ++++++++++++++++++++++ schema_validation/property_locator_test.go | 44 ++++++++++----------- schema_validation/validate_xml.go | 6 ++- 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index 6455b7e..736e573 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( require ( github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/basgys/goxml2json v1.1.0 // indirect + github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 492b6c9..ea0532c 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw= github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= @@ -24,18 +28,60 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/schema_validation/property_locator_test.go b/schema_validation/property_locator_test.go index 88b64e4..56b74ad 100644 --- a/schema_validation/property_locator_test.go +++ b/schema_validation/property_locator_test.go @@ -114,13 +114,13 @@ components: if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { sve := errors[0].SchemaValidationErrors[0] - if sve.OriginalError != nil { + if sve.OriginalJsonSchemaError != nil { // Test extractPatternFromCauses directly with the real error - pattern := extractPatternFromCauses(sve.OriginalError) + pattern := extractPatternFromCauses(sve.OriginalJsonSchemaError) assert.NotEmpty(t, pattern, "Should extract pattern from ValidationError") // Also test the info extraction - info := checkErrorForPropertyInfo(sve.OriginalError) + info := checkErrorForPropertyInfo(sve.OriginalJsonSchemaError) assert.NotNil(t, info) assert.Equal(t, "$with-pattern", info.PropertyName) assert.NotEmpty(t, info.Pattern, "Pattern should be extracted from causes") @@ -146,9 +146,9 @@ info: if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { for _, sve := range errors[0].SchemaValidationErrors { - if sve.OriginalError != nil { + if sve.OriginalJsonSchemaError != nil { // Call extractPatternFromCauses - may return empty string for errors without pattern - pattern := extractPatternFromCauses(sve.OriginalError) + pattern := extractPatternFromCauses(sve.OriginalJsonSchemaError) // Pattern might be empty for non-property-name errors (covering line 108) _ = pattern } @@ -241,9 +241,9 @@ components: if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { sve := errors[0].SchemaValidationErrors[0] - if sve.OriginalError != nil { + if sve.OriginalJsonSchemaError != nil { // Test pattern extraction - pattern := extractPatternFromCauses(sve.OriginalError) + pattern := extractPatternFromCauses(sve.OriginalJsonSchemaError) assert.NotEmpty(t, pattern, "Should extract pattern from error") assert.Equal(t, "^[a-zA-Z0-9._-]+$", pattern) } @@ -293,15 +293,15 @@ components: _, errors := ValidateOpenAPIDocument(doc) if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { sve := errors[0].SchemaValidationErrors[0] - if sve.OriginalError != nil { + if sve.OriginalJsonSchemaError != nil { // Test extraction from root error - info := extractPropertyNameFromError(sve.OriginalError) + info := extractPropertyNameFromError(sve.OriginalJsonSchemaError) assert.NotNil(t, info, "Should extract property name from root error") assert.Equal(t, "$direct-test", info.PropertyName) assert.NotEmpty(t, info.EnhancedReason) // Test extractPatternFromCauses on the root error - pattern := extractPatternFromCauses(sve.OriginalError) + pattern := extractPatternFromCauses(sve.OriginalJsonSchemaError) assert.NotEmpty(t, pattern, "Should extract pattern from error message") assert.Equal(t, "^[a-zA-Z0-9._-]+$", pattern) } @@ -343,8 +343,8 @@ paths: } else if len(errors) > 0 && len(errors[0].SchemaValidationErrors) > 0 { // If there are errors, test extraction (might not find property name info) sve := errors[0].SchemaValidationErrors[0] - if sve.OriginalError != nil { - info := extractPropertyNameFromError(sve.OriginalError) + if sve.OriginalJsonSchemaError != nil { + info := extractPropertyNameFromError(sve.OriginalJsonSchemaError) // Info might be nil for non-property-name errors _ = info } @@ -815,9 +815,9 @@ components: assert.Contains(t, sve.FieldPath, "$defs-atmVolatility_type", "FieldPath should include property name") // Original validation check that extractPropertyNameFromError works - assert.NotNil(t, sve.OriginalError, "OriginalError should be populated") + assert.NotNil(t, sve.OriginalJsonSchemaError, "OriginalError should be populated") - info := extractPropertyNameFromError(sve.OriginalError) + info := extractPropertyNameFromError(sve.OriginalJsonSchemaError) // This should successfully extract the property name assert.NotNil(t, info, "Should extract property name info from error") assert.Equal(t, "$defs-atmVolatility_type", info.PropertyName) @@ -827,15 +827,15 @@ components: // Explicitly test checkErrorForPropertyInfo with the root error and causes // to ensure coverage of different code paths - rootInfo := checkErrorForPropertyInfo(sve.OriginalError) - if rootInfo == nil && len(sve.OriginalError.Causes) > 0 { + rootInfo := checkErrorForPropertyInfo(sve.OriginalJsonSchemaError) + if rootInfo == nil && len(sve.OriginalJsonSchemaError.Causes) > 0 { // Check first cause - causeInfo := checkErrorForPropertyInfo(sve.OriginalError.Causes[0]) + causeInfo := checkErrorForPropertyInfo(sve.OriginalJsonSchemaError.Causes[0]) _ = causeInfo } // Explicitly test extractPatternFromCauses to exercise recursive pattern extraction - pattern := extractPatternFromCauses(sve.OriginalError) + pattern := extractPatternFromCauses(sve.OriginalJsonSchemaError) if pattern != "" { assert.Equal(t, "^[a-zA-Z0-9._-]+$", pattern) } @@ -897,8 +897,8 @@ components: noPatternCount := 0 for _, sve := range errors[0].SchemaValidationErrors { - if sve.OriginalError != nil { - info := extractPropertyNameFromError(sve.OriginalError) + if sve.OriginalJsonSchemaError != nil { + info := extractPropertyNameFromError(sve.OriginalJsonSchemaError) if info != nil { foundCount++ assert.NotEmpty(t, info.PropertyName) @@ -915,7 +915,7 @@ components: } // Test extractPatternFromCauses coverage - pattern := extractPatternFromCauses(sve.OriginalError) + pattern := extractPatternFromCauses(sve.OriginalJsonSchemaError) // Pattern may or may not be found depending on error structure _ = pattern } @@ -993,7 +993,7 @@ components: "FieldPath should include the property name") // Verify OriginalError is preserved for debugging - assert.NotNil(t, sve.OriginalError, "OriginalError should be populated for debugging") + assert.NotNil(t, sve.OriginalJsonSchemaError, "OriginalError should be populated for debugging") } // TestValidateOpenAPIDocument_Issue726_MultipleInvalidPropertyNames tests that the fix diff --git a/schema_validation/validate_xml.go b/schema_validation/validate_xml.go index 32be620..a2c82a7 100644 --- a/schema_validation/validate_xml.go +++ b/schema_validation/validate_xml.go @@ -50,8 +50,10 @@ func transformXMLToSchemaJSON(xmlString string, schema *base.Schema) (interface{ return nil, fmt.Errorf("empty xml content") } - // parse xml using goxml2json - jsonBuf, err := xj.Convert(strings.NewReader(xmlString)) + // parse xml using goxml2json with type conversion for numbers only + // note: we convert floats and ints, but not booleans, since xml content + // may legitimately contain "true"/"false" as string values + jsonBuf, err := xj.Convert(strings.NewReader(xmlString), xj.WithTypeConverter(xj.Float, xj.Int)) if err != nil { return nil, fmt.Errorf("malformed xml: %w", err) } From fcb8f64aa484cf03662940d058c976df6aaa8f15 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 14:40:34 -0800 Subject: [PATCH 21/25] Fix linting errors - Remove extra blank lines (gofumpt) - Remove redundant nil checks before len() (staticcheck) --- helpers/json_pointer.go | 1 - helpers/json_pointer_test.go | 1 - parameters/validate_parameter.go | 18 +++++++++--------- parameters/validate_parameter_test.go | 4 ++-- responses/validate_body_test.go | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/helpers/json_pointer.go b/helpers/json_pointer.go index a7bccac..50eecac 100644 --- a/helpers/json_pointer.go +++ b/helpers/json_pointer.go @@ -38,4 +38,3 @@ func ConstructResponseHeaderJSONPointer(pathTemplate, method, statusCode, header method = strings.ToLower(method) return fmt.Sprintf("/paths/%s/%s/responses/%s/headers/%s/%s", escapedPath, method, statusCode, headerName, keyword) } - diff --git a/helpers/json_pointer_test.go b/helpers/json_pointer_test.go index f96eb8a..5e08b2d 100644 --- a/helpers/json_pointer_test.go +++ b/helpers/json_pointer_test.go @@ -147,4 +147,3 @@ func TestConstructResponseHeaderJSONPointer(t *testing.T) { }) } } - diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index ed7d099..132aa39 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -241,20 +241,20 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading ~1 - + // er.KeywordLocation is relative to the schema (e.g., "/minLength" or "/enum") // Prepend the full OpenAPI path keywordLocation = fmt.Sprintf("/paths/%s/%s/parameters/%s/schema%s", escapedPath, strings.ToLower(operation), name, er.KeywordLocation) } - fail := &errors.SchemaValidationFailure{ - Reason: errMsg, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: keywordLocation, - OriginalJsonSchemaError: scErrs, - } + fail := &errors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: keywordLocation, + OriginalJsonSchemaError: scErrs, + } if schema != nil { rendered, err := schema.RenderInline() if err == nil && rendered != nil { diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index 859559e..222155b 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -469,7 +469,7 @@ func TestComplexRegexSchemaCompilationError(t *testing.T) { found := false for _, err := range valErrs { if err.ParameterName == "complexParam" && - (err.SchemaValidationErrors == nil || len(err.SchemaValidationErrors) == 0) { + len(err.SchemaValidationErrors) == 0 { // Schema compilation errors don't have SchemaValidationFailure objects if strings.Contains(err.Reason, "failed to compile JSON schema") { found = true @@ -554,7 +554,7 @@ func TestValidateParameterSchema_SchemaCompilationFailure(t *testing.T) { for _, validationError := range validationErrors { if validationError.ParameterName == "failParam" && validationError.ValidationSubType == helpers.ParameterValidationQuery && - (validationError.SchemaValidationErrors == nil || len(validationError.SchemaValidationErrors) == 0) { + len(validationError.SchemaValidationErrors) == 0 { // Schema compilation errors don't have SchemaValidationFailure objects if strings.Contains(validationError.Reason, "failed to compile JSON schema") { assert.Contains(t, validationError.Reason, "failed to compile JSON schema") diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index c6cc842..6097348 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1488,7 +1488,7 @@ paths: found := false for _, err := range validationErrors { if err.ValidationSubType == helpers.Schema && - (err.SchemaValidationErrors == nil || len(err.SchemaValidationErrors) == 0) { + len(err.SchemaValidationErrors) == 0 { // Schema compilation errors don't have SchemaValidationFailure objects if strings.Contains(err.Reason, "failed to compile JSON schema") { found = true From bf68afede05a4ae2786a2fc469ae495b014f5c78 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 14:48:15 -0800 Subject: [PATCH 22/25] Fix additional gofumpt formatting issues --- responses/validate_response.go | 20 ++++++++++---------- schema_validation/validate_document.go | 20 ++++++++++---------- schema_validation/validate_schema.go | 20 ++++++++++---------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/responses/validate_response.go b/responses/validate_response.go index e35ccd9..f8f104b 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -225,16 +225,16 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors referenceObject = string(responseBody) } - violation := &errors.SchemaValidationFailure{ - Reason: errMsg, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - ReferenceSchema: referenceSchema, - ReferenceObject: referenceObject, - OriginalJsonSchemaError: jk, - } + violation := &errors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + ReferenceSchema: referenceSchema, + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { diff --git a/schema_validation/validate_document.go b/schema_validation/validate_document.go index 60747d8..03a299f 100644 --- a/schema_validation/validate_document.go +++ b/schema_validation/validate_document.go @@ -78,16 +78,16 @@ func ValidateOpenAPIDocument(doc libopenapi.Document, opts ...config.Option) (bo } if errMsg != "" { - // locate the violated property in the schema - located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) - violation := &liberrors.SchemaValidationFailure{ - Reason: errMsg, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - OriginalJsonSchemaError: jk, - } + // locate the violated property in the schema + located := LocateSchemaPropertyNodeByJSONPath(info.RootNode.Content[0], er.InstanceLocation) + violation := &liberrors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index 2c459cb..8db3176 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -281,16 +281,16 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, referenceObject = string(payload) } - violation := &liberrors.SchemaValidationFailure{ - Reason: errMsg, - FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), - FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), - InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), - KeywordLocation: er.KeywordLocation, - ReferenceSchema: string(renderedSchema), - ReferenceObject: referenceObject, - OriginalJsonSchemaError: jk, - } + violation := &liberrors.SchemaValidationFailure{ + Reason: errMsg, + FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation), + FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation), + InstancePath: helpers.ConvertStringLocationToPathSegments(er.InstanceLocation), + KeywordLocation: er.KeywordLocation, + ReferenceSchema: string(renderedSchema), + ReferenceObject: referenceObject, + OriginalJsonSchemaError: jk, + } // if we have a location within the schema, add it to the error if located != nil { line := located.Line From 2141779a05346eb391e7ef2c973c42dd7e057b85 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 15:06:08 -0800 Subject: [PATCH 23/25] Remove duplicate OriginalError field - Keep only OriginalJsonSchemaError (our convention) - OriginalError was mistakenly re-added during upstream merge --- errors/validation_error.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/errors/validation_error.go b/errors/validation_error.go index f0a4919..dd848c7 100644 --- a/errors/validation_error.go +++ b/errors/validation_error.go @@ -51,9 +51,6 @@ type SchemaValidationFailure struct { // The original jsonschema.ValidationError object, if the schema failure originated from the jsonschema library. OriginalJsonSchemaError *jsonschema.ValidationError `json:"-" yaml:"-"` - // OriginalError is an alias for OriginalJsonSchemaError for backwards compatibility - OriginalError *jsonschema.ValidationError `json:"-" yaml:"-"` - // Context is the raw schema object that failed validation (for programmatic access) Context interface{} `json:"-" yaml:"-"` } From 60131251f8fe257f8698a955eea1118e56ee73cd Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 15:17:10 -0800 Subject: [PATCH 24/25] Use ValidateSingleParameterSchemaWithPath consistently - Updated path parameters to use ValidateSingleParameterSchemaWithPath - Updated header parameters to use ValidateSingleParameterSchemaWithPath - Now all parameter types (query, path, header, cookie) get full OpenAPI context - Ensures KeywordLocation is consistent across all parameter validation --- parameters/header_parameters.go | 4 +++- parameters/path_parameters.go | 9 +++++++++ parameters/query_parameters.go | 2 +- parameters/validate_parameter.go | 13 ------------- parameters/validate_parameter_test.go | 2 +- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/parameters/header_parameters.go b/parameters/header_parameters.go index 592d525..78c3a73 100644 --- a/parameters/header_parameters.go +++ b/parameters/header_parameters.go @@ -183,7 +183,9 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, validationErrors = append(validationErrors, ValidateSingleParameterSchema(sch, param, p.Name, - lowbase.SchemaLabel, p.Name, helpers.ParameterValidation, helpers.ParameterValidationHeader, v.options)...) + lowbase.SchemaLabel, p.Name, helpers.ParameterValidation, helpers.ParameterValidationHeader, v.options, + pathValue, + operation)...) } } else { if p.Required != nil && *p.Required { diff --git a/parameters/path_parameters.go b/parameters/path_parameters.go index 78d8339..3522c1d 100644 --- a/parameters/path_parameters.go +++ b/parameters/path_parameters.go @@ -47,6 +47,9 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p submittedSegments := strings.Split(paths.StripRequestPath(request, v.document), helpers.Slash) pathSegments := strings.Split(pathValue, helpers.Slash) + // get the operation method for error reporting + operation := strings.ToLower(request.Method) + // extract params for the operation params := helpers.ExtractParamsForOperation(request, pathItem) var validationErrors []*errors.ValidationError @@ -185,6 +188,8 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p helpers.ParameterValidation, helpers.ParameterValidationPath, v.options, + pathValue, + operation, )...) case helpers.Integer: @@ -208,6 +213,8 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p helpers.ParameterValidation, helpers.ParameterValidationPath, v.options, + pathValue, + operation, )...) case helpers.Number: @@ -231,6 +238,8 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p helpers.ParameterValidation, helpers.ParameterValidationPath, v.options, + pathValue, + operation, )...) case helpers.Boolean: diff --git a/parameters/query_parameters.go b/parameters/query_parameters.go index 91fe90c..8de715c 100644 --- a/parameters/query_parameters.go +++ b/parameters/query_parameters.go @@ -282,7 +282,7 @@ func (v *paramValidator) validateSimpleParam(sch *base.Schema, rawParam string, } } - return ValidateSingleParameterSchemaWithPath( + return ValidateSingleParameterSchema( sch, parsedParam, "Query parameter", diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 132aa39..5e9d2ef 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -33,19 +33,6 @@ func ValidateSingleParameterSchema( validationType string, subValType string, o *config.ValidationOptions, -) (validationErrors []*errors.ValidationError) { - return ValidateSingleParameterSchemaWithPath(schema, rawObject, entity, reasonEntity, name, validationType, subValType, o, "", "") -} - -func ValidateSingleParameterSchemaWithPath( - schema *base.Schema, - rawObject any, - entity string, - reasonEntity string, - name string, - validationType string, - subValType string, - o *config.ValidationOptions, pathTemplate string, operation string, ) (validationErrors []*errors.ValidationError) { diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index 222155b..9c51395 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -18,7 +18,7 @@ import ( func Test_ForceCompilerError(t *testing.T) { // Try to force a panic - result := ValidateSingleParameterSchema(nil, nil, "", "", "", "", "", nil) + result := ValidateSingleParameterSchema(nil, nil, "", "", "", "", "", nil, "", "") // Ideally this would result in an error response, current behavior swallows the error require.Empty(t, result) From d7507970d40328868a22f4671fa5fc7c0e571549 Mon Sep 17 00:00:00 2001 From: Michael Bonifacio Date: Sat, 15 Nov 2025 15:30:21 -0800 Subject: [PATCH 25/25] Use helper for JSON Pointer construction in formatJsonSchemaValidationError --- parameters/validate_parameter.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 5e9d2ef..1f962f0 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -224,14 +224,9 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val // Construct full OpenAPI path for KeywordLocation if pathTemplate and operation are provided keywordLocation := er.KeywordLocation if pathTemplate != "" && operation != "" && validationType == helpers.ParameterValidation { - // Build full OpenAPI path: /paths/{escapedPath}/{operation}/parameters/{paramName}/schema{relativeKeywordLocation} - escapedPath := strings.ReplaceAll(pathTemplate, "~", "~0") - escapedPath = strings.ReplaceAll(escapedPath, "/", "~1") - escapedPath = strings.TrimPrefix(escapedPath, "~1") // Remove leading ~1 - // er.KeywordLocation is relative to the schema (e.g., "/minLength" or "/enum") - // Prepend the full OpenAPI path - keywordLocation = fmt.Sprintf("/paths/%s/%s/parameters/%s/schema%s", escapedPath, strings.ToLower(operation), name, er.KeywordLocation) + keyword := strings.TrimPrefix(er.KeywordLocation, "/") + keywordLocation = helpers.ConstructParameterJSONPointer(pathTemplate, operation, name, keyword) } fail := &errors.SchemaValidationFailure{