Skip to content

Commit

Permalink
Merge pull request #178 from danielgtaylor/map-any
Browse files Browse the repository at this point in the history
fix: enable validation for map[any]any used by some formats
  • Loading branch information
danielgtaylor authored Nov 22, 2023
2 parents 04ea541 + 6ccfa29 commit f54b62a
Show file tree
Hide file tree
Showing 2 changed files with 218 additions and 6 deletions.
82 changes: 81 additions & 1 deletion validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
142 changes: 137 additions & 5 deletions validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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{}{}),
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -853,15 +980,15 @@ 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 {
if test.s != nil {
s = test.s
s.PrecomputeMessages()
} else {
s = registry.Schema(test.typ, false, "TestInput")
s = registry.Schema(test.typ, true, "TestInput")
}
}

Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit f54b62a

Please sign in to comment.