diff --git a/autopatch/autopatch.go b/autopatch/autopatch.go index e04f2e71..79219192 100644 --- a/autopatch/autopatch.go +++ b/autopatch/autopatch.go @@ -467,6 +467,7 @@ func makeOptionalSchema(s *huma.Schema) *huma.Schema { Default: s.Default, Examples: s.Examples, AdditionalProperties: s.AdditionalProperties, + PatternProperties: s.PatternProperties, Enum: s.Enum, Minimum: s.Minimum, ExclusiveMinimum: s.ExclusiveMinimum, diff --git a/schema.go b/schema.go index f267b4de..caadd0c1 100644 --- a/schema.go +++ b/schema.go @@ -105,6 +105,16 @@ func (d *Discriminator) MarshalJSON() ([]byte, error) { // schema := huma.SchemaFromType(registry, reflect.TypeOf(MyType{})) // // Note that the registry may create references for your types. + +// patternPropertySchema holds a compiled `patternProperties` regular +// expression alongside the schema that matching properties must validate +// against. The list is precomputed from the `PatternProperties` map so that +// validation avoids recompiling regexes and iterates in a deterministic order. +type patternPropertySchema struct { + re *regexp.Regexp + schema *Schema +} + type Schema struct { Type string `yaml:"type,omitempty"` Nullable bool `yaml:"-"` @@ -118,6 +128,7 @@ type Schema struct { Items *Schema `yaml:"items,omitempty"` AdditionalProperties any `yaml:"additionalProperties,omitempty"` Properties map[string]*Schema `yaml:"properties,omitempty"` + PatternProperties map[string]*Schema `yaml:"patternProperties,omitempty"` Enum []any `yaml:"enum,omitempty"` Const any `yaml:"const,omitempty"` Minimum *float64 `yaml:"minimum,omitempty"` @@ -149,10 +160,11 @@ type Schema struct { // OpenAPI specific fields Discriminator *Discriminator `yaml:"discriminator,omitempty"` - patternRe *regexp.Regexp `yaml:"-"` - requiredMap map[string]bool `yaml:"-"` - propertyNames []string `yaml:"-"` - hidden bool `yaml:"-"` + patternRe *regexp.Regexp `yaml:"-"` + patternProperties []patternPropertySchema `yaml:"-"` + requiredMap map[string]bool `yaml:"-"` + propertyNames []string `yaml:"-"` + hidden bool `yaml:"-"` // Precomputed validation messages. These prevent allocations during // validation and are known at schema creation time. @@ -214,6 +226,7 @@ func (s *Schema) MarshalJSON() ([]byte, error) { {"items", s.Items, omitEmpty}, {"additionalProperties", s.AdditionalProperties, omitNil}, {"properties", props, omitEmpty}, + {"patternProperties", s.PatternProperties, omitEmpty}, {"enum", s.Enum, omitEmpty}, {"const", s.Const, omitNil}, {"minimum", s.Minimum, omitEmpty}, @@ -336,6 +349,23 @@ func (s *Schema) PrecomputeMessages() { prop.PrecomputeMessages() } + s.patternProperties = s.patternProperties[:0] + if len(s.PatternProperties) > 0 { + patterns := make([]string, 0, len(s.PatternProperties)) + for pattern := range s.PatternProperties { + patterns = append(patterns, pattern) + } + sort.Strings(patterns) + for _, pattern := range patterns { + prop := s.PatternProperties[pattern] + s.patternProperties = append(s.patternProperties, patternPropertySchema{ + re: regexp.MustCompile(pattern), + schema: prop, + }) + prop.PrecomputeMessages() + } + } + for _, sub := range s.OneOf { sub.PrecomputeMessages() } @@ -353,6 +383,19 @@ func (s *Schema) PrecomputeMessages() { } } +// matchesPatternProperty reports whether the given property name matches at +// least one of the schema's precomputed `patternProperties` regular +// expressions. Such properties are validated against the matching pattern +// schema(s) and are therefore not treated as additional properties. +func (s *Schema) matchesPatternProperty(name string) bool { + for i := range s.patternProperties { + if s.patternProperties[i].re.MatchString(name) { + return true + } + } + return false +} + func boolTag(f reflect.StructField, tag string, def bool) bool { if v := f.Tag.Get(tag); v != "" { switch v { diff --git a/schema_test.go b/schema_test.go index c0598fdc..bb8e1b9d 100644 --- a/schema_test.go +++ b/schema_test.go @@ -1721,3 +1721,20 @@ func TestSchemaTransformer(t *testing.T) { updateSchema2 := huma.SchemaFromType(r, reflect.TypeFor[ExampleUpdateStruct]()) validateSchema(updateSchema2) } + +func TestSchemaPatternProperties(t *testing.T) { + s := &huma.Schema{ + Type: huma.TypeObject, + PatternProperties: map[string]*huma.Schema{ + "^x-": {Type: huma.TypeString}, + }, + } + b, err := json.Marshal(s) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "object", + "patternProperties": { + "^x-": {"type": "string"} + } + }`, string(b)) +} diff --git a/validate.go b/validate.go index a9dd7fac..d94d891d 100644 --- a/validate.go +++ b/validate.go @@ -838,11 +838,27 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, path.Pop() } + for i := range s.patternProperties { + pp := &s.patternProperties[i] + for k, v := range m { + if !pp.re.MatchString(k) { + continue + } + path.Push(k) + Validate(r, pp.schema, path, mode, v, 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 s.matchesPatternProperty(k) { + // Matched a `patternProperties` entry, so not additional. + continue addlPropLoop + } if !ValidateStrictCasing { for propName := range s.Properties { if strings.EqualFold(propName, k) { @@ -865,6 +881,9 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, if _, ok := s.Properties[k]; ok { continue } + if s.matchesPatternProperty(k) { + continue + } path.Push(k) Validate(r, addl, path, mode, v, res) @@ -952,6 +971,24 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m path.Pop() } + for i := range s.patternProperties { + pp := &s.patternProperties[i] + for k, v := range m { + var kStr string + if ks, ok := k.(string); ok { + kStr = ks + } else { + kStr = fmt.Sprint(k) + } + if !pp.re.MatchString(kStr) { + continue + } + path.Push(kStr) + Validate(r, pp.schema, path, mode, v, res) + path.Pop() + } + } + if addl, ok := s.AdditionalProperties.(bool); ok && !addl { for k := range m { // No additional properties allowed. @@ -962,6 +999,10 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m kStr = fmt.Sprint(k) } if _, ok := s.Properties[kStr]; !ok { + if s.matchesPatternProperty(kStr) { + // Matched a `patternProperties` entry, so not additional. + continue + } path.Push(kStr) res.Add(path, m, validation.MsgUnexpectedProperty) path.Pop() @@ -978,6 +1019,9 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m } else { kStr = fmt.Sprint(k) } + if s.matchesPatternProperty(kStr) { + continue + } path.Push(kStr) Validate(r, addl, path, mode, v, res) path.Pop() diff --git a/validate_test.go b/validate_test.go index 1ccc0e20..f2b3fa00 100644 --- a/validate_test.go +++ b/validate_test.go @@ -1095,6 +1095,94 @@ var validateTests = []struct { input: map[any]any{123: "whoops"}, errs: []string{"unexpected property"}, }, + { + name: "patternProperties success", + s: &huma.Schema{ + Type: huma.TypeObject, + PatternProperties: map[string]*huma.Schema{ + "^S_": {Type: huma.TypeString}, + "^I_": {Type: huma.TypeInteger}, + }, + }, + input: map[string]any{"S_name": "hello", "I_count": 1}, + }, + { + name: "patternProperties value type fail", + s: &huma.Schema{ + Type: huma.TypeObject, + PatternProperties: map[string]*huma.Schema{ + "^S_": {Type: huma.TypeString}, + }, + }, + input: map[string]any{"S_name": 123}, + errs: []string{"expected string"}, + }, + { + name: "patternProperties value constraint fail", + s: &huma.Schema{ + Type: huma.TypeObject, + PatternProperties: map[string]*huma.Schema{ + "^S_": {Type: huma.TypeString, MinLength: Ptr(3)}, + }, + }, + input: map[string]any{"S_name": "ab"}, + errs: []string{"expected length >= 3"}, + }, + { + name: "patternProperties allows matching additional property", + s: &huma.Schema{ + Type: huma.TypeObject, + AdditionalProperties: false, + PatternProperties: map[string]*huma.Schema{ + "^x-": {Type: huma.TypeString}, + }, + }, + input: map[string]any{"x-trace": "abc"}, + }, + { + name: "patternProperties non-matching additional property fails", + s: &huma.Schema{ + Type: huma.TypeObject, + AdditionalProperties: false, + PatternProperties: map[string]*huma.Schema{ + "^x-": {Type: huma.TypeString}, + }, + }, + input: map[string]any{"other": "abc"}, + errs: []string{"unexpected property"}, + }, + { + name: "patternProperties schema additionalProperties skips matches", + s: &huma.Schema{ + Type: huma.TypeObject, + AdditionalProperties: &huma.Schema{Type: huma.TypeInteger}, + PatternProperties: map[string]*huma.Schema{ + "^s_": {Type: huma.TypeString}, + }, + }, + input: map[string]any{"s_name": "hello", "count": 1}, + }, + { + name: "patternProperties map any success", + s: &huma.Schema{ + Type: huma.TypeObject, + PatternProperties: map[string]*huma.Schema{ + "^S_": {Type: huma.TypeString}, + }, + }, + input: map[any]any{"S_name": "hello"}, + }, + { + name: "patternProperties map any fail", + s: &huma.Schema{ + Type: huma.TypeObject, + PatternProperties: map[string]*huma.Schema{ + "^S_": {Type: huma.TypeString}, + }, + }, + input: map[any]any{"S_name": 123}, + errs: []string{"expected string"}, + }, { name: "nested success", typ: reflect.TypeFor[struct {