Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions autopatch/autopatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 47 additions & 4 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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()
}
Expand All @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
44 changes: 44 additions & 0 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down