Skip to content

Commit

Permalink
Merge pull request #629 from danielgtaylor/case-insensitive-matching
Browse files Browse the repository at this point in the history
feat: case-insensitive field matching for JSON/CBOR
  • Loading branch information
danielgtaylor authored Oct 28, 2024
2 parents 52482f3 + 91ff308 commit d67ab01
Show file tree
Hide file tree
Showing 2 changed files with 88 additions and 13 deletions.
38 changes: 34 additions & 4 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)*)*$")
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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()
Expand Down
63 changes: 54 additions & 9 deletions validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
}
}
Expand Down

0 comments on commit d67ab01

Please sign in to comment.