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
4 changes: 4 additions & 0 deletions autopatch/autopatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,10 @@ func makeOptionalSchema(s *huma.Schema) *huma.Schema {
}
}

if s.PropertyNames != nil {
optionalSchema.PropertyNames = makeOptionalSchema(s.PropertyNames)
}

if s.OneOf != nil {
optionalSchema.OneOf = make([]*huma.Schema, len(s.OneOf))
for i, schema := range s.OneOf {
Expand Down
7 changes: 7 additions & 0 deletions autopatch/autopatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -587,13 +587,20 @@ func TestMakeOptionalSchemaNestedSchemas(t *testing.T) {
Required: []string{"deeplyNested"},
},
},
PropertyNames: &huma.Schema{
Type: "string",
Pattern: "^[a-z][a-z0-9-]{1,10}$",
},
Required: []string{"nested"},
}

optionalNestedSchema := makeOptionalSchema(nestedSchema)

assert.Empty(t, optionalNestedSchema.Required)
assert.Empty(t, optionalNestedSchema.Properties["nested"].Required)
require.NotNil(t, optionalNestedSchema.PropertyNames)
assert.Equal(t, "string", optionalNestedSchema.PropertyNames.Type)
assert.Equal(t, "^[a-z][a-z0-9-]{1,10}$", optionalNestedSchema.PropertyNames.Pattern)
}

type findRelativeResourcePathTest struct {
Expand Down
6 changes: 6 additions & 0 deletions openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,12 @@ func downgradeSpec(input any) {
continue
}

if k == "propertyNames" {
// OpenAPI 3.0 has no propertyNames keyword.
delete(m, k)
continue
}

if k == "type" {
// OpenAPI 3.1 supports type arrays, which need to be converted.
// This may be lossy, but we want to keep it simple.
Expand Down
4 changes: 4 additions & 0 deletions openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ func TestDowngrade(t *testing.T) {
ContentEncoding: "base64",
},
},
PropertyNames: &huma.Schema{
Type: huma.TypeString,
Pattern: "^[a-z][a-z0-9-]{1,10}$",
},
},
},
"application/octet-stream": {},
Expand Down
6 changes: 6 additions & 0 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type Schema struct {
Items *Schema `yaml:"items,omitempty"`
AdditionalProperties any `yaml:"additionalProperties,omitempty"`
Properties map[string]*Schema `yaml:"properties,omitempty"`
PropertyNames *Schema `yaml:"propertyNames,omitempty"`
Enum []any `yaml:"enum,omitempty"`
Const any `yaml:"const,omitempty"`
Minimum *float64 `yaml:"minimum,omitempty"`
Expand Down Expand Up @@ -214,6 +215,7 @@ func (s *Schema) MarshalJSON() ([]byte, error) {
{"items", s.Items, omitEmpty},
{"additionalProperties", s.AdditionalProperties, omitNil},
{"properties", props, omitEmpty},
{"propertyNames", s.PropertyNames, omitEmpty},
{"enum", s.Enum, omitEmpty},
{"const", s.Const, omitNil},
{"minimum", s.Minimum, omitEmpty},
Expand Down Expand Up @@ -351,6 +353,10 @@ func (s *Schema) PrecomputeMessages() {
if sub := s.Not; sub != nil {
sub.PrecomputeMessages()
}

if s.PropertyNames != nil {
s.PropertyNames.PrecomputeMessages()
}
}

func boolTag(f reflect.StructField, tag string, def bool) bool {
Expand Down
90 changes: 90 additions & 0 deletions schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1721,3 +1721,93 @@ func TestSchemaTransformer(t *testing.T) {
updateSchema2 := huma.SchemaFromType(r, reflect.TypeFor[ExampleUpdateStruct]())
validateSchema(updateSchema2)
}

type PropertyNamesMetadata map[string]any

func (m PropertyNamesMetadata) Schema(r huma.Registry) *huma.Schema {
return &huma.Schema{
Type: huma.TypeObject,
PropertyNames: &huma.Schema{
Type: huma.TypeString,
Pattern: "^[a-z][a-z0-9-]{1,10}$",
},
}
}

var _ huma.SchemaProvider = PropertyNamesMetadata{}

type PropertyNamesPayload struct {
Fixed string `json:"fixed"`
}

func (p PropertyNamesPayload) TransformSchema(r huma.Registry, s *huma.Schema) *huma.Schema {
s.PropertyNames = &huma.Schema{
Type: huma.TypeString,
Pattern: "^[a-z][a-z0-9-]{1,10}$",
}
return s
}

var _ huma.SchemaTransformer = PropertyNamesPayload{}

func TestPropertyNamesSchemaMarshal(t *testing.T) {
s := &huma.Schema{
Type: huma.TypeObject,
PropertyNames: &huma.Schema{
Type: huma.TypeString,
Pattern: "^[a-z][a-z0-9-]{1,10}$",
},
}

b, err := json.Marshal(s)
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))

assert.Equal(t, huma.TypeObject, m["type"])

propertyNames, ok := m["propertyNames"].(map[string]any)
require.True(t, ok, "propertyNames missing from marshaled schema %s", string(b))
assert.Equal(t, huma.TypeString, propertyNames["type"])
assert.Equal(t, "^[a-z][a-z0-9-]{1,10}$", propertyNames["pattern"])
}

func TestPropertyNamesSchemaDeclaration(t *testing.T) {
tests := []struct {
name string
schema func(huma.Registry) *huma.Schema
checkFixed bool
}{
{
name: "schema provider",
schema: func(r huma.Registry) *huma.Schema {
return r.Schema(reflect.TypeFor[PropertyNamesMetadata](), false, "")
},
},
{
name: "schema transformer",
schema: func(r huma.Registry) *huma.Schema {
return r.Schema(reflect.TypeFor[PropertyNamesPayload](), false, "")
},
checkFixed: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer)
s := tc.schema(r)

assert.Equal(t, huma.TypeObject, s.Type)
require.NotNil(t, s.PropertyNames, "propertyNames must survive schema generation")
assert.Equal(t, huma.TypeString, s.PropertyNames.Type)
assert.Equal(t, "^[a-z][a-z0-9-]{1,10}$", s.PropertyNames.Pattern)

if tc.checkFixed {
require.Contains(t, s.Properties, "fixed", "ordinary properties must survive adding propertyNames")
assert.Equal(t, huma.TypeString, s.Properties["fixed"].Type)
}
})
}
}
26 changes: 26 additions & 0 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,17 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode,
}
}

// Property name constraints apply to every key as a string value,
// cumulatively with the named, pattern, and additional property value
// validations.
if s.PropertyNames != nil {
for k := range m {
path.Push(k)
Validate(r, s.PropertyNames, path, mode, k, res)
path.Pop()
}
}

for _, k := range s.propertyNames {
v := s.Properties[k]

Expand Down Expand Up @@ -885,6 +896,21 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m
}
}

// Property name constraints apply to every string key as a string value,
// cumulatively with the named, pattern, and additional property value
// validations. Non-string keys cannot violate property name constraints.
if s.PropertyNames != nil {
for k := range m {
kStr, ok := k.(string)
if !ok {
continue
}
path.Push(kStr)
Validate(r, s.PropertyNames, path, mode, kStr, res)
path.Pop()
}
}

for _, k := range s.propertyNames {
v := s.Properties[k]

Expand Down
Loading
Loading