From 6ccfa296e7f9b15310775f0fef67cc7c79657c40 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Wed, 22 Nov 2023 10:23:11 -0800 Subject: [PATCH] fix: enable validation for map[any]any used by some formats --- validate.go | 82 ++++++++++++++++++++++++++- validate_test.go | 142 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/validate.go b/validate.go index fd21c6b7..cc743cc9 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 diff --git a/validate_test.go b/validate_test.go index 17d07f7e..0f444c71 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()