diff --git a/validate.go b/validate.go index 03487d9c..50de8d07 100644 --- a/validate.go +++ b/validate.go @@ -35,6 +35,13 @@ const ( ModeWriteToServer ) +// ValidateStrictCasing controls whether or not field names are case-sensitive +// during validation. This is useful for clients that may send fields in a +// different case than expected by the server. For example, a legacy client may +// send `{"Foo": "bar"}` when the server expects `{"foo": "bar"}`. This is +// disabled by default to match Go's JSON unmarshaling behavior. +var ValidateStrictCasing = false + var rxHostname = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`) var rxURITemplate = regexp.MustCompile("^([^{]*({[^}]*})?)*$") var rxJSONPointer = regexp.MustCompile("^(?:/(?:[^~/]|~0|~1)*)*$") @@ -609,7 +616,20 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, continue } - if _, ok := m[k]; !ok { + actualKey := k + _, ok := m[k] + if !ok && !ValidateStrictCasing { + for actual := range m { + if strings.EqualFold(actual, k) { + // Case-insensitive match found, so this is not an error. + actualKey = actual + ok = true + break + } + } + } + + if !ok { if !s.requiredMap[k] { continue } @@ -622,13 +642,13 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, continue } - if m[k] == nil && (!s.requiredMap[k] || s.Nullable) { + if m[actualKey] == nil && (!s.requiredMap[k] || s.Nullable) { // This is a non-required field which is null, or a nullable field set // to null, so ignore it. continue } - if m[k] != nil && s.DependentRequired[k] != nil { + if m[actualKey] != nil && s.DependentRequired[k] != nil { for _, dependent := range s.DependentRequired[k] { if m[dependent] != nil { continue @@ -639,14 +659,24 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, } path.Push(k) - Validate(r, v, path, mode, m[k], res) + Validate(r, v, path, mode, m[actualKey], res) path.Pop() } if addl, ok := s.AdditionalProperties.(bool); ok && !addl { + addlPropLoop: for k := range m { // No additional properties allowed. if _, ok := s.Properties[k]; !ok { + if !ValidateStrictCasing { + for propName := range s.Properties { + if strings.EqualFold(propName, k) { + // Case-insensitive match found, so this is not an error. + continue addlPropLoop + } + } + } + path.Push(k) res.Add(path, m, validation.MsgUnexpectedProperty) path.Pop() diff --git a/validate_test.go b/validate_test.go index 04df3965..767d0cca 100644 --- a/validate_test.go +++ b/validate_test.go @@ -28,13 +28,15 @@ func mapTo[A, B any](s []A, f func(A) B) []B { } var validateTests = []struct { - name string - typ reflect.Type - s *huma.Schema - input any - mode huma.ValidateMode - errs []string - panic string + name string + typ reflect.Type + s *huma.Schema + input any + mode huma.ValidateMode + errs []string + panic string + before func() + cleanup func() }{ { name: "bool success", @@ -918,6 +920,34 @@ var validateTests = []struct { input: map[any]any{"value": "should not be set"}, errs: []string{"write only property is non-zero"}, }, + { + name: "case-insensive success", + typ: reflect.TypeOf(struct { + Value string `json:"value"` + }{}), + input: map[string]any{"VaLuE": "works"}, + }, + { + name: "case-insensive fail", + typ: reflect.TypeOf(struct { + Value string `json:"value" maxLength:"3"` + }{}), + input: map[string]any{"VaLuE": "fails"}, + errs: []string{"expected length <= 3"}, + }, + { + name: "case-sensive fail", + before: func() { huma.ValidateStrictCasing = true }, + cleanup: func() { huma.ValidateStrictCasing = false }, + typ: reflect.TypeOf(struct { + Value string `json:"value"` + }{}), + input: map[string]any{"VaLuE": "fails due to casing"}, + errs: []string{ + "expected required property value to be present", + "unexpected property", + }, + }, { name: "unexpected property", typ: reflect.TypeOf(struct { @@ -1368,6 +1398,13 @@ func TestValidate(t *testing.T) { for _, test := range validateTests { t.Run(test.name, func(t *testing.T) { + if test.before != nil { + test.before() + } + if test.cleanup != nil { + defer test.cleanup() + } + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) var s *huma.Schema @@ -1502,10 +1539,18 @@ func BenchmarkValidate(b *testing.B) { if s.Type == huma.TypeObject && s.Properties["value"] != nil { switch i := input.(type) { case map[string]any: - input = i["value"] + for k := range i { + if strings.EqualFold(k, "value") { + input = i[k] + } + } s = s.Properties["value"] case map[any]any: - input = i["value"] + for k := range i { + if strings.EqualFold(fmt.Sprintf("%v", k), "value") { + input = i[k] + } + } s = s.Properties["value"] } }