Skip to content

Commit edb81df

Browse files
fix: support boolean form for unevaluatedProperties and unevaluatedItems
JSON Schema 2020-12 allows unevaluatedProperties and unevaluatedItems to be either a boolean or a schema object (e.g. `unevaluatedProperties: false`). The fields were typed as *SchemaRef, which caused an unmarshal error when encountering the boolean form. Introduce BoolSchema, a type that handles both boolean and schema forms (same pattern as AdditionalProperties), and use it for all three fields. AdditionalProperties becomes a type alias for BoolSchema so existing code continues to compile unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2cc3060 commit edb81df

6 files changed

Lines changed: 71 additions & 46 deletions

File tree

openapi3/loader.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,12 +1058,12 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat
10581058
return err
10591059
}
10601060
}
1061-
if v := value.UnevaluatedItems; v != nil {
1061+
if v := value.UnevaluatedItems.Schema; v != nil {
10621062
if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil {
10631063
return err
10641064
}
10651065
}
1066-
if v := value.UnevaluatedProperties; v != nil {
1066+
if v := value.UnevaluatedProperties.Schema; v != nil {
10671067
if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil {
10681068
return err
10691069
}

openapi3/loader_31_schema_refs_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,16 @@ func TestResolveSchemaRefsIn31Fields(t *testing.T) {
8888
// unevaluatedItems ref should be resolved
8989
unItems := schemas["ArrayWithUnevaluatedItems"].Value
9090
require.NotNil(t, unItems)
91-
require.Equal(t, "#/components/schemas/StringType", unItems.UnevaluatedItems.Ref)
92-
require.NotNil(t, unItems.UnevaluatedItems.Value, "unevaluatedItems $ref should be resolved")
91+
require.NotNil(t, unItems.UnevaluatedItems.Schema)
92+
require.Equal(t, "#/components/schemas/StringType", unItems.UnevaluatedItems.Schema.Ref)
93+
require.NotNil(t, unItems.UnevaluatedItems.Schema.Value, "unevaluatedItems $ref should be resolved")
9394

9495
// unevaluatedProperties ref should be resolved
9596
unProps := schemas["ObjectWithUnevaluatedProperties"].Value
9697
require.NotNil(t, unProps)
97-
require.Equal(t, "#/components/schemas/StringType", unProps.UnevaluatedProperties.Ref)
98-
require.NotNil(t, unProps.UnevaluatedProperties.Value, "unevaluatedProperties $ref should be resolved")
98+
require.NotNil(t, unProps.UnevaluatedProperties.Schema)
99+
require.Equal(t, "#/components/schemas/StringType", unProps.UnevaluatedProperties.Schema.Ref)
100+
require.NotNil(t, unProps.UnevaluatedProperties.Schema.Value, "unevaluatedProperties $ref should be resolved")
99101

100102
// if/then/else refs should be resolved
101103
ifThenElse := schemas["ObjectWithIfThenElse"].Value

openapi3/origin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ func applyOriginsToStruct(val reflect.Value, ptr reflect.Value, tree *yaml.Origi
179179

180180
// Handle wrapper types whose inner struct has no json tag:
181181
// - *Ref types (e.g. SchemaRef, ResponseRef) have a "Value" field
182-
// - AdditionalProperties has a "Schema" field
182+
// - BoolSchema (AdditionalProperties, UnevaluatedProperties, UnevaluatedItems) has a "Schema" field
183183
// The origin tree data applies to the inner struct, not a sub-key.
184184
for _, fieldName := range []string{"Value", "Schema"} {
185185
vf := val.FieldByName(fieldName)

openapi3/schema.go

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ type Schema struct {
145145
PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"`
146146
DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"`
147147
PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"`
148-
UnevaluatedItems *SchemaRef `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"`
149-
UnevaluatedProperties *SchemaRef `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"`
148+
UnevaluatedItems BoolSchema `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"`
149+
UnevaluatedProperties BoolSchema `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"`
150150

151151
// JSON Schema 2020-12 conditional keywords
152152
If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"`
@@ -357,56 +357,61 @@ func (types *Types) UnmarshalJSON(data []byte) error {
357357
return nil
358358
}
359359

360-
type AdditionalProperties struct {
360+
// BoolSchema represents a JSON Schema keyword that can be either a boolean or a schema object.
361+
// Used for additionalProperties, unevaluatedProperties, and unevaluatedItems.
362+
type BoolSchema struct {
361363
Has *bool
362364
Schema *SchemaRef
363365
}
364366

365-
// MarshalYAML returns the YAML encoding of AdditionalProperties.
366-
func (addProps AdditionalProperties) MarshalYAML() (any, error) {
367-
if x := addProps.Has; x != nil {
367+
// AdditionalProperties is a type alias for BoolSchema, kept for backward compatibility.
368+
type AdditionalProperties = BoolSchema
369+
370+
// MarshalYAML returns the YAML encoding of BoolSchema.
371+
func (bs BoolSchema) MarshalYAML() (any, error) {
372+
if x := bs.Has; x != nil {
368373
if *x {
369374
return true, nil
370375
}
371376
return false, nil
372377
}
373-
if x := addProps.Schema; x != nil {
378+
if x := bs.Schema; x != nil {
374379
return x.MarshalYAML()
375380
}
376381
return nil, nil
377382
}
378383

379-
// MarshalJSON returns the JSON encoding of AdditionalProperties.
380-
func (addProps AdditionalProperties) MarshalJSON() ([]byte, error) {
381-
x, err := addProps.MarshalYAML()
384+
// MarshalJSON returns the JSON encoding of BoolSchema.
385+
func (bs BoolSchema) MarshalJSON() ([]byte, error) {
386+
x, err := bs.MarshalYAML()
382387
if err != nil {
383388
return nil, err
384389
}
385390
return json.Marshal(x)
386391
}
387392

388-
// UnmarshalJSON sets AdditionalProperties to a copy of data.
389-
func (addProps *AdditionalProperties) UnmarshalJSON(data []byte) error {
393+
// UnmarshalJSON sets BoolSchema to a copy of data.
394+
func (bs *BoolSchema) UnmarshalJSON(data []byte) error {
390395
var x any
391396
if err := json.Unmarshal(data, &x); err != nil {
392397
return unmarshalError(err)
393398
}
394399
switch y := x.(type) {
395400
case nil:
396401
case bool:
397-
addProps.Has = &y
402+
bs.Has = &y
398403
case map[string]any:
399404
if len(y) == 0 {
400-
addProps.Schema = &SchemaRef{Value: &Schema{}}
405+
bs.Schema = &SchemaRef{Value: &Schema{}}
401406
} else {
402407
buf := new(bytes.Buffer)
403408
_ = json.NewEncoder(buf).Encode(y)
404-
if err := json.NewDecoder(buf).Decode(&addProps.Schema); err != nil {
409+
if err := json.NewDecoder(buf).Decode(&bs.Schema); err != nil {
405410
return err
406411
}
407412
}
408413
default:
409-
return errors.New("cannot unmarshal additionalProperties: value must be either a schema object or a boolean")
414+
return errors.New("cannot unmarshal: value must be either a schema object or a boolean")
410415
}
411416
return nil
412417
}
@@ -647,11 +652,11 @@ func (schema Schema) MarshalYAML() (any, error) {
647652
if x := schema.PropertyNames; x != nil {
648653
m["propertyNames"] = x
649654
}
650-
if x := schema.UnevaluatedItems; x != nil {
651-
m["unevaluatedItems"] = x
655+
if x := schema.UnevaluatedItems; x.Has != nil || x.Schema != nil {
656+
m["unevaluatedItems"] = &x
652657
}
653-
if x := schema.UnevaluatedProperties; x != nil {
654-
m["unevaluatedProperties"] = x
658+
if x := schema.UnevaluatedProperties; x.Has != nil || x.Schema != nil {
659+
m["unevaluatedProperties"] = &x
655660
}
656661
if x := schema.If; x != nil {
657662
m["if"] = x
@@ -923,18 +928,24 @@ func (schema Schema) JSONLookup(token string) (any, error) {
923928
return schema.PropertyNames.Value, nil
924929
}
925930
case "unevaluatedItems":
926-
if schema.UnevaluatedItems != nil {
927-
if schema.UnevaluatedItems.Ref != "" {
928-
return &Ref{Ref: schema.UnevaluatedItems.Ref}, nil
931+
if ui := schema.UnevaluatedItems.Has; ui != nil {
932+
return *ui, nil
933+
}
934+
if ui := schema.UnevaluatedItems.Schema; ui != nil {
935+
if ui.Ref != "" {
936+
return &Ref{Ref: ui.Ref}, nil
929937
}
930-
return schema.UnevaluatedItems.Value, nil
938+
return ui.Value, nil
931939
}
932940
case "unevaluatedProperties":
933-
if schema.UnevaluatedProperties != nil {
934-
if schema.UnevaluatedProperties.Ref != "" {
935-
return &Ref{Ref: schema.UnevaluatedProperties.Ref}, nil
941+
if up := schema.UnevaluatedProperties.Has; up != nil {
942+
return *up, nil
943+
}
944+
if up := schema.UnevaluatedProperties.Schema; up != nil {
945+
if up.Ref != "" {
946+
return &Ref{Ref: up.Ref}, nil
936947
}
937-
return schema.UnevaluatedProperties.Value, nil
948+
return up.Value, nil
938949
}
939950
case "if":
940951
if schema.If != nil {
@@ -1340,10 +1351,16 @@ func (schema *Schema) IsEmpty() bool {
13401351
if pn := schema.PropertyNames; pn != nil && pn.Value != nil && !pn.Value.IsEmpty() {
13411352
return false
13421353
}
1343-
if ui := schema.UnevaluatedItems; ui != nil && ui.Value != nil && !ui.Value.IsEmpty() {
1354+
if ui := schema.UnevaluatedItems.Schema; ui != nil && ui.Value != nil && !ui.Value.IsEmpty() {
1355+
return false
1356+
}
1357+
if uih := schema.UnevaluatedItems.Has; uih != nil && !*uih {
13441358
return false
13451359
}
1346-
if up := schema.UnevaluatedProperties; up != nil && up.Value != nil && !up.Value.IsEmpty() {
1360+
if up := schema.UnevaluatedProperties.Schema; up != nil && up.Value != nil && !up.Value.IsEmpty() {
1361+
return false
1362+
}
1363+
if uph := schema.UnevaluatedProperties.Has; uph != nil && !*uph {
13471364
return false
13481365
}
13491366
if len(schema.Examples) != 0 {
@@ -1676,7 +1693,10 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema,
16761693
return stack, err
16771694
}
16781695
}
1679-
if ref := schema.UnevaluatedItems; ref != nil {
1696+
if schema.UnevaluatedItems.Has != nil && schema.UnevaluatedItems.Schema != nil {
1697+
return stack, errors.New("unevaluatedItems is set to both boolean and schema")
1698+
}
1699+
if ref := schema.UnevaluatedItems.Schema; ref != nil {
16801700
v := ref.Value
16811701
if v == nil {
16821702
return stack, foundUnresolvedRef(ref.Ref)
@@ -1687,7 +1707,10 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema,
16871707
return stack, err
16881708
}
16891709
}
1690-
if ref := schema.UnevaluatedProperties; ref != nil {
1710+
if schema.UnevaluatedProperties.Has != nil && schema.UnevaluatedProperties.Schema != nil {
1711+
return stack, errors.New("unevaluatedProperties is set to both boolean and schema")
1712+
}
1713+
if ref := schema.UnevaluatedProperties.Schema; ref != nil {
16911714
v := ref.Value
16921715
if v == nil {
16931716
return stack, foundUnresolvedRef(ref.Ref)

openapi3/schema_jsonschema_validator_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,10 @@ func TestJSONSchema2020Validator_TransformRecursesInto31Fields(t *testing.T) {
344344
PrefixItems: SchemaRefs{
345345
&SchemaRef{Value: &Schema{Type: &Types{"integer"}}},
346346
},
347-
UnevaluatedItems: &SchemaRef{Value: &Schema{
347+
UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: &Schema{
348348
Type: &Types{"string"},
349349
Nullable: true,
350-
}},
350+
}}},
351351
}
352352

353353
err := schema.VisitJSON([]any{1, nil}, EnableJSONSchema2020())
@@ -360,10 +360,10 @@ func TestJSONSchema2020Validator_TransformRecursesInto31Fields(t *testing.T) {
360360
Properties: Schemas{
361361
"name": &SchemaRef{Value: &Schema{Type: &Types{"string"}}},
362362
},
363-
UnevaluatedProperties: &SchemaRef{Value: &Schema{
363+
UnevaluatedProperties: BoolSchema{Schema: &SchemaRef{Value: &Schema{
364364
Type: &Types{"string"},
365365
Nullable: true,
366-
}},
366+
}}},
367367
}
368368

369369
err := schema.VisitJSON(map[string]any{"name": "foo", "extra": nil}, EnableJSONSchema2020())

openapi3/schema_validate_31_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func TestSchemaValidate31SubSchemas(t *testing.T) {
7070
t.Run("unevaluatedItems with invalid sub-schema", func(t *testing.T) {
7171
schema := &Schema{
7272
Type: &Types{"array"},
73-
UnevaluatedItems: &SchemaRef{Value: invalidSchema},
73+
UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: invalidSchema}},
7474
}
7575
err := schema.Validate(ctx)
7676
require.Error(t, err, "should detect invalid sub-schema in unevaluatedItems")
@@ -79,7 +79,7 @@ func TestSchemaValidate31SubSchemas(t *testing.T) {
7979
t.Run("unevaluatedProperties with invalid sub-schema", func(t *testing.T) {
8080
schema := &Schema{
8181
Type: &Types{"object"},
82-
UnevaluatedProperties: &SchemaRef{Value: invalidSchema},
82+
UnevaluatedProperties: BoolSchema{Schema: &SchemaRef{Value: invalidSchema}},
8383
}
8484
err := schema.Validate(ctx)
8585
require.Error(t, err, "should detect invalid sub-schema in unevaluatedProperties")
@@ -104,7 +104,7 @@ func TestSchemaValidate31SubSchemas(t *testing.T) {
104104
{Value: validSubSchema},
105105
},
106106
Contains: &SchemaRef{Value: validSubSchema},
107-
UnevaluatedItems: &SchemaRef{Value: validSubSchema},
107+
UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: validSubSchema}},
108108
}
109109
err := schema.Validate(ctx)
110110
require.NoError(t, err, "valid sub-schemas should pass validation")

0 commit comments

Comments
 (0)