Skip to content

Commit b00ede4

Browse files
fix: resolve 8 additional OpenAPI 3.1 issues
- Add $comment keyword to Schema struct (MarshalYAML, UnmarshalJSON, IsEmpty, JSONLookup) - Fix PrefixItems type from []*SchemaRef to SchemaRefs for consistency with OneOf/AnyOf/AllOf and JSON Pointer support - Fix exclusiveBoundToBool data loss: preserve numeric bound value when converting OAS 3.1 exclusive bounds to OAS 2.0 - Auto-enable JSON Schema 2020-12 validation for OpenAPI 3.1 documents in doc.Validate() so library users don't need explicit opt-in - Add ref resolution tests for if/then/else and contentSchema - Add transform test for contentSchema with nullable nested schema - Add validate test for contentSchema with invalid sub-schema - Document breaking API changes in README (ExclusiveBound, PrefixItems) - Regenerate docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ac50aa commit b00ede4

File tree

10 files changed

+126
-30
lines changed

10 files changed

+126
-30
lines changed

.github/docs/openapi3.txt

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,17 +1672,17 @@ type Schema struct {
16721672
Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"`
16731673

16741674
// OpenAPI 3.1 / JSON Schema 2020-12 fields
1675-
Const any `json:"const,omitempty" yaml:"const,omitempty"`
1676-
Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"`
1677-
PrefixItems []*SchemaRef `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"`
1678-
Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"`
1679-
MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"`
1680-
MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"`
1681-
PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"`
1682-
DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"`
1683-
PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"`
1684-
UnevaluatedItems *SchemaRef `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"`
1685-
UnevaluatedProperties *SchemaRef `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"`
1675+
Const any `json:"const,omitempty" yaml:"const,omitempty"`
1676+
Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"`
1677+
PrefixItems SchemaRefs `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"`
1678+
Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"`
1679+
MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"`
1680+
MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"`
1681+
PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"`
1682+
DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"`
1683+
PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"`
1684+
UnevaluatedItems *SchemaRef `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"`
1685+
UnevaluatedProperties *SchemaRef `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"`
16861686

16871687
// JSON Schema 2020-12 conditional keywords
16881688
If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"`
@@ -1692,6 +1692,9 @@ type Schema struct {
16921692
// JSON Schema 2020-12 dependent requirements
16931693
DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"`
16941694

1695+
// JSON Schema 2020-12 core keywords
1696+
Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"`
1697+
16951698
// JSON Schema 2020-12 identity/referencing keywords
16961699
SchemaID string `json:"$id,omitempty" yaml:"$id,omitempty"`
16971700
Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"`

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ for _, path := range doc.Paths.InMatchingOrder() {
294294

295295
## CHANGELOG: Sub-v1 breaking API changes
296296

297+
### v0.132.0
298+
* `openapi3.Schema.ExclusiveMin` and `openapi3.Schema.ExclusiveMax` fields changed from `bool` to `ExclusiveBound` (a union type holding `*bool` for OpenAPI 3.0 or `*float64` for OpenAPI 3.1).
299+
* `openapi3.Schema.PrefixItems` field changed from `[]*SchemaRef` to `SchemaRefs`.
300+
297301
### v0.131.0
298302
* No longer `openapi3filter.RegisterBodyDecoder` the `openapi3filter.ZipFileBodyDecoder` by default.
299303

openapi2conv/openapi2_conv.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -881,8 +881,8 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components
881881
Description: schema.Value.Description,
882882
Type: paramType,
883883
Enum: schema.Value.Enum,
884-
Minimum: schema.Value.Min,
885-
Maximum: schema.Value.Max,
884+
Minimum: effectiveMin(schema.Value.Min, schema.Value.ExclusiveMin),
885+
Maximum: effectiveMax(schema.Value.Max, schema.Value.ExclusiveMax),
886886
ExclusiveMin: exclusiveBoundToBool(schema.Value.ExclusiveMin),
887887
ExclusiveMax: exclusiveBoundToBool(schema.Value.ExclusiveMax),
888888
MinLength: schema.Value.MinLength,
@@ -918,8 +918,8 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components
918918
AllowEmptyValue: schema.Value.AllowEmptyValue,
919919
Deprecated: schema.Value.Deprecated,
920920
XML: schema.Value.XML,
921-
Min: schema.Value.Min,
922-
Max: schema.Value.Max,
921+
Min: effectiveMin(schema.Value.Min, schema.Value.ExclusiveMin),
922+
Max: effectiveMax(schema.Value.Max, schema.Value.ExclusiveMax),
923923
MultipleOf: schema.Value.MultipleOf,
924924
MinLength: schema.Value.MinLength,
925925
MaxLength: schema.Value.MaxLength,
@@ -1053,8 +1053,8 @@ func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameter
10531053
Items: v2Items,
10541054
MinItems: val.MinItems,
10551055
MaxItems: val.MaxItems,
1056-
Maximum: val.Max,
1057-
Minimum: val.Min,
1056+
Maximum: effectiveMax(val.Max, val.ExclusiveMax),
1057+
Minimum: effectiveMin(val.Min, val.ExclusiveMin),
10581058
Pattern: val.Pattern,
10591059
// CollectionFormat: val.CollectionFormat,
10601060
// Format: val.Format,
@@ -1357,6 +1357,23 @@ func exclusiveBoundToBool(eb openapi3.ExclusiveBound) bool {
13571357
return *eb.Bool
13581358
}
13591359
// If it's a number (OpenAPI 3.1 style), we return true to indicate exclusivity
1360-
// The actual bound value would need to be handled separately
13611360
return eb.Value != nil
13621361
}
1362+
1363+
// effectiveMin returns the minimum value for OAS 2.0 conversion, considering ExclusiveBound.
1364+
// In OAS 3.1, exclusiveMinimum is a number. In OAS 2.0, it must be in the minimum field.
1365+
func effectiveMin(min *float64, eb openapi3.ExclusiveBound) *float64 {
1366+
if min != nil {
1367+
return min
1368+
}
1369+
// If OAS 3.1 style numeric exclusive bound with no minimum, use the bound value as minimum
1370+
return eb.Value
1371+
}
1372+
1373+
// effectiveMax returns the maximum value for OAS 2.0 conversion, considering ExclusiveBound.
1374+
func effectiveMax(max *float64, eb openapi3.ExclusiveBound) *float64 {
1375+
if max != nil {
1376+
return max
1377+
}
1378+
return eb.Value
1379+
}

openapi3/issue230_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ webhooks:
246246

247247
schema := &openapi3.Schema{
248248
Type: &openapi3.Types{"array"},
249-
PrefixItems: []*openapi3.SchemaRef{
249+
PrefixItems: openapi3.SchemaRefs{
250250
{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}},
251251
{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}},
252252
},

openapi3/loader_31_schema_refs_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,22 @@ func TestResolveSchemaRefsIn31Fields(t *testing.T) {
6464
require.NotNil(t, unProps)
6565
require.Equal(t, "#/components/schemas/StringType", unProps.UnevaluatedProperties.Ref)
6666
require.NotNil(t, unProps.UnevaluatedProperties.Value, "unevaluatedProperties $ref should be resolved")
67+
68+
// if/then/else refs should be resolved
69+
ifThenElse := schemas["ObjectWithIfThenElse"].Value
70+
require.NotNil(t, ifThenElse)
71+
require.Equal(t, "#/components/schemas/StringType", ifThenElse.If.Ref)
72+
require.NotNil(t, ifThenElse.If.Value, "if $ref should be resolved")
73+
require.Equal(t, "string", ifThenElse.If.Value.Type.Slice()[0])
74+
require.Equal(t, "#/components/schemas/IntegerType", ifThenElse.Then.Ref)
75+
require.NotNil(t, ifThenElse.Then.Value, "then $ref should be resolved")
76+
require.Equal(t, "integer", ifThenElse.Then.Value.Type.Slice()[0])
77+
require.Equal(t, "#/components/schemas/NonNegative", ifThenElse.Else.Ref)
78+
require.NotNil(t, ifThenElse.Else.Value, "else $ref should be resolved")
79+
80+
// contentSchema ref should be resolved
81+
contentSchema := schemas["StringWithContentSchema"].Value
82+
require.NotNil(t, contentSchema)
83+
require.Equal(t, "#/components/schemas/NonNegative", contentSchema.ContentSchema.Ref)
84+
require.NotNil(t, contentSchema.ContentSchema.Value, "contentSchema $ref should be resolved")
6785
}

openapi3/openapi3.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ func (doc *T) AddServers(servers ...*Server) {
187187
// Validate returns an error if T does not comply with the OpenAPI spec.
188188
// Validations Options can be provided to modify the validation behavior.
189189
func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error {
190+
// Auto-enable JSON Schema 2020-12 validation for OpenAPI 3.1 documents
191+
if doc.IsOpenAPI3_1() {
192+
opts = append([]ValidationOption{EnableJSONSchema2020Validation()}, opts...)
193+
}
190194
ctx = WithValidationOptions(ctx, opts...)
191195

192196
if doc.OpenAPI == "" {

openapi3/schema.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,17 +136,17 @@ type Schema struct {
136136
Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"`
137137

138138
// OpenAPI 3.1 / JSON Schema 2020-12 fields
139-
Const any `json:"const,omitempty" yaml:"const,omitempty"`
140-
Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"`
141-
PrefixItems []*SchemaRef `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"`
142-
Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"`
143-
MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"`
144-
MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"`
145-
PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"`
146-
DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"`
147-
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"`
139+
Const any `json:"const,omitempty" yaml:"const,omitempty"`
140+
Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"`
141+
PrefixItems SchemaRefs `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"`
142+
Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"`
143+
MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"`
144+
MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"`
145+
PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"`
146+
DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"`
147+
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"`
150150

151151
// JSON Schema 2020-12 conditional keywords
152152
If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"`
@@ -156,6 +156,9 @@ type Schema struct {
156156
// JSON Schema 2020-12 dependent requirements
157157
DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"`
158158

159+
// JSON Schema 2020-12 core keywords
160+
Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"`
161+
159162
// JSON Schema 2020-12 identity/referencing keywords
160163
SchemaID string `json:"$id,omitempty" yaml:"$id,omitempty"`
161164
Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"`
@@ -660,6 +663,9 @@ func (schema Schema) MarshalYAML() (any, error) {
660663
if x := schema.DependentRequired; len(x) != 0 {
661664
m["dependentRequired"] = x
662665
}
666+
if x := schema.Comment; x != "" {
667+
m["$comment"] = x
668+
}
663669
if x := schema.SchemaID; x != "" {
664670
m["$id"] = x
665671
}
@@ -760,6 +766,7 @@ func (schema *Schema) UnmarshalJSON(data []byte) error {
760766
delete(x.Extensions, "then")
761767
delete(x.Extensions, "else")
762768
delete(x.Extensions, "dependentRequired")
769+
delete(x.Extensions, "$comment")
763770
delete(x.Extensions, "$id")
764771
delete(x.Extensions, "$anchor")
765772
delete(x.Extensions, "$dynamicRef")
@@ -943,6 +950,8 @@ func (schema Schema) JSONLookup(token string) (any, error) {
943950
}
944951
case "dependentRequired":
945952
return schema.DependentRequired, nil
953+
case "$comment":
954+
return schema.Comment, nil
946955
case "$id":
947956
return schema.SchemaID, nil
948957
case "$anchor":
@@ -1354,6 +1363,9 @@ func (schema *Schema) IsEmpty() bool {
13541363
if len(schema.DependentRequired) != 0 {
13551364
return false
13561365
}
1366+
if schema.Comment != "" {
1367+
return false
1368+
}
13571369
if schema.SchemaID != "" || schema.Anchor != "" || schema.DynamicRef != "" || schema.DynamicAnchor != "" {
13581370
return false
13591371
}

openapi3/schema_jsonschema_validator_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,21 @@ func TestJSONSchema2020Validator_TransformRecursesInto31Fields(t *testing.T) {
369369
err := schema.VisitJSON(map[string]any{"name": "foo", "extra": nil}, EnableJSONSchema2020())
370370
require.NoError(t, err, "null should be accepted after nullable conversion in unevaluatedProperties")
371371
})
372+
373+
t.Run("contentSchema with nullable nested schema", func(t *testing.T) {
374+
schema := &Schema{
375+
Type: &Types{"string"},
376+
ContentMediaType: "application/json",
377+
ContentSchema: &SchemaRef{Value: &Schema{
378+
Type: &Types{"object"},
379+
Nullable: true,
380+
}},
381+
}
382+
383+
// contentSchema transform should not crash and should handle nullable
384+
err := schema.VisitJSON("null", EnableJSONSchema2020())
385+
require.NoError(t, err, "contentSchema transform should handle nullable nested schema")
386+
})
372387
}
373388

374389
func TestBuiltInValidatorStillWorks(t *testing.T) {

openapi3/schema_validate_31_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ func TestSchemaValidate31SubSchemas(t *testing.T) {
8585
require.Error(t, err, "should detect invalid sub-schema in unevaluatedProperties")
8686
})
8787

88+
t.Run("contentSchema with invalid sub-schema", func(t *testing.T) {
89+
schema := &Schema{
90+
Type: &Types{"string"},
91+
ContentMediaType: "application/json",
92+
ContentSchema: &SchemaRef{Value: invalidSchema},
93+
}
94+
err := schema.Validate(ctx)
95+
require.Error(t, err, "should detect invalid sub-schema in contentSchema")
96+
})
97+
8898
t.Run("valid 3.1 sub-schemas pass validation", func(t *testing.T) {
8999
validSubSchema := &Schema{Type: &Types{"string"}}
90100
schema := &Schema{

openapi3/testdata/schema31refs.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,16 @@ components:
5555
type: object
5656
unevaluatedProperties:
5757
$ref: "#/components/schemas/StringType"
58+
ObjectWithIfThenElse:
59+
type: object
60+
if:
61+
$ref: "#/components/schemas/StringType"
62+
then:
63+
$ref: "#/components/schemas/IntegerType"
64+
else:
65+
$ref: "#/components/schemas/NonNegative"
66+
StringWithContentSchema:
67+
type: string
68+
contentMediaType: application/json
69+
contentSchema:
70+
$ref: "#/components/schemas/NonNegative"

0 commit comments

Comments
 (0)