diff --git a/autopatch/autopatch.go b/autopatch/autopatch.go index e04f2e71..44d4a0a2 100644 --- a/autopatch/autopatch.go +++ b/autopatch/autopatch.go @@ -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 { diff --git a/autopatch/autopatch_test.go b/autopatch/autopatch_test.go index 1312f118..f294ab02 100644 --- a/autopatch/autopatch_test.go +++ b/autopatch/autopatch_test.go @@ -587,6 +587,10 @@ func TestMakeOptionalSchemaNestedSchemas(t *testing.T) { Required: []string{"deeplyNested"}, }, }, + PropertyNames: &huma.Schema{ + Type: "string", + Pattern: "^[a-z][a-z0-9-]{1,10}$", + }, Required: []string{"nested"}, } @@ -594,6 +598,9 @@ func TestMakeOptionalSchemaNestedSchemas(t *testing.T) { 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 { diff --git a/openapi.go b/openapi.go index a3efdf46..805d4a4e 100644 --- a/openapi.go +++ b/openapi.go @@ -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. diff --git a/openapi_test.go b/openapi_test.go index a0e1d5ae..0f679c11 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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": {}, diff --git a/schema.go b/schema.go index f267b4de..45b2b636 100644 --- a/schema.go +++ b/schema.go @@ -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"` @@ -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}, @@ -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 { diff --git a/schema_test.go b/schema_test.go index c0598fdc..899464fd 100644 --- a/schema_test.go +++ b/schema_test.go @@ -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) + } + }) + } +} diff --git a/validate.go b/validate.go index a9dd7fac..0bf98be9 100644 --- a/validate.go +++ b/validate.go @@ -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] @@ -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] diff --git a/validate_test.go b/validate_test.go index 1ccc0e20..ee5d55a9 100644 --- a/validate_test.go +++ b/validate_test.go @@ -1966,3 +1966,194 @@ func TestValidateUnresolvedSchemaRefNoPanic(t *testing.T) { }) }) } + +func TestPropertyNamesValidate(t *testing.T) { + type expectedError struct { + location string + messagePart string + } + + tests := []struct { + name string + schema *huma.Schema + input any + wantPanic bool + wantErrors []expectedError + }{ + { + name: "rejects invalid pattern", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z][a-z0-9-]{1,10}$", + }, + }, + input: map[string]any{"User_ID": "anything"}, + wantErrors: []expectedError{ + {location: "User_ID"}, + }, + }, + { + name: "rejects short name", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + MinLength: Ptr(2), + }, + }, + input: map[string]any{"a": 1}, + wantErrors: []expectedError{ + {location: "a"}, + }, + }, + { + name: "rejects long name", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + MaxLength: Ptr(6), + }, + }, + input: map[string]any{"toolong": 1}, + wantErrors: []expectedError{ + {location: "toolong"}, + }, + }, + { + name: "accepts valid name", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z][a-z0-9-]{1,10}$", + }, + }, + input: map[string]any{"valid-name": "ok"}, + }, + { + name: "checks declared property names", + schema: &huma.Schema{ + Type: huma.TypeObject, + Properties: map[string]*huma.Schema{ + "X": {Type: huma.TypeString}, + }, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z]+$", + }, + }, + input: map[string]any{"X": "value"}, + wantErrors: []expectedError{ + {location: "X"}, + }, + }, + { + name: "still checks property values", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z-]+$", + }, + AdditionalProperties: &huma.Schema{Type: huma.TypeInteger}, + }, + input: map[string]any{"valid-name": "bad"}, + wantErrors: []expectedError{ + {location: "valid-name", messagePart: "expected integer"}, + }, + }, + { + name: "reports property name and value errors", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z-]+$", + }, + AdditionalProperties: &huma.Schema{Type: huma.TypeInteger}, + }, + input: map[string]any{"BAD": 1, "valid-name": "bad"}, + wantErrors: []expectedError{ + {location: "BAD"}, + {location: "valid-name", messagePart: "expected integer"}, + }, + }, + { + name: "checks map any string key", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z]+$", + }, + }, + input: map[any]any{"BAD": "value"}, + wantErrors: []expectedError{ + {location: "BAD"}, + }, + }, + { + name: "invalid nested regex panics", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "[", + }, + }, + wantPanic: true, + }, + { + name: "ignores map any non-string key", + schema: &huma.Schema{ + Type: huma.TypeObject, + PropertyNames: &huma.Schema{ + Type: huma.TypeString, + Pattern: "^[a-z]+$", + }, + }, + input: map[any]any{123: "value"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + if tc.wantPanic { + assert.Panics(t, func() { + tc.schema.PrecomputeMessages() + }) + return + } + + tc.schema.PrecomputeMessages() + pb := huma.NewPathBuffer([]byte(""), 0) + res := &huma.ValidateResult{} + + huma.Validate(registry, tc.schema, pb, huma.ModeReadFromServer, tc.input, res) + if len(tc.wantErrors) == 0 { + assert.Empty(t, res.Errors) + return + } + + require.Len(t, res.Errors, len(tc.wantErrors)) + for _, expected := range tc.wantErrors { + found := false + for _, err := range res.Errors { + detail := err.(*huma.ErrorDetail) + if detail.Location != expected.location { + continue + } + if expected.messagePart == "" || strings.Contains(detail.Message, expected.messagePart) { + found = true + break + } + } + assert.Truef(t, found, "expected matching error location=%q messagePart=%q errors=%v", expected.location, expected.messagePart, res.Errors) + } + }) + } +}