apimachinery/field: Add MarkDeclarativeNative support to Error and ErrorList

This commit is contained in:
Lalit Chauhan
2026-01-06 21:23:52 +00:00
parent 758ef0ffbc
commit 19fa5ef5eb
3 changed files with 144 additions and 11 deletions

View File

@@ -44,6 +44,7 @@ type ErrorMatcher struct {
matchOrigin bool
matchDetail func(want, got string) bool
requireOriginWhenInvalid bool
matchDeclarativeNative bool
// normalizationRules holds the pre-compiled regex patterns for path normalization.
normalizationRules []NormalizationRule
}
@@ -86,6 +87,10 @@ func (m ErrorMatcher) Matches(want, got *Error) bool {
if m.matchDetail != nil && !m.matchDetail(want.Detail, got.Detail) {
return false
}
if m.matchDeclarativeNative && want.DeclarativeNative != got.DeclarativeNative {
return false
}
return true
}
@@ -148,6 +153,10 @@ func (m ErrorMatcher) Render(e *Error) string {
comma()
buf.WriteString(fmt.Sprintf("Detail=%q", e.Detail))
}
if m.matchDeclarativeNative {
comma()
buf.WriteString(fmt.Sprintf("DeclarativeNative=%t", e.DeclarativeNative))
}
return "{" + buf.String() + "}"
}
@@ -224,6 +233,13 @@ func (m ErrorMatcher) RequireOriginWhenInvalid() ErrorMatcher {
return m
}
// ByDeclarativeNative returns a derived ErrorMatcher which also matches by the DeclarativeNative
// value of field errors.
func (m ErrorMatcher) ByDeclarativeNative() ErrorMatcher {
m.matchDeclarativeNative = true
return m
}
// ByDetailExact returns a derived ErrorMatcher which also matches errors by
// the exact detail string.
func (m ErrorMatcher) ByDetailExact() ErrorMatcher {

View File

@@ -303,6 +303,26 @@ func TestErrorMatcher_Matches(t *testing.T) {
wantedErr: baseErr,
actualErr: &Error{Type: ErrorTypeRequired, Field: "field", BadValue: "value", Detail: "detail", Origin: "origin"},
matches: false,
}, {
name: "ByDeclarativeNative: match",
matcher: ErrorMatcher{}.ByDeclarativeNative(),
wantedErr: func() *Error {
e := baseErr()
e.DeclarativeNative = true
return e
},
actualErr: &Error{DeclarativeNative: true},
matches: true,
}, {
name: "ByDeclarativeNative: no match",
matcher: ErrorMatcher{}.ByDeclarativeNative(),
wantedErr: func() *Error {
e := baseErr()
e.DeclarativeNative = true
return e
},
actualErr: &Error{DeclarativeNative: false},
matches: false,
}, {
name: "RequireOriginWhenInvalid: match",
matcher: ErrorMatcher{}.ByOrigin().RequireOriginWhenInvalid(),
@@ -406,6 +426,17 @@ func TestErrorMatcher_Test(t *testing.T) {
want: ErrorList{Invalid(NewPath("f").Index(0).Child("x", "a"), nil, "")},
got: ErrorList{Invalid(NewPath("f").Index(1).Child("a"), "v", "d")},
expectedErrors: []string{"expected an error matching:", "unmatched error:"},
}, {
name: "declarative native: match",
matcher: ErrorMatcher{}.ByDeclarativeNative(),
want: ErrorList{{DeclarativeNative: true}},
got: ErrorList{{DeclarativeNative: true}},
}, {
name: "declarative native: no match",
matcher: ErrorMatcher{}.ByDeclarativeNative(),
want: ErrorList{{DeclarativeNative: true}},
got: ErrorList{{DeclarativeNative: false}},
expectedErrors: []string{"expected an error matching:", "unmatched error:"},
}, {
name: "with origin: single match",
matcher: ErrorMatcher{}.ByField().ByOrigin(),
@@ -533,6 +564,25 @@ func TestErrorMatcher_Render(t *testing.T) {
expected: `{Field="f[0].x.a"}`,
},
{
name: "with covered by declarative",
matcher: ErrorMatcher{}.ByDeclarativeNative(),
err: func() *Error {
e := Invalid(NewPath("field"), "value", "detail")
e.DeclarativeNative = true
return e
}(),
expected: `{DeclarativeNative=true}`,
},
{
name: "all fields with covered by declarative",
matcher: ErrorMatcher{}.ByType().ByField().ByValue().ByOrigin().ByDetailExact().ByDeclarativeNative(),
err: func() *Error {
e := Invalid(NewPath("field"), "value", "detail").WithOrigin("origin")
e.DeclarativeNative = true
return e
}(),
expected: `{Type="Invalid value", Field="field", Value="value", Origin="origin", Detail="detail", DeclarativeNative=true}`,
}, {
name: "requireOriginWhenInvalid with origin",
matcher: ErrorMatcher{}.ByOrigin().RequireOriginWhenInvalid(),
err: Invalid(NewPath("field"), "value", "detail").WithOrigin("origin"),

View File

@@ -42,7 +42,7 @@ type Error struct {
// The value should be either:
// - A simple camelCase identifier (e.g., "maximum", "maxItems")
// - A structured format using "format=<dash-style-identifier>" for validation errors related to specific formats
// (e.g., "format=dns-label", "format=qualified-name")
// (e.g. "format=k8s-short-name")
//
// If the Origin corresponds to an existing declarative validation tag or JSON Schema keyword,
// use that same name for consistency.
@@ -55,6 +55,10 @@ type Error struct {
// validation. This field is to identify errors from imperative validation
// that should also be caught by declarative validation.
CoveredByDeclarative bool
// DeclarativeNative is true when this error originates from a declarative-native validation.
// This field is used to distinguish errors that are exclusively declarative and lack an imperative counterpart.
DeclarativeNative bool
}
var _ error = &Error{}
@@ -201,32 +205,56 @@ func (t ErrorType) String() string {
// TypeInvalid returns a *Error indicating "type is invalid"
func TypeInvalid(field *Path, value interface{}, detail string) *Error {
return &Error{ErrorTypeTypeInvalid, field.String(), value, detail, "", false}
return &Error{
Type: ErrorTypeTypeInvalid,
Field: field.String(),
BadValue: value,
Detail: detail,
}
}
// NotFound returns a *Error indicating "value not found". This is
// used to report failure to find a requested value (e.g. looking up an ID).
func NotFound(field *Path, value interface{}) *Error {
return &Error{ErrorTypeNotFound, field.String(), value, "", "", false}
return &Error{
Type: ErrorTypeNotFound,
Field: field.String(),
BadValue: value,
}
}
// Required returns a *Error indicating "value required". This is used
// to report required values that are not provided (e.g. empty strings, null
// values, or empty arrays).
func Required(field *Path, detail string) *Error {
return &Error{ErrorTypeRequired, field.String(), "", detail, "", false}
return &Error{
Type: ErrorTypeRequired,
Field: field.String(),
Detail: detail,
BadValue: "",
}
}
// Duplicate returns a *Error indicating "duplicate value". This is
// used to report collisions of values that must be unique (e.g. names or IDs).
func Duplicate(field *Path, value interface{}) *Error {
return &Error{ErrorTypeDuplicate, field.String(), value, "", "", false}
return &Error{
Type: ErrorTypeDuplicate,
Field: field.String(),
BadValue: value,
}
}
// Invalid returns a *Error indicating "invalid value". This is used
// to report malformed values (e.g. failed regex match, too long, out of bounds).
func Invalid(field *Path, value interface{}, detail string) *Error {
return &Error{ErrorTypeInvalid, field.String(), value, detail, "", false}
return &Error{
Type: ErrorTypeInvalid,
Field: field.String(),
BadValue: value,
Detail: detail,
}
}
// NotSupported returns a *Error indicating "unsupported value".
@@ -241,7 +269,12 @@ func NotSupported[T ~string](field *Path, value interface{}, validValues []T) *E
}
detail = "supported values: " + strings.Join(quotedValues, ", ")
}
return &Error{ErrorTypeNotSupported, field.String(), value, detail, "", false}
return &Error{
Type: ErrorTypeNotSupported,
Field: field.String(),
BadValue: value,
Detail: detail,
}
}
// Forbidden returns a *Error indicating "forbidden". This is used to
@@ -249,7 +282,12 @@ func NotSupported[T ~string](field *Path, value interface{}, validValues []T) *E
// some conditions, but which are not permitted by current conditions (e.g.
// security policy).
func Forbidden(field *Path, detail string) *Error {
return &Error{ErrorTypeForbidden, field.String(), "", detail, "", false}
return &Error{
Type: ErrorTypeForbidden,
Field: field.String(),
Detail: detail,
BadValue: "",
}
}
// TooLong returns a *Error indicating "too long". This is used to report that
@@ -267,7 +305,12 @@ func TooLong(field *Path, _ interface{}, maxLength int) *Error {
} else {
msg = "value is too long"
}
return &Error{ErrorTypeTooLong, field.String(), "<value omitted>", msg, "", false}
return &Error{
Type: ErrorTypeTooLong,
Field: field.String(),
BadValue: "<value omitted>",
Detail: msg,
}
}
// TooLongMaxLength returns a *Error indicating "too long".
@@ -299,14 +342,24 @@ func TooMany(field *Path, actualQuantity, maxQuantity int) *Error {
actual = omitValue
}
return &Error{ErrorTypeTooMany, field.String(), actual, msg, "", false}
return &Error{
Type: ErrorTypeTooMany,
Field: field.String(),
BadValue: actual,
Detail: msg,
}
}
// InternalError returns a *Error indicating "internal error". This is used
// to signal that an error was found that was not directly related to user
// input. The err argument must be non-nil.
func InternalError(field *Path, err error) *Error {
return &Error{ErrorTypeInternal, field.String(), nil, err.Error(), "", false}
return &Error{
Type: ErrorTypeInternal,
Field: field.String(),
BadValue: err,
Detail: err.Error(),
}
}
// ErrorList holds a set of Errors. It is plausible that we might one day have
@@ -397,6 +450,20 @@ func (list ErrorList) ExtractCoveredByDeclarative() ErrorList {
return newList
}
// MarkDeclarativeNative marks the error as originating from a declarative-native validation.
func (e *Error) MarkDeclarativeNative() *Error {
e.DeclarativeNative = true
return e
}
// MarkDeclarativeNative marks all errors in the list as originating from declarative-native validations.
func (list ErrorList) MarkDeclarativeNative() ErrorList {
for _, err := range list {
err.DeclarativeNative = true
}
return list
}
// RemoveCoveredByDeclarative returns a new ErrorList containing only the errors that should not be covered by declarative validation.
func (list ErrorList) RemoveCoveredByDeclarative() ErrorList {
newList := ErrorList{}