From 73f1c464da4028a9fdcfd54ef6aac9f5bcc6ddf6 Mon Sep 17 00:00:00 2001 From: Christian Marbach Date: Fri, 20 Mar 2026 14:19:21 +0100 Subject: [PATCH] feat(validation): add machine-readable Code and Params to ErrorDetail Validation errors previously exposed only a human-readable Message string, making it impossible to programmatically handle specific error types without fragile string parsing. Add Code (snake_case identifier) and Params (constraint parameters) fields to ErrorDetail, along with matching Code* constants in the validation package. All internal call sites are migrated to the new ValidateResult.AddCode() method which threads the code and params through. Both fields are omitempty and fully backwards-compatible. Consumers can now switch on detail.Code and read structured params (e.g. allowed enum values, numeric bounds, required property names) without touching Message. Closes #999 --- error.go | 11 ++ huma_test.go | 12 +- validate.go | 120 ++++++------ validate_test.go | 406 +++++++++++++++++++++++++++++++++++++++++ validation/messages.go | 50 +++++ 5 files changed, 544 insertions(+), 55 deletions(-) diff --git a/error.go b/error.go index f48d801a..e855e757 100644 --- a/error.go +++ b/error.go @@ -28,6 +28,17 @@ type ErrorDetail struct { // the client didn't send extra whitespace or help when the client // did not log an outgoing request. Value any `json:"value,omitempty" doc:"The value at the given location"` + + // Code is a machine-readable identifier for the error type, corresponding + // to one of the validation.Code* constants. It enables programmatic error + // handling without parsing the human-readable Message field. + Code string `json:"code,omitempty" doc:"Machine-readable error code"` + + // Params contains the constraint parameters relevant to the error, e.g. + // {"allowed": ["a","b"]} for a CodeExpectedOneOf error or {"min": 3} for + // a CodeExpectedMinLength error. It is omitted when there are no relevant + // parameters. + Params map[string]any `json:"params,omitempty" doc:"Constraint parameters for the error"` } // Error returns the error message / satisfies the `error` interface. If a diff --git a/huma_test.go b/huma_test.go index 44c257e5..3a4f1d26 100644 --- a/huma_test.go +++ b/huma_test.go @@ -3333,15 +3333,21 @@ func TestExhaustiveErrors(t *testing.T) { { "message": "expected number <= 10", "location": "path.id", - "value": 15 + "value": 15, + "code": "expected_maximum_number", + "params": {"max": 10} }, { "message": "expected number >= 1", "location": "body.count", - "value": -6 + "value": -6, + "code": "expected_minimum_number", + "params": {"min": 1} }, { "message": "expected length <= 10", "location": "body.name", - "value": "12345678901" + "value": "12345678901", + "code": "expected_max_length", + "params": {"max": 10} }, { "message": "input resolver error", "location": "path.id", diff --git a/validate.go b/validate.go index b9b09ae1..6495a647 100644 --- a/validate.go +++ b/validate.go @@ -186,6 +186,22 @@ func (r *ValidateResult) Add(path *PathBuffer, v any, msg string) { }) } +// AddCode adds an error with a machine-readable code and optional constraint +// params to the validation result. Pass nil for params when there are no +// relevant constraint parameters. +func (r *ValidateResult) AddCode(path *PathBuffer, v any, code, msg string, params map[string]any) { + d := &ErrorDetail{ + Message: msg, + Location: path.String(), + Value: v, + Code: code, + } + if len(params) > 0 { + d.Params = params + } + r.Errors = append(r.Errors, d) +} + // Addf adds an error to the validation result at the given path and with // the given value, allowing for fmt.Printf-style formatting. func (r *ValidateResult) Addf(path *PathBuffer, v any, format string, args ...any) { @@ -212,19 +228,19 @@ func validateFormat(path *PathBuffer, str string, s *Schema, res *ValidateResult } } if !found { - res.Add(path, str, validation.MsgExpectedRFC3339DateTime) + res.AddCode(path, str, validation.CodeExpectedRFC3339DateTime, validation.MsgExpectedRFC3339DateTime, nil) } case "date-time-http": if _, err := time.Parse(time.RFC1123, str); err != nil { - res.Add(path, str, validation.MsgExpectedRFC1123DateTime) + res.AddCode(path, str, validation.CodeExpectedRFC1123DateTime, validation.MsgExpectedRFC1123DateTime, nil) } case "date": if _, err := time.Parse(time.DateOnly, str); err != nil { - res.Add(path, str, validation.MsgExpectedRFC3339Date) + res.AddCode(path, str, validation.CodeExpectedRFC3339Date, validation.MsgExpectedRFC3339Date, nil) } case "duration": if _, err := time.ParseDuration(str); err != nil { - res.Add(path, str, ErrorFormatter(validation.MsgExpectedDuration, err)) + res.AddCode(path, str, validation.CodeExpectedDuration, ErrorFormatter(validation.MsgExpectedDuration, err), map[string]any{"error": err.Error()}) } case "time": found := false @@ -235,15 +251,15 @@ func validateFormat(path *PathBuffer, str string, s *Schema, res *ValidateResult } } if !found { - res.Add(path, str, validation.MsgExpectedRFC3339Time) + res.AddCode(path, str, validation.CodeExpectedRFC3339Time, validation.MsgExpectedRFC3339Time, nil) } case "email", "idn-email": if _, err := mail.ParseAddress(str); err != nil { - res.Add(path, str, ErrorFormatter(validation.MsgExpectedRFC5322Email, err)) + res.AddCode(path, str, validation.CodeExpectedRFC5322Email, ErrorFormatter(validation.MsgExpectedRFC5322Email, err), map[string]any{"error": err.Error()}) } case "idn-hostname", "hostname": if len(str) >= 256 || !rxHostname.MatchString(str) { - res.Add(path, str, validation.MsgExpectedRFC5890Hostname) + res.AddCode(path, str, validation.CodeExpectedRFC5890Hostname, validation.MsgExpectedRFC5890Hostname, nil) } case "ipv4", "ipv6", "ip": addr, err := netip.ParseAddr(str) @@ -251,51 +267,51 @@ func validateFormat(path *PathBuffer, str string, s *Schema, res *ValidateResult switch s.Format { case "ipv4": if err != nil || !addr.Is4() { - res.Add(path, str, validation.MsgExpectedRFC2673IPv4) + res.AddCode(path, str, validation.CodeExpectedRFC2673IPv4, validation.MsgExpectedRFC2673IPv4, nil) } case "ipv6": if err != nil || !addr.Is6() || addr.Is4In6() { - res.Add(path, str, validation.MsgExpectedRFC2373IPv6) + res.AddCode(path, str, validation.CodeExpectedRFC2373IPv6, validation.MsgExpectedRFC2373IPv6, nil) } default: // case "ip". if err != nil { - res.Add(path, str, validation.MsgExpectedRFCIPAddr) + res.AddCode(path, str, validation.CodeExpectedRFCIPAddr, validation.MsgExpectedRFCIPAddr, nil) } } // TODO: investigate supporting idn-hostname without external library. // case "idn-hostname": // if _, err := idnaProfile.ToASCII(str); err != nil { - // res.Add(path, str, validation.MsgExpectedRFC5890Hostname) + // res.AddCode(path, str, validation.CodeExpectedRFC5890Hostname, validation.MsgExpectedRFC5890Hostname, nil) // } case "uri", "uri-reference", "iri", "iri-reference": if _, err := url.Parse(str); err != nil { - res.Add(path, str, ErrorFormatter(validation.MsgExpectedRFC3986URI, err)) + res.AddCode(path, str, validation.CodeExpectedRFC3986URI, ErrorFormatter(validation.MsgExpectedRFC3986URI, err), map[string]any{"error": err.Error()}) } // TODO: check if it's actually a reference? case "uri-template": u, err := url.Parse(str) if err != nil { - res.Add(path, str, ErrorFormatter(validation.MsgExpectedRFC3986URI, err)) + res.AddCode(path, str, validation.CodeExpectedRFC3986URI, ErrorFormatter(validation.MsgExpectedRFC3986URI, err), map[string]any{"error": err.Error()}) return } if !rxURITemplate.MatchString(u.Path) { - res.Add(path, str, validation.MsgExpectedRFC6570URITemplate) + res.AddCode(path, str, validation.CodeExpectedRFC6570URITemplate, validation.MsgExpectedRFC6570URITemplate, nil) } case "uuid": if err := validateUUID(str); err != nil { - res.Add(path, str, ErrorFormatter(validation.MsgExpectedRFC4122UUID, err)) + res.AddCode(path, str, validation.CodeExpectedRFC4122UUID, ErrorFormatter(validation.MsgExpectedRFC4122UUID, err), map[string]any{"error": err.Error()}) } case "json-pointer": if !rxJSONPointer.MatchString(str) { - res.Add(path, str, validation.MsgExpectedRFC6901JSONPointer) + res.AddCode(path, str, validation.CodeExpectedRFC6901JSONPointer, validation.MsgExpectedRFC6901JSONPointer, nil) } case "relative-json-pointer": if !rxRelJSONPointer.MatchString(str) { - res.Add(path, str, validation.MsgExpectedRFC6901RelativeJSONPointer) + res.AddCode(path, str, validation.CodeExpectedRFC6901RelativeJSONPointer, validation.MsgExpectedRFC6901RelativeJSONPointer, nil) } case "regex": if _, err := regexp.Compile(str); err != nil { - res.Add(path, str, ErrorFormatter(validation.MsgExpectedRegexp, err)) + res.AddCode(path, str, validation.CodeExpectedRegexp, ErrorFormatter(validation.MsgExpectedRegexp, err), map[string]any{"error": err.Error()}) } } } @@ -314,7 +330,7 @@ func validateOneOf(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v subRes.Reset() } if !found { - res.Add(path, v, validation.MsgExpectedMatchExactlyOneSchema) + res.AddCode(path, v, validation.CodeExpectedMatchExactlyOneSchema, validation.MsgExpectedMatchExactlyOneSchema, nil) } } @@ -330,7 +346,7 @@ func validateAnyOf(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v } if matches == 0 { - res.Add(path, v, validation.MsgExpectedMatchAtLeastOneSchema) + res.AddCode(path, v, validation.CodeExpectedMatchAtLeastOneSchema, validation.MsgExpectedMatchAtLeastOneSchema, nil) } } @@ -348,7 +364,7 @@ func validateDiscriminator(r Registry, s *Schema, path *PathBuffer, mode Validat if !found { path.Push(s.Discriminator.PropertyName) - res.Add(path, v, validation.MsgExpectedPropertyNameInObject) + res.AddCode(path, v, validation.CodeExpectedPropertyNameInObject, validation.MsgExpectedPropertyNameInObject, nil) return } @@ -417,7 +433,7 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, subRes := &ValidateResult{} Validate(r, s.Not, path, mode, v, subRes) if len(subRes.Errors) == 0 { - res.Add(path, v, validation.MsgExpectedNotMatchSchema) + res.AddCode(path, v, validation.CodeExpectedNotMatchSchema, validation.MsgExpectedNotMatchSchema, nil) } } @@ -428,7 +444,7 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, switch s.Type { case TypeBoolean: if _, ok := v.(bool); !ok { - res.Add(path, v, validation.MsgExpectedBoolean) + res.AddCode(path, v, validation.CodeExpectedBoolean, validation.MsgExpectedBoolean, nil) return } case TypeNumber, TypeInteger: @@ -461,40 +477,40 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, num = float64(v) default: if s.Type == TypeInteger { - res.Add(path, v, validation.MsgExpectedInteger) + res.AddCode(path, v, validation.CodeExpectedInteger, validation.MsgExpectedInteger, nil) } else { - res.Add(path, v, validation.MsgExpectedNumber) + res.AddCode(path, v, validation.CodeExpectedNumber, validation.MsgExpectedNumber, nil) } return } if s.Type == TypeInteger && num != math.Trunc(num) { - res.Add(path, v, validation.MsgExpectedInteger) + res.AddCode(path, v, validation.CodeExpectedInteger, validation.MsgExpectedInteger, nil) } if s.Minimum != nil { if num < *s.Minimum { - res.Add(path, v, s.msgMinimum) + res.AddCode(path, v, validation.CodeExpectedMinimumNumber, s.msgMinimum, map[string]any{"min": *s.Minimum}) } } if s.ExclusiveMinimum != nil { if num <= *s.ExclusiveMinimum { - res.Add(path, v, s.msgExclusiveMinimum) + res.AddCode(path, v, validation.CodeExpectedExclusiveMinimumNumber, s.msgExclusiveMinimum, map[string]any{"min": *s.ExclusiveMinimum}) } } if s.Maximum != nil { if num > *s.Maximum { - res.Add(path, v, s.msgMaximum) + res.AddCode(path, v, validation.CodeExpectedMaximumNumber, s.msgMaximum, map[string]any{"max": *s.Maximum}) } } if s.ExclusiveMaximum != nil { if num >= *s.ExclusiveMaximum { - res.Add(path, v, s.msgExclusiveMaximum) + res.AddCode(path, v, validation.CodeExpectedExclusiveMaximumNumber, s.msgExclusiveMaximum, map[string]any{"max": *s.ExclusiveMaximum}) } } if s.MultipleOf != nil { if r := math.Mod(num, *s.MultipleOf); math.Abs(r) > 1e-9 && math.Abs(r-*s.MultipleOf) > 1e-9 { - res.Add(path, v, s.msgMultipleOf) + res.AddCode(path, v, validation.CodeExpectedNumberBeMultipleOf, s.msgMultipleOf, map[string]any{"multiple_of": *s.MultipleOf}) } } case TypeString: @@ -503,26 +519,26 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, if b, ok := v.([]byte); ok { str = *(*string)(unsafe.Pointer(&b)) } else { - res.Add(path, v, validation.MsgExpectedString) + res.AddCode(path, v, validation.CodeExpectedString, validation.MsgExpectedString, nil) return } } if s.MinLength != nil { if utf8.RuneCountInString(str) < *s.MinLength { - res.Add(path, str, s.msgMinLength) + res.AddCode(path, str, validation.CodeExpectedMinLength, s.msgMinLength, map[string]any{"min": *s.MinLength}) } } if s.MaxLength != nil { if utf8.RuneCountInString(str) > *s.MaxLength { - res.Add(path, str, s.msgMaxLength) + res.AddCode(path, str, validation.CodeExpectedMaxLength, s.msgMaxLength, map[string]any{"max": *s.MaxLength}) } } if s.patternRe != nil { if !s.patternRe.MatchString(str) { - res.Add(path, v, s.msgPattern) + res.AddCode(path, v, validation.CodeExpectedBePattern, s.msgPattern, map[string]any{"pattern": s.Pattern}) } } @@ -532,7 +548,7 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, if s.ContentEncoding == "base64" { if !rxBase64.MatchString(str) { - res.Add(path, str, validation.MsgExpectedBase64String) + res.AddCode(path, str, validation.CodeExpectedBase64String, validation.MsgExpectedBase64String, nil) } } case TypeArray: @@ -565,7 +581,7 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, case []float64: handleArray(r, s, path, mode, res, arr) default: - res.Add(path, v, validation.MsgExpectedArray) + res.AddCode(path, v, validation.CodeExpectedArray, validation.MsgExpectedArray, nil) return } case TypeObject: @@ -575,7 +591,7 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, case map[any]any: handleMapAny(r, s, path, mode, vv, res) default: - res.Add(path, v, validation.MsgExpectedObject) + res.AddCode(path, v, validation.CodeExpectedObject, validation.MsgExpectedObject, nil) return } } @@ -583,7 +599,7 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, if len(s.Enum) > 0 { found := slices.Contains(s.Enum, v) if !found { - res.Add(path, v, s.msgEnum) + res.AddCode(path, v, validation.CodeExpectedOneOf, s.msgEnum, map[string]any{"allowed": s.Enum}) } } } @@ -591,12 +607,12 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, func handleArray[T any](r Registry, s *Schema, path *PathBuffer, mode ValidateMode, res *ValidateResult, arr []T) { if s.MinItems != nil { if len(arr) < *s.MinItems { - res.Add(path, arr, s.msgMinItems) + res.AddCode(path, arr, validation.CodeExpectedMinItems, s.msgMinItems, map[string]any{"min": *s.MinItems}) } } if s.MaxItems != nil { if len(arr) > *s.MaxItems { - res.Add(path, arr, s.msgMaxItems) + res.AddCode(path, arr, validation.CodeExpectedMaxItems, s.msgMaxItems, map[string]any{"max": *s.MaxItems}) } } @@ -604,7 +620,7 @@ func handleArray[T any](r Registry, s *Schema, path *PathBuffer, mode ValidateMo seen := make(map[any]struct{}, len(arr)) for _, item := range arr { if _, ok := seen[item]; ok { - res.Add(path, arr, validation.MsgExpectedArrayItemsUnique) + res.AddCode(path, arr, validation.CodeExpectedArrayItemsUnique, validation.MsgExpectedArrayItemsUnique, nil) } seen[item] = struct{}{} } @@ -620,12 +636,12 @@ func handleArray[T any](r Registry, s *Schema, path *PathBuffer, mode ValidateMo func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m map[string]any, res *ValidateResult) { if s.MinProperties != nil { if len(m) < *s.MinProperties { - res.Add(path, m, s.msgMinProperties) + res.AddCode(path, m, validation.CodeExpectedMinProperties, s.msgMinProperties, map[string]any{"min": *s.MinProperties}) } } if s.MaxProperties != nil { if len(m) > *s.MaxProperties { - res.Add(path, m, s.msgMaxProperties) + res.AddCode(path, m, validation.CodeExpectedMaxProperties, s.msgMaxProperties, map[string]any{"max": *s.MaxProperties}) } } @@ -674,7 +690,7 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, // These are not required for the current mode. continue } - res.Add(path, m, s.msgRequired[k]) + res.AddCode(path, m, validation.CodeExpectedRequiredProperty, s.msgRequired[k], map[string]any{"property": k}) continue } @@ -690,7 +706,7 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, continue } - res.Add(path, m, s.msgDependentRequired[k][dependent]) + res.AddCode(path, m, validation.CodeExpectedDependentRequiredProperty, s.msgDependentRequired[k][dependent], map[string]any{"property": dependent, "dependent_on": k}) } } @@ -714,7 +730,7 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, } path.Push(k) - res.Add(path, m, validation.MsgUnexpectedProperty) + res.AddCode(path, m, validation.CodeUnexpectedProperty, validation.MsgUnexpectedProperty, nil) path.Pop() } } @@ -737,12 +753,12 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m map[any]any, res *ValidateResult) { if s.MinProperties != nil { if len(m) < *s.MinProperties { - res.Add(path, m, s.msgMinProperties) + res.AddCode(path, m, validation.CodeExpectedMinProperties, s.msgMinProperties, map[string]any{"min": *s.MinProperties}) } } if s.MaxProperties != nil { if len(m) > *s.MaxProperties { - res.Add(path, m, s.msgMaxProperties) + res.AddCode(path, m, validation.CodeExpectedMaxProperties, s.msgMaxProperties, map[string]any{"max": *s.MaxProperties}) } } @@ -778,7 +794,7 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m // These are not required for the current mode. continue } - res.Add(path, m, s.msgRequired[k]) + res.AddCode(path, m, validation.CodeExpectedRequiredProperty, s.msgRequired[k], map[string]any{"property": k}) continue } @@ -794,7 +810,7 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m continue } - res.Add(path, m, s.msgDependentRequired[k][dependent]) + res.AddCode(path, m, validation.CodeExpectedDependentRequiredProperty, s.msgDependentRequired[k][dependent], map[string]any{"property": dependent, "dependent_on": k}) } } @@ -814,7 +830,7 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m } if _, ok := s.Properties[kStr]; !ok { path.Push(kStr) - res.Add(path, m, validation.MsgUnexpectedProperty) + res.AddCode(path, m, validation.CodeUnexpectedProperty, validation.MsgUnexpectedProperty, nil) path.Pop() } } diff --git a/validate_test.go b/validate_test.go index 38723eb9..c2d6aa44 100644 --- a/validate_test.go +++ b/validate_test.go @@ -1478,6 +1478,412 @@ func TestValidate(t *testing.T) { } } +type errorCodeTest struct { + name string + typ reflect.Type + s *huma.Schema + input any + mode huma.ValidateMode + code string + params map[string]any +} + +var validateErrorCodeTests = []errorCodeTest{ + // Type checks + { + name: "bool type error", + typ: reflect.TypeFor[bool](), + input: "not-a-bool", + code: validation.CodeExpectedBoolean, + }, + { + name: "integer type error", + typ: reflect.TypeFor[int](), + input: "not-an-int", + code: validation.CodeExpectedInteger, + }, + { + name: "number type error", + typ: reflect.TypeFor[float64](), + input: "not-a-number", + code: validation.CodeExpectedNumber, + }, + { + name: "integer non-whole float error", + typ: reflect.TypeFor[int](), + input: 3.5, + code: validation.CodeExpectedInteger, + }, + { + name: "string type error", + typ: reflect.TypeFor[string](), + input: 42, + code: validation.CodeExpectedString, + }, + { + name: "array type error", + typ: reflect.TypeFor[[]string](), + input: "not-an-array", + code: validation.CodeExpectedArray, + }, + { + name: "object type error", + typ: reflect.TypeFor[struct { + V string `json:"v"` + }](), + input: "not-an-object", + code: validation.CodeExpectedObject, + }, + // Numeric constraints + { + name: "minimum number", + typ: reflect.TypeFor[struct { + V float64 `json:"v" minimum:"5"` + }](), + input: map[string]any{"v": 3.0}, + code: validation.CodeExpectedMinimumNumber, + params: map[string]any{"min": 5.0}, + }, + { + name: "exclusive minimum number", + typ: reflect.TypeFor[struct { + V float64 `json:"v" exclusiveMinimum:"5"` + }](), + input: map[string]any{"v": 5.0}, + code: validation.CodeExpectedExclusiveMinimumNumber, + params: map[string]any{"min": 5.0}, + }, + { + name: "maximum number", + typ: reflect.TypeFor[struct { + V float64 `json:"v" maximum:"10"` + }](), + input: map[string]any{"v": 15.0}, + code: validation.CodeExpectedMaximumNumber, + params: map[string]any{"max": 10.0}, + }, + { + name: "exclusive maximum number", + typ: reflect.TypeFor[struct { + V float64 `json:"v" exclusiveMaximum:"10"` + }](), + input: map[string]any{"v": 10.0}, + code: validation.CodeExpectedExclusiveMaximumNumber, + params: map[string]any{"max": 10.0}, + }, + { + name: "multiple of", + typ: reflect.TypeFor[struct { + V float64 `json:"v" multipleOf:"3"` + }](), + input: map[string]any{"v": 7.0}, + code: validation.CodeExpectedNumberBeMultipleOf, + params: map[string]any{"multiple_of": 3.0}, + }, + // String constraints + { + name: "min length", + typ: reflect.TypeFor[struct { + V string `json:"v" minLength:"5"` + }](), + input: map[string]any{"v": "hi"}, + code: validation.CodeExpectedMinLength, + params: map[string]any{"min": 5}, + }, + { + name: "max length", + typ: reflect.TypeFor[struct { + V string `json:"v" maxLength:"3"` + }](), + input: map[string]any{"v": "toolong"}, + code: validation.CodeExpectedMaxLength, + params: map[string]any{"max": 3}, + }, + { + name: "pattern description", + typ: reflect.TypeFor[struct { + V string `json:"v" pattern:"^[a-z]+$" patternDescription:"lowercase letters"` + }](), + input: map[string]any{"v": "ABC"}, + code: validation.CodeExpectedBePattern, + params: map[string]any{"pattern": "^[a-z]+$"}, + }, + { + name: "base64", + typ: reflect.TypeFor[struct { + V string `json:"v" encoding:"base64"` + }](), + input: map[string]any{"v": "not!!base64"}, + code: validation.CodeExpectedBase64String, + }, + // Array constraints + { + name: "min items", + typ: reflect.TypeFor[struct { + V []string `json:"v" minItems:"2"` + }](), + input: map[string]any{"v": []any{"one"}}, + code: validation.CodeExpectedMinItems, + params: map[string]any{"min": 2}, + }, + { + name: "max items", + typ: reflect.TypeFor[struct { + V []string `json:"v" maxItems:"2"` + }](), + input: map[string]any{"v": []any{"a", "b", "c"}}, + code: validation.CodeExpectedMaxItems, + params: map[string]any{"max": 2}, + }, + { + name: "unique items", + typ: reflect.TypeFor[struct { + V []string `json:"v" uniqueItems:"true"` + }](), + input: map[string]any{"v": []any{"a", "b", "a"}}, + code: validation.CodeExpectedArrayItemsUnique, + }, + // Object constraints + { + name: "min properties", + s: &huma.Schema{ + Type: huma.TypeObject, + MinProperties: Ptr(2), + }, + input: map[string]any{"a": 1}, + code: validation.CodeExpectedMinProperties, + params: map[string]any{"min": 2}, + }, + { + name: "max properties", + s: &huma.Schema{ + Type: huma.TypeObject, + MaxProperties: Ptr(1), + }, + input: map[string]any{"a": 1, "b": 2}, + code: validation.CodeExpectedMaxProperties, + params: map[string]any{"max": 1}, + }, + { + name: "required property", + typ: reflect.TypeFor[struct { + V string `json:"v"` + }](), + input: map[string]any{}, + code: validation.CodeExpectedRequiredProperty, + params: map[string]any{"property": "v"}, + }, + { + name: "dependent required property", + typ: reflect.TypeFor[struct { + Value string `json:"value,omitempty" dependentRequired:"dep"` + Dep string `json:"dep,omitempty"` + }](), + input: map[string]any{"value": "set"}, + code: validation.CodeExpectedDependentRequiredProperty, + params: map[string]any{"property": "dep", "dependent_on": "value"}, + }, + { + name: "unexpected property", + typ: reflect.TypeFor[struct { + V string `json:"v,omitempty"` + }](), + input: map[string]any{"unknown": "field"}, + code: validation.CodeUnexpectedProperty, + }, + // Enum + { + name: "enum string", + typ: reflect.TypeFor[struct { + V string `json:"v" enum:"foo,bar"` + }](), + input: map[string]any{"v": "baz"}, + code: validation.CodeExpectedOneOf, + params: map[string]any{"allowed": []any{"foo", "bar"}}, + }, + // Schema combinators + { + name: "anyOf fail", + s: &huma.Schema{ + AnyOf: []*huma.Schema{ + {Type: huma.TypeBoolean}, + {Type: huma.TypeString}, + }, + }, + input: 123, + code: validation.CodeExpectedMatchAtLeastOneSchema, + }, + { + name: "oneOf fail none", + s: &huma.Schema{ + OneOf: []*huma.Schema{ + {Type: huma.TypeBoolean}, + {Type: huma.TypeString}, + }, + }, + input: 123, + code: validation.CodeExpectedMatchExactlyOneSchema, + }, + { + name: "not schema fail", + s: &huma.Schema{Not: &huma.Schema{Type: huma.TypeString}}, + input: "hello", + code: validation.CodeExpectedNotMatchSchema, + }, + // Format validation + { + name: "format date-time", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"date-time"` + }](), + input: map[string]any{"v": "not-a-datetime"}, + code: validation.CodeExpectedRFC3339DateTime, + }, + { + name: "format date", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"date"` + }](), + input: map[string]any{"v": "not-a-date"}, + code: validation.CodeExpectedRFC3339Date, + }, + { + name: "format time", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"time"` + }](), + input: map[string]any{"v": "not-a-time"}, + code: validation.CodeExpectedRFC3339Time, + }, + { + name: "format email", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"email"` + }](), + input: map[string]any{"v": "not-an-email"}, + code: validation.CodeExpectedRFC5322Email, + }, + { + name: "format hostname", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"hostname"` + }](), + input: map[string]any{"v": strings.Repeat("a", 300)}, + code: validation.CodeExpectedRFC5890Hostname, + }, + { + name: "format ipv4", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"ipv4"` + }](), + input: map[string]any{"v": "999.999.999.999"}, + code: validation.CodeExpectedRFC2673IPv4, + }, + { + name: "format ipv6", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"ipv6"` + }](), + input: map[string]any{"v": "not-ipv6"}, + code: validation.CodeExpectedRFC2373IPv6, + }, + { + name: "format ip", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"ip"` + }](), + input: map[string]any{"v": "not-an-ip"}, + code: validation.CodeExpectedRFCIPAddr, + }, + { + name: "format uuid", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"uuid"` + }](), + input: map[string]any{"v": "not-a-uuid"}, + code: validation.CodeExpectedRFC4122UUID, + }, + { + name: "format json-pointer", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"json-pointer"` + }](), + input: map[string]any{"v": "no-leading-slash"}, + code: validation.CodeExpectedRFC6901JSONPointer, + }, + { + name: "format relative-json-pointer", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"relative-json-pointer"` + }](), + input: map[string]any{"v": "not-relative"}, + code: validation.CodeExpectedRFC6901RelativeJSONPointer, + }, + { + name: "format regex", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"regex"` + }](), + input: map[string]any{"v": "[invalid"}, + code: validation.CodeExpectedRegexp, + }, + { + name: "format uri-template", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"uri-template"` + }](), + input: map[string]any{"v": "http://example.com/{unclosed"}, + code: validation.CodeExpectedRFC6570URITemplate, + }, + { + name: "format duration", + typ: reflect.TypeFor[struct { + V string `json:"v" format:"duration"` + }](), + input: map[string]any{"v": "not-a-duration"}, + code: validation.CodeExpectedDuration, + }, +} + +func TestValidateErrorCodes(t *testing.T) { + pb := huma.NewPathBuffer([]byte(""), 0) + res := &huma.ValidateResult{} + + for _, tc := range validateErrorCodeTests { + t.Run(tc.name, func(t *testing.T) { + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + + var s *huma.Schema + if tc.s != nil { + s = tc.s + s.PrecomputeMessages() + } else { + s = registry.Schema(tc.typ, true, "TestInput") + } + + pb.Reset() + res.Reset() + huma.Validate(registry, s, pb, tc.mode, tc.input, res) + + require.NotEmpty(t, res.Errors, "expected at least one validation error") + + // Find the error with the expected code. + var found *huma.ErrorDetail + for _, e := range res.Errors { + if d, ok := e.(*huma.ErrorDetail); ok && d.Code == tc.code { + found = d + break + } + } + require.NotNilf(t, found, "no error with code %q found in %v", tc.code, res.Errors) + + for k, want := range tc.params { + assert.Equalf(t, want, found.Params[k], "param %q mismatch", k) + } + }) + } +} + func TestValidateCustomFormatter(t *testing.T) { originalFormatter := huma.ErrorFormatter defer func() { diff --git a/validation/messages.go b/validation/messages.go index bb8f16ab..129689f5 100644 --- a/validation/messages.go +++ b/validation/messages.go @@ -1,5 +1,55 @@ package validation +// List of built-in validation error codes. Each code corresponds to a Msg* +// variable and can be used for machine-readable error handling. +var ( + CodeUnexpectedProperty = "unexpected_property" + CodeExpectedRFC3339DateTime = "expected_rfc3339_date_time" + CodeExpectedRFC1123DateTime = "expected_rfc1123_date_time" + CodeExpectedRFC3339Date = "expected_rfc3339_date" + CodeExpectedRFC3339Time = "expected_rfc3339_time" + CodeExpectedRFC5322Email = "expected_rfc5322_email" + CodeExpectedRFC5890Hostname = "expected_rfc5890_hostname" + CodeExpectedRFC2673IPv4 = "expected_rfc2673_ipv4" + CodeExpectedRFC2373IPv6 = "expected_rfc2373_ipv6" + CodeExpectedRFCIPAddr = "expected_rfc_ip_addr" + CodeExpectedRFC3986URI = "expected_rfc3986_uri" + CodeExpectedRFC4122UUID = "expected_rfc4122_uuid" + CodeExpectedRFC6570URITemplate = "expected_rfc6570_uri_template" + CodeExpectedRFC6901JSONPointer = "expected_rfc6901_json_pointer" + CodeExpectedRFC6901RelativeJSONPointer = "expected_rfc6901_relative_json_pointer" + CodeExpectedRegexp = "expected_regexp" + CodeExpectedMatchAtLeastOneSchema = "expected_match_at_least_one_schema" + CodeExpectedMatchExactlyOneSchema = "expected_match_exactly_one_schema" + CodeExpectedNotMatchSchema = "expected_not_match_schema" + CodeExpectedPropertyNameInObject = "expected_property_name_in_object" + CodeExpectedBoolean = "expected_boolean" + CodeExpectedDuration = "expected_duration" + CodeExpectedNumber = "expected_number" + CodeExpectedInteger = "expected_integer" + CodeExpectedString = "expected_string" + CodeExpectedBase64String = "expected_base64_string" + CodeExpectedArray = "expected_array" + CodeExpectedObject = "expected_object" + CodeExpectedArrayItemsUnique = "expected_array_items_unique" + CodeExpectedOneOf = "expected_one_of" + CodeExpectedMinimumNumber = "expected_minimum_number" + CodeExpectedExclusiveMinimumNumber = "expected_exclusive_minimum_number" + CodeExpectedMaximumNumber = "expected_maximum_number" + CodeExpectedExclusiveMaximumNumber = "expected_exclusive_maximum_number" + CodeExpectedNumberBeMultipleOf = "expected_number_multiple_of" + CodeExpectedMinLength = "expected_min_length" + CodeExpectedMaxLength = "expected_max_length" + CodeExpectedBePattern = "expected_be_pattern" + CodeExpectedMatchPattern = "expected_match_pattern" + CodeExpectedMinItems = "expected_min_items" + CodeExpectedMaxItems = "expected_max_items" + CodeExpectedMinProperties = "expected_min_properties" + CodeExpectedMaxProperties = "expected_max_properties" + CodeExpectedRequiredProperty = "expected_required_property" + CodeExpectedDependentRequiredProperty = "expected_dependent_required_property" +) + // List of built-in validation error messages. var ( MsgUnexpectedProperty = "unexpected property"