Repository: danielgtaylor/huma
Pull request #178 (2023-11-22 18:27:02)

fix: enable validation for map[any]any used by some formats
Enables validation for `map[any]any` as well as providing a bunch of tests for those inputs. Notably this is used by CBOR and can be used by other formats as well that support more than just `string` keys for maps. There's a small bit of code duplication in an effort to keep memory allocations down, so that is intentional.


Patch:
diff --git a/validate.go b/validate.go
index fd21c6b..cc743cc 100644
--- a/validate.go
+++ b/validate.go
@@ -454,7 +454,8 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any,
 	case TypeObject:
 		if vv, ok := v.(map[string]any); ok {
 			handleMapString(r, s, path, mode, vv, res)
-			// TODO: handle map[any]any
+		} else if vv, ok := v.(map[any]any); ok {
+			handleMapAny(r, s, path, mode, vv, res)
 		} else {
 			res.Add(path, v, "expected object")
 			return
@@ -575,6 +576,85 @@ 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)
+		}
+	}
+	if s.MaxProperties != nil {
+		if len(m) > *s.MaxProperties {
+			res.Add(path, m, s.msgMaxProperties)
+		}
+	}
+
+	for _, k := range s.propertyNames {
+		v := s.Properties[k]
+		for v.Ref != "" {
+			v = r.SchemaFromRef(v.Ref)
+		}
+
+		// We should be permissive by default to enable easy round-trips for the
+		// client without needing to remove read-only values.
+		// TODO: should we make this configurable?
+
+		// Be stricter for responses, enabling validation of the server if desired.
+		if mode == ModeReadFromServer && v.WriteOnly && m[k] != nil && !reflect.ValueOf(m[k]).IsZero() {
+			res.Add(path, m[k], "write only property is non-zero")
+			continue
+		}
+
+		if m[k] == nil {
+			if !s.requiredMap[k] {
+				continue
+			}
+			if (mode == ModeWriteToServer && v.ReadOnly) ||
+				(mode == ModeReadFromServer && v.WriteOnly) {
+				// These are not required for the current mode.
+				continue
+			}
+			res.Add(path, m, s.msgRequired[k])
+			continue
+		}
+
+		path.Push(k)
+		Validate(r, v, path, mode, m[k], res)
+		path.Pop()
+	}
+
+	if addl, ok := s.AdditionalProperties.(bool); ok && !addl {
+		for k := range m {
+			// No additional properties allowed.
+			var kStr string
+			if s, ok := k.(string); ok {
+				kStr = s
+			} else {
+				kStr = fmt.Sprint(k)
+			}
+			if _, ok := s.Properties[kStr]; !ok {
+				path.Push(kStr)
+				res.Add(path, m, "unexpected property")
+				path.Pop()
+			}
+		}
+	}
+
+	if addl, ok := s.AdditionalProperties.(*Schema); ok {
+		// Additional properties are allowed, but must match the given schema.
+		for k, v := range m {
+			var kStr string
+			if s, ok := k.(string); ok {
+				kStr = s
+			} else {
+				kStr = fmt.Sprint(k)
+			}
+			path.Push(kStr)
+			Validate(r, addl, path, mode, v, res)
+			path.Pop()
+		}
+	}
+}
+
 // ModelValidator is a utility for validating e.g. JSON loaded data against a
 // Go struct model. It is not goroutine-safe and should not be used in HTTP
 // handlers! Schemas are generated on-the-fly on first use and re-used on


Test patch:
diff --git a/validate_test.go b/validate_test.go
index 17d07f7..0f444c7 100644
--- a/validate_test.go
+++ b/validate_test.go
@@ -589,11 +589,27 @@ var validateTests = []struct {
 		input: map[string]any{"one": 1, "two": 2},
 	},
 	{
-		name:  "expected map item",
+		name:  "map any success",
 		typ:   reflect.TypeOf(map[string]int{}),
+		input: map[any]any{"one": 1, "two": 2},
+	},
+	{
+		name:  "map any int success",
+		typ:   reflect.TypeOf(map[int]string{}),
+		input: map[any]any{1: "one", 2: "two"},
+	},
+	{
+		name:  "expected map item",
+		typ:   reflect.TypeOf(map[any]int{}),
 		input: map[string]any{"one": 1, "two": true},
 		errs:  []string{"expected number"},
 	},
+	{
+		name:  "expected map any item",
+		typ:   reflect.TypeOf(map[any]int{}),
+		input: map[any]any{"one": 1, "two": true},
+		errs:  []string{"expected number"},
+	},
 	{
 		name: "map minProps success",
 		typ: reflect.TypeOf(struct {
@@ -603,6 +619,15 @@ var validateTests = []struct {
 			"value": map[string]any{"one": 1},
 		},
 	},
+	{
+		name: "map any minProps success",
+		typ: reflect.TypeOf(struct {
+			Value map[any]int `json:"value" minProperties:"1"`
+		}{}),
+		input: map[any]any{
+			"value": map[any]any{"one": 1},
+		},
+	},
 	{
 		name: "expected map minProps",
 		typ: reflect.TypeOf(struct {
@@ -613,6 +638,16 @@ var validateTests = []struct {
 		},
 		errs: []string{"expected object with at least 1 properties"},
 	},
+	{
+		name: "expected map any minProps",
+		typ: reflect.TypeOf(struct {
+			Value map[any]int `json:"value" minProperties:"1"`
+		}{}),
+		input: map[any]any{
+			"value": map[any]any{},
+		},
+		errs: []string{"expected object with at least 1 properties"},
+	},
 	{
 		name: "map maxProps success",
 		typ: reflect.TypeOf(struct {
@@ -622,6 +657,15 @@ var validateTests = []struct {
 			"value": map[string]any{"one": 1},
 		},
 	},
+	{
+		name: "map any maxProps success",
+		typ: reflect.TypeOf(struct {
+			Value map[any]int `json:"value" maxProperties:"1"`
+		}{}),
+		input: map[any]any{
+			"value": map[any]any{"one": 1},
+		},
+	},
 	{
 		name: "expected map maxProps",
 		typ: reflect.TypeOf(struct {
@@ -632,11 +676,26 @@ var validateTests = []struct {
 		},
 		errs: []string{"expected object with at most 1 properties"},
 	},
+	{
+		name: "expected map any maxProps",
+		typ: reflect.TypeOf(struct {
+			Value map[any]int `json:"value" maxProperties:"1"`
+		}{}),
+		input: map[any]any{
+			"value": map[any]any{"one": 1, "two": 2},
+		},
+		errs: []string{"expected object with at most 1 properties"},
+	},
 	{
 		name:  "object struct success",
 		typ:   reflect.TypeOf(struct{}{}),
 		input: map[string]any{},
 	},
+	{
+		name:  "object struct any success",
+		typ:   reflect.TypeOf(struct{}{}),
+		input: map[any]any{},
+	},
 	{
 		name:  "expected object",
 		typ:   reflect.TypeOf(struct{}{}),
@@ -650,6 +709,13 @@ var validateTests = []struct {
 		}{}),
 		input: map[string]any{},
 	},
+	{
+		name: "object any optional success",
+		typ: reflect.TypeOf(struct {
+			Value string `json:"value,omitempty"`
+		}{}),
+		input: map[any]any{},
+	},
 	{
 		name: "readOnly set success",
 		typ: reflect.TypeOf(struct {
@@ -658,6 +724,14 @@ var validateTests = []struct {
 		mode:  huma.ModeWriteToServer,
 		input: map[string]any{"value": "whoops"},
 	},
+	{
+		name: "readOnly any set success",
+		typ: reflect.TypeOf(struct {
+			Value string `json:"value" readOnly:"true"`
+		}{}),
+		mode:  huma.ModeWriteToServer,
+		input: map[any]any{"value": "whoops"},
+	},
 	{
 		name: "readOnly missing success",
 		typ: reflect.TypeOf(struct {
@@ -666,6 +740,14 @@ var validateTests = []struct {
 		mode:  huma.ModeWriteToServer,
 		input: map[string]any{},
 	},
+	{
+		name: "readOnly any missing success",
+		typ: reflect.TypeOf(struct {
+			Value string `json:"value" readOnly:"true"`
+		}{}),
+		mode:  huma.ModeWriteToServer,
+		input: map[any]any{},
+	},
 	{
 		name: "readOnly missing fail",
 		typ: reflect.TypeOf(struct {
@@ -675,6 +757,15 @@ var validateTests = []struct {
 		input: map[string]any{},
 		errs:  []string{"expected required property value to be present"},
 	},
+	{
+		name: "readOnly any missing fail",
+		typ: reflect.TypeOf(struct {
+			Value string `json:"value" readOnly:"true"`
+		}{}),
+		mode:  huma.ModeReadFromServer,
+		input: map[any]any{},
+		errs:  []string{"expected required property value to be present"},
+	},
 	{
 		name: "writeOnly missing fail",
 		typ: reflect.TypeOf(struct {
@@ -684,6 +775,15 @@ var validateTests = []struct {
 		input: map[string]any{"value": "should not be set"},
 		errs:  []string{"write only property is non-zero"},
 	},
+	{
+		name: "writeOnly any missing fail",
+		typ: reflect.TypeOf(struct {
+			Value string `json:"value" writeOnly:"true"`
+		}{}),
+		mode:  huma.ModeReadFromServer,
+		input: map[any]any{"value": "should not be set"},
+		errs:  []string{"write only property is non-zero"},
+	},
 	{
 		name: "unexpected property",
 		typ: reflect.TypeOf(struct {
@@ -692,6 +792,14 @@ var validateTests = []struct {
 		input: map[string]any{"value2": "whoops"},
 		errs:  []string{"unexpected property"},
 	},
+	{
+		name: "unexpected property any",
+		typ: reflect.TypeOf(struct {
+			Value string `json:"value,omitempty"`
+		}{}),
+		input: map[any]any{123: "whoops"},
+		errs:  []string{"unexpected property"},
+	},
 	{
 		name: "nested success",
 		typ: reflect.TypeOf(struct {
@@ -701,6 +809,15 @@ var validateTests = []struct {
 		}{}),
 		input: map[string]any{"items": []any{map[string]any{"value": "hello"}}},
 	},
+	{
+		name: "nested any success",
+		typ: reflect.TypeOf(struct {
+			Items []struct {
+				Value string `json:"value"`
+			} `json:"items"`
+		}{}),
+		input: map[any]any{"items": []any{map[any]any{"value": "hello"}}},
+	},
 	{
 		name: "expected nested",
 		typ: reflect.TypeOf(struct {
@@ -711,6 +828,16 @@ var validateTests = []struct {
 		input: map[string]any{"items": []any{map[string]any{}}},
 		errs:  []string{"expected required property value to be present"},
 	},
+	{
+		name: "expected nested any",
+		typ: reflect.TypeOf(struct {
+			Items []struct {
+				Value string `json:"value"`
+			} `json:"items"`
+		}{}),
+		input: map[any]any{"items": []any{map[any]any{}}},
+		errs:  []string{"expected required property value to be present"},
+	},
 	{
 		name: "enum success",
 		typ: reflect.TypeOf(struct {
@@ -853,7 +980,7 @@ func TestValidate(t *testing.T) {
 			var s *huma.Schema
 			if test.panic != "" {
 				assert.Panics(t, func() {
-					registry.Schema(test.typ, false, "TestInput")
+					registry.Schema(test.typ, true, "TestInput")
 				})
 				return
 			} else {
@@ -861,7 +988,7 @@ func TestValidate(t *testing.T) {
 					s = test.s
 					s.PrecomputeMessages()
 				} else {
-					s = registry.Schema(test.typ, false, "TestInput")
+					s = registry.Schema(test.typ, true, "TestInput")
 				}
 			}
 
@@ -933,8 +1060,13 @@ func BenchmarkValidate(b *testing.B) {
 
 			input := test.input
 			if s.Type == huma.TypeObject && s.Properties["value"] != nil {
-				s = s.Properties["value"]
-				input = input.(map[string]any)["value"]
+				if i, ok := input.(map[string]any); ok {
+					input = i["value"]
+					s = s.Properties["value"]
+				} else if i, ok := input.(map[any]any); ok {
+					input = i["value"]
+					s = s.Properties["value"]
+				}
 			}
 
 			b.ReportAllocs()