diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index bbb2991bb..da045557c 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -23,7 +23,7 @@ For OpenAPI 3.1 validation, use the JSON Schema 2020-12 validator option: Version detection is available via helper methods: - if doc.IsOpenAPI3_1() { + if doc.IsOpenAPI31OrLater() { // Handle OpenAPI 3.1 specific features } @@ -745,7 +745,7 @@ type Info struct { Origin *Origin `json:"-" yaml:"-"` Title string `json:"title" yaml:"title"` // Required - Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` // OpenAPI 3.1 + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` // OpenAPI >=3.1 Description string `json:"description,omitempty" yaml:"description,omitempty"` TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` Contact *Contact `json:"contact,omitempty" yaml:"contact,omitempty"` @@ -781,7 +781,7 @@ type License struct { // Identifier is an SPDX license expression for the API (OpenAPI 3.1) // Either url or identifier can be specified, not both - Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` + Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // OpenAPI >=3.1 } License is specified by OpenAPI/Swagger standard version 3. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#license-object @@ -1686,10 +1686,8 @@ type Schema struct { // Array-related, here for struct compactness UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` // Number-related, here for struct compactness - // In OpenAPI 3.0: boolean modifier for minimum/maximum - // In OpenAPI 3.1: number representing the actual exclusive bound - ExclusiveMin ExclusiveBound `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` - ExclusiveMax ExclusiveBound `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + ExclusiveMin ExclusiveBound `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` // Number for v3.1+ otherwise boolean + ExclusiveMax ExclusiveBound `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` // Number for v3.1+ otherwise boolean // Properties Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` @@ -1721,45 +1719,41 @@ type Schema struct { AdditionalProperties AdditionalProperties `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` - // OpenAPI 3.1 / JSON Schema 2020-12 fields - Const any `json:"const,omitempty" yaml:"const,omitempty"` - Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"` - PrefixItems SchemaRefs `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"` - Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"` - MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"` - MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"` - PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` - DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` - PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` - UnevaluatedItems BoolSchema `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"` - UnevaluatedProperties BoolSchema `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"` - - // JSON Schema 2020-12 conditional keywords - If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"` - Then *SchemaRef `json:"then,omitempty" yaml:"then,omitempty"` - Else *SchemaRef `json:"else,omitempty" yaml:"else,omitempty"` - - // JSON Schema 2020-12 dependent requirements - DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` - - // JSON Schema 2020-12 core keywords - Defs Schemas `json:"$defs,omitempty" yaml:"$defs,omitempty"` - SchemaDialect string `json:"$schema,omitempty" yaml:"$schema,omitempty"` - Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` - - // JSON Schema 2020-12 identity/referencing keywords - SchemaID string `json:"$id,omitempty" yaml:"$id,omitempty"` - Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"` - DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` - DynamicAnchor string `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"` - - // JSON Schema 2020-12 content vocabulary - ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` - ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` - ContentSchema *SchemaRef `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` + Const any `json:"const,omitempty" yaml:"const,omitempty"` // OpenAPI >=3.1 + Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"` // OpenAPI >=3.1 + PrefixItems SchemaRefs `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"` // OpenAPI >=3.1 + Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"` // OpenAPI >=3.1 + MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"` // OpenAPI >=3.1 + MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"` // OpenAPI >=3.1 + PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` // OpenAPI >=3.1 + DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` // OpenAPI >=3.1 + PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` // OpenAPI >=3.1 + UnevaluatedItems BoolSchema `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"` // OpenAPI >=3.1 + UnevaluatedProperties BoolSchema `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"` // OpenAPI >=3.1 + + If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"` // OpenAPI >=3.1 + Then *SchemaRef `json:"then,omitempty" yaml:"then,omitempty"` // OpenAPI >=3.1 + Else *SchemaRef `json:"else,omitempty" yaml:"else,omitempty"` // OpenAPI >=3.1 + + DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` // OpenAPI >=3.1 + + Defs Schemas `json:"$defs,omitempty" yaml:"$defs,omitempty"` // OpenAPI >=3.1 + SchemaDialect string `json:"$schema,omitempty" yaml:"$schema,omitempty"` // OpenAPI >=3.1 + Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` // OpenAPI >=3.1 + + SchemaID string `json:"$id,omitempty" yaml:"$id,omitempty"` // OpenAPI >=3.1 + Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"` // OpenAPI >=3.1 + DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` // OpenAPI >=3.1 + DynamicAnchor string `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"` // OpenAPI >=3.1 + + ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` // OpenAPI >=3.1 + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` // OpenAPI >=3.1 + ContentSchema *SchemaRef `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` // OpenAPI >=3.1 } Schema is specified by OpenAPI/Swagger 3.0 standard. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object + and + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#schema-object func NewAllOfSchema(schemas ...*Schema) *Schema @@ -1827,6 +1821,9 @@ func (schema *Schema) Validate(ctx context.Context, opts ...ValidationOption) er Validate returns an error if Schema does not comply with the OpenAPI spec. func (schema *Schema) VisitJSON(value any, opts ...SchemaValidationOption) error + VisitJSON applies a Schema to the given data, considering opts. + To validate data against an OpenAPIv3.1+ schema, be sure to pass the + EnableJSONSchema2020() option. func (schema *Schema) VisitJSONArray(value []any) error @@ -2107,6 +2104,8 @@ type SecurityScheme struct { } SecurityScheme is specified by OpenAPI/Swagger standard version 3. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object + and + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object func NewCSRFSecurityScheme() *SecurityScheme @@ -2292,22 +2291,16 @@ func (stringMap *StringMap[V]) UnmarshalJSON(data []byte) (err error) type T struct { Extensions map[string]any `json:"-" yaml:"-"` - OpenAPI string `json:"openapi" yaml:"openapi"` // Required - Components *Components `json:"components,omitempty" yaml:"components,omitempty"` - Info *Info `json:"info" yaml:"info"` // Required - Paths *Paths `json:"paths,omitempty" yaml:"paths,omitempty"` // Required in 3.0, optional in 3.1 - Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` - Servers Servers `json:"servers,omitempty" yaml:"servers,omitempty"` - Tags Tags `json:"tags,omitempty" yaml:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` - - // OpenAPI 3.1.x specific fields - // Webhooks are a new feature in OpenAPI 3.1 that allow APIs to define callback operations - Webhooks map[string]*PathItem `json:"webhooks,omitempty" yaml:"webhooks,omitempty"` - - // JSONSchemaDialect allows specifying the default JSON Schema dialect for Schema Objects - // See https://spec.openapis.org/oas/v3.1.0#schema-object - JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty" yaml:"jsonSchemaDialect,omitempty"` + OpenAPI string `json:"openapi" yaml:"openapi"` // Required + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + Info *Info `json:"info" yaml:"info"` // Required + Paths *Paths `json:"paths" yaml:"paths"` // Required in 3.0, optional in 3.1 + Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` + Servers Servers `json:"servers,omitempty" yaml:"servers,omitempty"` + Tags Tags `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Webhooks map[string]*PathItem `json:"webhooks,omitempty" yaml:"webhooks,omitempty"` // OpenAPI >=3.1 + JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty" yaml:"jsonSchemaDialect,omitempty"` // OpenAPI >=3.1 // Has unexported fields. } @@ -2340,11 +2333,15 @@ func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(*T, Comp doc.InternalizeRefs(context.Background(), nil) -func (doc *T) IsOpenAPI3_0() bool - IsOpenAPI3_0 returns true if the document is OpenAPI 3.0.x +func (doc *T) IsOpenAPI30() bool + IsOpenAPI30 returns whether doc is an OpenAPI document version 3.0.x. + Returns true for 3, 3.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, ... And false + for 3.1.0, 3.2, ... and for invalid strings. -func (doc *T) IsOpenAPI3_1() bool - IsOpenAPI3_1 returns true if the document is OpenAPI 3.1.x +func (doc *T) IsOpenAPI31OrLater() bool + IsOpenAPI31OrLater returns whether doc is an OpenAPI document version >=3.1. + Returns true for 3.1, 3.1.0, 3.1.1, 3.1.2, 3.2.0, ... And false for cases + where IsOpenAPI30 returns true and for invalid strings. func (doc *T) JSONLookup(token string) (any, error) JSONLookup implements @@ -2356,6 +2353,10 @@ func (doc *T) MarshalJSON() ([]byte, error) func (doc *T) MarshalYAML() (any, error) MarshalYAML returns the YAML encoding of T. +func (doc *T) OpenAPIMajorMinor() string + OpenAPIMajorMinor returns 3.y of the OpenAPI "3.y" or "3.y.z" version of the + document. Returns the empty string for invalid OpenAPI version strings. + func (doc *T) SetIntegerFormatValidator(name string, validator IntegerFormatValidator) SetIntegerFormatValidator sets a single document-scoped integer format validator. @@ -2387,14 +2388,14 @@ func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if T does not comply with the OpenAPI spec. Validations Options can be provided to modify the validation behavior. + By default, doc.OpenAPI's field dictates whether "JSON Schema Draft 2020-12" + validation is enabled. + func (doc *T) ValidateSchemaJSON(schema *Schema, value any, opts ...SchemaValidationOption) error ValidateSchemaJSON validates data against a schema using this document's format validators. This is a convenience method that automatically applies the document's format validators. -func (doc *T) Version() string - Version returns the major.minor version of the OpenAPI document - type Tag struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -2583,11 +2584,6 @@ func EnableExamplesValidation() ValidationOption EnableExamplesValidation does the opposite of DisableExamplesValidation. By default, all schema examples are validated. -func EnableJSONSchema2020Validation() ValidationOption - EnableJSONSchema2020Validation enables JSON Schema 2020-12 compliant - validation for OpenAPI 3.1 documents. This option should be used with - doc.Validate(). - func EnableSchemaDefaultsValidation() ValidationOption EnableSchemaDefaultsValidation does the opposite of DisableSchemaDefaultsValidation. By default, schema default values are @@ -2603,6 +2599,10 @@ func EnableSchemaPatternValidation() ValidationOption DisableSchemaPatternValidation. By default, schema pattern validation is enabled. +func IsOpenAPI31OrLater() ValidationOption + IsOpenAPI31OrLater enables "JSON Schema Draft 2020-12"-compliant validation + (for OpenAPI 3.1 documents). + func ProhibitExtensionsWithRef() ValidationOption ProhibitExtensionsWithRef causes the validation to return an error if extensions (fields starting with 'x-') are found as siblings for $ref diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0e64cbed6..ee095cec2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -138,7 +138,7 @@ jobs: - if: runner.os == 'Linux' name: Missing specification object link to definition run: | - [[ 31 -eq $(git grep -InE '^// See https:.+OpenAPI-Specification.+3[.]0[.]3[.]md#.+bject$' openapi3/*.go | grep -v _test.go | grep -v doc.go | wc -l) ]] + [[ 31 -eq $(git grep -InE '^// See https:.+OpenAPI-Specification.+3[0-9.]+md#.+bject$' openapi3/*.go | grep -v _test.go | grep -v doc.go | wc -l) ]] - if: runner.os == 'Linux' name: Missing validation of unknown fields in extensions diff --git a/cmd/validate/main.go b/cmd/validate/main.go index 410937403..fc442f825 100644 --- a/cmd/validate/main.go +++ b/cmd/validate/main.go @@ -68,10 +68,6 @@ func main() { } var opts []openapi3.ValidationOption - if doc.IsOpenAPI3_1() { - log.Println("Detected OpenAPI 3.1 document, enabling JSON Schema 2020-12 validation") - opts = append(opts, openapi3.EnableJSONSchema2020Validation()) - } if !*defaults { opts = append(opts, openapi3.DisableSchemaDefaultsValidation()) } diff --git a/openapi3/doc.go b/openapi3/doc.go index 73d5aee1c..4179030b5 100644 --- a/openapi3/doc.go +++ b/openapi3/doc.go @@ -19,7 +19,7 @@ // // Version detection is available via helper methods: // -// if doc.IsOpenAPI3_1() { +// if doc.IsOpenAPI31OrLater() { // // Handle OpenAPI 3.1 specific features // } package openapi3 diff --git a/openapi3/example_jsonschema2020_test.go b/openapi3/example_jsonschema2020_test.go index 08771c655..e3542c6f4 100644 --- a/openapi3/example_jsonschema2020_test.go +++ b/openapi3/example_jsonschema2020_test.go @@ -239,14 +239,12 @@ func Example_comparingValidators() { testValue := "test" // Test with built-in validator (no option) - err1 := schema.VisitJSON(testValue) - if err1 != nil { + if err := schema.VisitJSON(testValue); err != nil { fmt.Println("built-in validator: rejected") } // Test with JSON Schema 2020-12 validator - err2 := schema.VisitJSON(testValue, openapi3.EnableJSONSchema2020()) - if err2 != nil { + if err := schema.VisitJSON(testValue, openapi3.EnableJSONSchema2020()); err != nil { fmt.Println("visit JSON Schema 2020-12 validator: rejected") } diff --git a/openapi3/example_validation.go b/openapi3/example_validation.go index 5c0568656..1ecdcf3b8 100644 --- a/openapi3/example_validation.go +++ b/openapi3/example_validation.go @@ -3,7 +3,7 @@ package openapi3 import "context" func validateExampleValue(ctx context.Context, input any, schema *Schema) error { - opts := make([]SchemaValidationOption, 0, 2) + opts := []SchemaValidationOption{MultiErrors()} vo := getValidationOptions(ctx) if vo.examplesValidationAsReq { @@ -16,7 +16,5 @@ func validateExampleValue(ctx context.Context, input any, schema *Schema) error opts = append(opts, EnableJSONSchema2020()) } - opts = append(opts, MultiErrors()) - return schema.VisitJSON(input, opts...) } diff --git a/openapi3/info.go b/openapi3/info.go index bf60b2ca1..7aa4a7940 100644 --- a/openapi3/info.go +++ b/openapi3/info.go @@ -15,7 +15,7 @@ type Info struct { Origin *Origin `json:"-" yaml:"-"` Title string `json:"title" yaml:"title"` // Required - Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` // OpenAPI 3.1 + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` // OpenAPI >=3.1 Description string `json:"description,omitempty" yaml:"description,omitempty"` TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` Contact *Contact `json:"contact,omitempty" yaml:"contact,omitempty"` @@ -40,7 +40,6 @@ func (info *Info) MarshalYAML() (any, error) { m := make(map[string]any, 7+len(info.Extensions)) maps.Copy(m, info.Extensions) m["title"] = info.Title - // OpenAPI 3.1 field if x := info.Summary; x != "" { m["summary"] = x } @@ -69,7 +68,7 @@ func (info *Info) UnmarshalJSON(data []byte) error { } _ = json.Unmarshal(data, &x.Extensions) delete(x.Extensions, "title") - delete(x.Extensions, "summary") // OpenAPI 3.1 + delete(x.Extensions, "summary") delete(x.Extensions, "description") delete(x.Extensions, "termsOfService") delete(x.Extensions, "contact") @@ -86,6 +85,10 @@ func (info *Info) UnmarshalJSON(data []byte) error { func (info *Info) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + if info.Summary != "" && !getValidationOptions(ctx).isOpenAPI31OrLater { + return errFieldFor31Plus("summary") + } + if contact := info.Contact; contact != nil { if err := contact.Validate(ctx); err != nil { return err diff --git a/openapi3/info_test.go b/openapi3/info_test.go new file mode 100644 index 000000000..f3aca8f03 --- /dev/null +++ b/openapi3/info_test.go @@ -0,0 +1,45 @@ +package openapi3_test + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" +) + +func TestValidateInfo_SummaryIn30(t *testing.T) { + spec := []byte(` +openapi: '3' +paths: {} +info: + title: An API + version: 1.2.3.4 + summary: bla +`) + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(spec) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.ErrorContains(t, err, "invalid info") + require.ErrorContains(t, err, "field summary") +} + +func TestValidateInfo_SummaryIn31(t *testing.T) { + spec := []byte(` +openapi: '3.1' +paths: {} +info: + title: An API + version: 1.2.3.4 + summary: bla +`) + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(spec) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) +} diff --git a/openapi3/issue230_test.go b/openapi3/issue230_test.go index bcede4275..45a3ef1f2 100644 --- a/openapi3/issue230_test.go +++ b/openapi3/issue230_test.go @@ -46,9 +46,9 @@ paths: require.NotNil(t, doc) // Verify version detection - require.True(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - require.Equal(t, "3.0", doc.Version()) + require.True(t, doc.IsOpenAPI30()) + require.False(t, doc.IsOpenAPI31OrLater()) + require.Equal(t, "3.0", doc.OpenAPIMajorMinor()) // Verify structure require.Equal(t, "Test API", doc.Info.Title) @@ -167,9 +167,9 @@ webhooks: require.NotNil(t, doc) // Verify version detection - require.True(t, doc.IsOpenAPI3_1()) - require.False(t, doc.IsOpenAPI3_0()) - require.Equal(t, "3.1", doc.Version()) + require.True(t, doc.IsOpenAPI31OrLater()) + require.False(t, doc.IsOpenAPI30()) + require.Equal(t, "3.1", doc.OpenAPIMajorMinor()) // Verify 3.1 fields require.NotNil(t, doc.Webhooks) @@ -496,7 +496,7 @@ func TestMigrationScenarios(t *testing.T) { err := json.Unmarshal(spec30, &doc30) require.NoError(t, err) - if doc30.IsOpenAPI3_1() { + if doc30.IsOpenAPI31OrLater() { } // Simulate loading 3.1 document @@ -505,7 +505,7 @@ func TestMigrationScenarios(t *testing.T) { err = json.Unmarshal(spec31, &doc31) require.NoError(t, err) - if doc31.IsOpenAPI3_1() { + if doc31.IsOpenAPI31OrLater() { } // Cleanup @@ -539,11 +539,13 @@ func TestEdgeCases(t *testing.T) { } // Nil webhooks should not serialize - data30, _ := json.Marshal(doc30) + data30, err := json.Marshal(doc30) + require.NoError(t, err) require.NotContains(t, string(data30), "webhooks") // Empty webhooks should not serialize - data31, _ := json.Marshal(doc31Empty) + data31, err := json.Marshal(doc31Empty) + require.NoError(t, err) require.NotContains(t, string(data31), "webhooks") }) @@ -561,21 +563,6 @@ func TestEdgeCases(t *testing.T) { require.Contains(t, string(data), `"identifier"`) }) - t.Run("version detection with edge cases", func(t *testing.T) { - var doc *openapi3.T - require.False(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - require.Equal(t, "", doc.Version()) - - doc = &openapi3.T{} - require.False(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - - doc = &openapi3.T{OpenAPI: "3.x"} - require.False(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - }) - t.Run("schema without type permits any type", func(t *testing.T) { schema := &openapi3.Schema{} diff --git a/openapi3/issue495_test.go b/openapi3/issue495_test.go index b4d4a202f..a07acdffd 100644 --- a/openapi3/issue495_test.go +++ b/openapi3/issue495_test.go @@ -121,7 +121,10 @@ paths: doc, err := sl.LoadFromData(spec) require.NoError(t, err) - err = doc.Validate(sl.Context) + // draft-04 meta-schema contains $id and $schema; in OAS 3.0 these require + // opt-in via AllowExtraSiblingFields so the test can assert its real target + // (the unresolved inner "#" ref). + err = doc.Validate(sl.Context, AllowExtraSiblingFields("$id", "$schema")) require.ErrorContains(t, err, `found unresolved ref: "#"`) } @@ -157,6 +160,9 @@ paths: doc, err := sl.LoadFromData(spec) require.NoError(t, err) - err = doc.Validate(sl.Context) + // draft-04 meta-schema contains $id and $schema; in OAS 3.0 these require + // opt-in via AllowExtraSiblingFields so the test can assert its real target + // (the unresolved inner "#" ref). + err = doc.Validate(sl.Context, AllowExtraSiblingFields("$id", "$schema")) require.ErrorContains(t, err, `found unresolved ref: "#"`) } diff --git a/openapi3/issue513_test.go b/openapi3/issue513_test.go index 38454f672..d26fb7667 100644 --- a/openapi3/issue513_test.go +++ b/openapi3/issue513_test.go @@ -2,13 +2,14 @@ package openapi3 import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/require" ) func TestExtraSiblingsInRemoteRef(t *testing.T) { - spec := []byte(` + spec := ` openapi: 3.0.1 servers: - url: http://localhost:5000 @@ -31,7 +32,7 @@ paths: application/json: schema: $ref: http://schemas.sentex.io/store/categories.json -`[1:]) +` // When that site fails to respond: // see https://github.com/getkin/kin-openapi/issues/495 @@ -62,14 +63,19 @@ paths: // "maximum": 30 // } - sl := NewLoader() - sl.IsExternalRefsAllowed = true + for _, majmin := range []string{"'3.0'", "'3.1'"} { + t.Run(majmin, func(t *testing.T) { + t.Parallel() + sl := NewLoader() + sl.IsExternalRefsAllowed = true - doc, err := sl.LoadFromData(spec) - require.NoError(t, err) + doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.1", majmin))) + require.NoError(t, err) - err = doc.Validate(sl.Context, AllowExtraSiblingFields("$id", "$schema")) - require.NoError(t, err) + err = doc.Validate(sl.Context, AllowExtraSiblingFields("$id", "$schema")) + require.NoError(t, err) + }) + } } func TestIssue513OKWithExtension(t *testing.T) { @@ -104,14 +110,20 @@ components: description: A detailed message describing the error. type: string `[1:] - sl := NewLoader() - doc, err := sl.LoadFromData([]byte(spec)) - require.NoError(t, err) - err = doc.Validate(sl.Context) - require.NoError(t, err) - data, err := json.Marshal(doc) - require.NoError(t, err) - require.Contains(t, string(data), `x-my-extension`) + + for _, majmin := range []string{"3.0", "3.1"} { + t.Run(majmin, func(t *testing.T) { + t.Parallel() + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.NoError(t, err) + data, err := json.Marshal(doc) + require.NoError(t, err) + require.Contains(t, string(data), `x-my-extension`) + }) + } } func TestIssue513KOHasExtraFieldSchema(t *testing.T) { @@ -149,12 +161,18 @@ components: description: A detailed message describing the error. type: string `[1:] - sl := NewLoader() - doc, err := sl.LoadFromData([]byte(spec)) - require.NoError(t, err) - require.Contains(t, doc.Paths.Value("/v1/operation").Delete.Responses.Default().Value.Extensions, `x-my-extension`) - err = doc.Validate(sl.Context) - require.ErrorContains(t, err, `extra sibling fields: [schema]`) + + for _, majmin := range []string{"3.0", "3.1"} { + t.Run(majmin, func(t *testing.T) { + t.Parallel() + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) + require.NoError(t, err) + require.Contains(t, doc.Paths.Value("/v1/operation").Delete.Responses.Default().Value.Extensions, `x-my-extension`) + err = doc.Validate(sl.Context) + require.ErrorContains(t, err, `extra sibling fields: [schema]`) + }) + } } func TestIssue513KOMixesRefAlongWithOtherFieldsDisallowed(t *testing.T) { @@ -190,11 +208,17 @@ components: description: A detailed message describing the error. type: string `[1:] - sl := NewLoader() - doc, err := sl.LoadFromData([]byte(spec)) - require.NoError(t, err) - err = doc.Validate(sl.Context) - require.ErrorContains(t, err, `extra sibling fields: [description]`) + + for _, majmin := range []string{"3.0", "3.1"} { + t.Run(majmin, func(t *testing.T) { + t.Parallel() + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.ErrorContains(t, err, `extra sibling fields: [description]`) + }) + } } func TestIssue513KOMixesRefAlongWithOtherFieldsAllowed(t *testing.T) { @@ -230,9 +254,15 @@ components: description: A detailed message describing the error. type: string `[1:] - sl := NewLoader() - doc, err := sl.LoadFromData([]byte(spec)) - require.NoError(t, err) - err = doc.Validate(sl.Context, AllowExtraSiblingFields("description")) - require.NoError(t, err) + + for _, majmin := range []string{"3.0", "3.1"} { + t.Run(majmin, func(t *testing.T) { + t.Parallel() + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) + require.NoError(t, err) + err = doc.Validate(sl.Context, AllowExtraSiblingFields("description")) + require.NoError(t, err) + }) + } } diff --git a/openapi3/issue601_test.go b/openapi3/issue601_test.go index 09c98eeba..e867d87a8 100644 --- a/openapi3/issue601_test.go +++ b/openapi3/issue601_test.go @@ -17,11 +17,13 @@ func TestIssue601(t *testing.T) { doc, err := sl.LoadFromFile("testdata/lxkns.yaml") require.NoError(t, err) - err = doc.Validate(sl.Context) + // lxkns.yaml has `description` siblings alongside $ref (invalid in OAS 3.0). + // Allow them so we can exercise the example-type validation this test actually targets. + err = doc.Validate(sl.Context, AllowExtraSiblingFields("description")) require.ErrorContains(t, err, `invalid components: schema "DiscoveryResult": invalid example: Error at "/type": property "type" is missing`) require.ErrorContains(t, err, `| Error at "/nsid": property "nsid" is missing`) - err = doc.Validate(sl.Context, DisableExamplesValidation()) + err = doc.Validate(sl.Context, AllowExtraSiblingFields("description"), DisableExamplesValidation()) require.NoError(t, err) // Now let's remove all the invalid parts @@ -29,6 +31,6 @@ func TestIssue601(t *testing.T) { schema.Value.Example = nil } - err = doc.Validate(sl.Context) + err = doc.Validate(sl.Context, AllowExtraSiblingFields("description")) require.NoError(t, err) } diff --git a/openapi3/license.go b/openapi3/license.go index ec13c87ba..de86cf6de 100644 --- a/openapi3/license.go +++ b/openapi3/license.go @@ -19,7 +19,7 @@ type License struct { // Identifier is an SPDX license expression for the API (OpenAPI 3.1) // Either url or identifier can be specified, not both - Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` + Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // OpenAPI >=3.1 } // MarshalJSON returns the JSON encoding of License. @@ -33,13 +33,12 @@ func (license License) MarshalJSON() ([]byte, error) { // MarshalYAML returns the YAML encoding of License. func (license License) MarshalYAML() (any, error) { - m := make(map[string]any, 2+len(license.Extensions)) + m := make(map[string]any, 3+len(license.Extensions)) maps.Copy(m, license.Extensions) m["name"] = license.Name if x := license.URL; x != "" { m["url"] = x } - // OpenAPI 3.1 field if x := license.Identifier; x != "" { m["identifier"] = x } @@ -56,7 +55,7 @@ func (license *License) UnmarshalJSON(data []byte) error { _ = json.Unmarshal(data, &x.Extensions) delete(x.Extensions, "name") delete(x.Extensions, "url") - delete(x.Extensions, "identifier") // OpenAPI 3.1 + delete(x.Extensions, "identifier") if len(x.Extensions) == 0 { x.Extensions = nil } @@ -68,6 +67,10 @@ func (license *License) UnmarshalJSON(data []byte) error { func (license *License) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + if license.Identifier != "" && !getValidationOptions(ctx).isOpenAPI31OrLater { + return errFieldFor31Plus("identifier") + } + if license.Name == "" { return errors.New("value of license name must be a non-empty string") } diff --git a/openapi3/loader.go b/openapi3/loader.go index ff29980ca..4813c33b6 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -270,14 +270,11 @@ func (loader *Loader) ResolveRefsIn(doc *T, location *url.URL) (err error) { } } - // Visit all webhooks (OpenAPI 3.1) for _, name := range componentNames(doc.Webhooks) { - pathItem := doc.Webhooks[name] - if pathItem == nil { - continue - } - if err = loader.resolvePathItemRef(doc, pathItem, location); err != nil { - return + if pathItem := doc.Webhooks[name]; pathItem != nil { + if err = loader.resolvePathItemRef(doc, pathItem, location); err != nil { + return + } } } @@ -958,7 +955,7 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat // they augment the referenced schema (e.g. deprecated:true alongside $ref). // Only apply for OAS 3.1+ — in 3.0 $ref replaces its entire object and siblings // are (validly) ignored. - if strings.HasPrefix(doc.OpenAPI, "3.1") && component.sibling != nil && component.Value != nil { + if doc.IsOpenAPI31OrLater() && component.sibling != nil && component.Value != nil { // Work on a copy so we don't mutate a schema shared by other references. schemaCopy := *component.Value applySiblingSchemaFields(&schemaCopy, component.sibling, component.extra) @@ -1360,22 +1357,128 @@ func unescapeRefString(ref string) string { func applySiblingSchemaFields(dst, sibling *Schema, presentFields []string) { for _, field := range presentFields { switch field { - case "deprecated": - dst.Deprecated = sibling.Deprecated - case "description": - dst.Description = sibling.Description + case "oneOf": + dst.OneOf = sibling.OneOf + case "anyOf": + dst.AnyOf = sibling.AnyOf + case "allOf": + dst.AllOf = sibling.AllOf + case "not": + dst.Not = sibling.Not + case "type": + dst.Type = sibling.Type case "title": dst.Title = sibling.Title - case "readOnly": - dst.ReadOnly = sibling.ReadOnly - case "writeOnly": - dst.WriteOnly = sibling.WriteOnly + case "format": + dst.Format = sibling.Format + case "description": + dst.Description = sibling.Description + case "enum": + dst.Enum = sibling.Enum + case "default": + dst.Default = sibling.Default case "example": dst.Example = sibling.Example case "externalDocs": dst.ExternalDocs = sibling.ExternalDocs - case "default": - dst.Default = sibling.Default + case "uniqueItems": + dst.UniqueItems = sibling.UniqueItems + case "exclusiveMinimum": + dst.ExclusiveMin = sibling.ExclusiveMin + case "exclusiveMaximum": + dst.ExclusiveMax = sibling.ExclusiveMax + case "nullable": + dst.Nullable = sibling.Nullable + case "readOnly": + dst.ReadOnly = sibling.ReadOnly + case "writeOnly": + dst.WriteOnly = sibling.WriteOnly + case "allowEmptyValue": + dst.AllowEmptyValue = sibling.AllowEmptyValue + case "deprecated": + dst.Deprecated = sibling.Deprecated + case "xml": + dst.XML = sibling.XML + case "minimum": + dst.Min = sibling.Min + case "maximum": + dst.Max = sibling.Max + case "multipleOf": + dst.MultipleOf = sibling.MultipleOf + case "minLength": + dst.MinLength = sibling.MinLength + case "maxLength": + dst.MaxLength = sibling.MaxLength + case "pattern": + dst.Pattern = sibling.Pattern + case "minItems": + dst.MinItems = sibling.MinItems + case "maxItems": + dst.MaxItems = sibling.MaxItems + case "items": + dst.Items = sibling.Items + case "required": + dst.Required = sibling.Required + case "properties": + dst.Properties = sibling.Properties + case "minProperties": + dst.MinProps = sibling.MinProps + case "maxProperties": + dst.MaxProps = sibling.MaxProps + case "additionalProperties": + dst.AdditionalProperties = sibling.AdditionalProperties + case "discriminator": + dst.Discriminator = sibling.Discriminator + case "const": + dst.Const = sibling.Const + case "examples": + dst.Examples = sibling.Examples + case "prefixItems": + dst.PrefixItems = sibling.PrefixItems + case "contains": + dst.Contains = sibling.Contains + case "minContains": + dst.MinContains = sibling.MinContains + case "maxContains": + dst.MaxContains = sibling.MaxContains + case "patternProperties": + dst.PatternProperties = sibling.PatternProperties + case "dependentSchemas": + dst.DependentSchemas = sibling.DependentSchemas + case "propertyNames": + dst.PropertyNames = sibling.PropertyNames + case "unevaluatedItems": + dst.UnevaluatedItems = sibling.UnevaluatedItems + case "unevaluatedProperties": + dst.UnevaluatedProperties = sibling.UnevaluatedProperties + case "if": + dst.If = sibling.If + case "then": + dst.Then = sibling.Then + case "else": + dst.Else = sibling.Else + case "dependentRequired": + dst.DependentRequired = sibling.DependentRequired + case "$defs": + dst.Defs = sibling.Defs + case "$schema": + dst.SchemaDialect = sibling.SchemaDialect + case "$comment": + dst.Comment = sibling.Comment + case "$id": + dst.SchemaID = sibling.SchemaID + case "$anchor": + dst.Anchor = sibling.Anchor + case "$dynamicRef": + dst.DynamicRef = sibling.DynamicRef + case "$dynamicAnchor": + dst.DynamicAnchor = sibling.DynamicAnchor + case "contentMediaType": + dst.ContentMediaType = sibling.ContentMediaType + case "contentEncoding": + dst.ContentEncoding = sibling.ContentEncoding + case "contentSchema": + dst.ContentSchema = sibling.ContentSchema } } } diff --git a/openapi3/loader_31_conditional_test.go b/openapi3/loader_31_conditional_test.go index c6673bbfd..44c912377 100644 --- a/openapi3/loader_31_conditional_test.go +++ b/openapi3/loader_31_conditional_test.go @@ -1,13 +1,14 @@ -package openapi3 +package openapi3_test import ( "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestResolveConditionalSchemaRefs(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() doc, err := loader.LoadFromFile("testdata/schema31_conditional.yml") require.NoError(t, err) diff --git a/openapi3/loader_31_schema_refs_test.go b/openapi3/loader_31_schema_refs_test.go index 04b24322b..4fa65bbdf 100644 --- a/openapi3/loader_31_schema_refs_test.go +++ b/openapi3/loader_31_schema_refs_test.go @@ -1,8 +1,11 @@ -package openapi3 +package openapi3_test import ( + "fmt" + "strings" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -19,27 +22,80 @@ import ( // $ref: "#/components/schemas/PingStatus" // // should result in a SchemaRef whose Value has Deprecated==true. -func TestOAS31_RefSiblingKeyword(t *testing.T) { - loader := NewLoader() - doc, err := loader.LoadFromFile("testdata/schema31-ref-siblings.yml") - require.NoError(t, err) - - pingResp := doc.Components.Schemas["PingResponse"].Value - require.NotNil(t, pingResp) - - statusRef := pingResp.Properties["status"] - require.NotNil(t, statusRef) - - // The $ref should still be resolved. - require.NotNil(t, statusRef.Value, "$ref to PingStatus should be resolved") - require.Equal(t, "string", statusRef.Value.Type.Slice()[0], "$ref target type should be string") - - // The sibling deprecated:true must survive — not be discarded because $ref is present. - require.True(t, statusRef.Value.Deprecated, "deprecated:true sibling to $ref must be honoured in OAS 3.1") +func TestSchemaRefSiblingKeyword(t *testing.T) { + spec := ` +openapi: "3.1.0" +info: + title: Ref Sibling Test + version: "1.0" +paths: + /ping: + get: + operationId: getPing + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/PingResponse" +components: + schemas: + PingStatus: + type: string + enum: [ok, error] + PingResponse: + type: object + required: [message, status] + properties: + message: + type: string + status: + deprecated: true # sibling keyword alongside $ref — valid in OAS 3.1, ignored in 3.0 + $ref: "#/components/schemas/PingStatus" +` + + type testcase struct { + oas string + siblings, valid bool + } + + for _, tc := range []testcase{ + {oas: "3.1", siblings: true, valid: true}, {oas: "3.0"}, {oas: "3.0", valid: true}, + } { + t.Run(fmt.Sprintf("%v", tc), func(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + + doc, err := loader.LoadFromData([]byte(strings.ReplaceAll(spec, "3.1.0", tc.oas))) + require.NoError(t, err) + + statusRef := doc.Components.Schemas["PingResponse"].Value.Properties["status"] + require.NotNil(t, statusRef) + + // The $ref should still be resolved. + require.Equal(t, statusRef.Ref, "#/components/schemas/PingStatus") + require.NotNil(t, statusRef.Value, "$ref to PingStatus should be resolved") + require.Equal(t, "string", statusRef.Value.Type.Slice()[0], "$ref target type should be string") + + require.Equal(t, tc.siblings, statusRef.Value.Deprecated, "deprecated:true sibling to $ref must be honoured in OAS 3.1") + + var valopts []openapi3.ValidationOption + if tc.valid && !tc.siblings { // For this test case let's try the option that allows siblings for 3.0 + valopts = append(valopts, openapi3.AllowExtraSiblingFields("deprecated")) + } + err = doc.Validate(loader.Context, valopts...) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err, "Siblings to $ref is not valid OpenAPIv3.0 (by default)") + } + }) + } } func TestResolveSchemaRefsIn31Fields(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() doc, err := loader.LoadFromFile("testdata/schema31refs.yml") require.NoError(t, err) diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go index b130c1bd1..701ebbe6d 100644 --- a/openapi3/openapi3.go +++ b/openapi3/openapi3.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "net/url" + "slices" "github.com/go-openapi/jsonpointer" ) @@ -17,22 +18,16 @@ import ( type T struct { Extensions map[string]any `json:"-" yaml:"-"` - OpenAPI string `json:"openapi" yaml:"openapi"` // Required - Components *Components `json:"components,omitempty" yaml:"components,omitempty"` - Info *Info `json:"info" yaml:"info"` // Required - Paths *Paths `json:"paths,omitempty" yaml:"paths,omitempty"` // Required in 3.0, optional in 3.1 - Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` - Servers Servers `json:"servers,omitempty" yaml:"servers,omitempty"` - Tags Tags `json:"tags,omitempty" yaml:"tags,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` - - // OpenAPI 3.1.x specific fields - // Webhooks are a new feature in OpenAPI 3.1 that allow APIs to define callback operations - Webhooks map[string]*PathItem `json:"webhooks,omitempty" yaml:"webhooks,omitempty"` - - // JSONSchemaDialect allows specifying the default JSON Schema dialect for Schema Objects - // See https://spec.openapis.org/oas/v3.1.0#schema-object - JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty" yaml:"jsonSchemaDialect,omitempty"` + OpenAPI string `json:"openapi" yaml:"openapi"` // Required + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + Info *Info `json:"info" yaml:"info"` // Required + Paths *Paths `json:"paths" yaml:"paths"` // Required in 3.0, optional in 3.1 + Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` + Servers Servers `json:"servers,omitempty" yaml:"servers,omitempty"` + Tags Tags `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Webhooks map[string]*PathItem `json:"webhooks,omitempty" yaml:"webhooks,omitempty"` // OpenAPI >=3.1 + JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty" yaml:"jsonSchemaDialect,omitempty"` // OpenAPI >=3.1 visited visitedComponent url *url.URL @@ -44,30 +39,48 @@ type T struct { integerFormats map[string]IntegerFormatValidator } -var _ jsonpointer.JSONPointable = (*T)(nil) +// IsOpenAPI30 returns whether doc is an OpenAPI document version 3.0.x. +// Returns true for 3, 3.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, ... +// And false for 3.1.0, 3.2, ... and for invalid strings. +func (doc *T) IsOpenAPI30() bool { + return doc.OpenAPIMajorMinor() == "3.0" +} + +// IsOpenAPI31OrLater returns whether doc is an OpenAPI document version >=3.1. +// Returns true for 3.1, 3.1.0, 3.1.1, 3.1.2, 3.2.0, ... +// And false for cases where IsOpenAPI30 returns true and for invalid strings. +func (doc *T) IsOpenAPI31OrLater() bool { + return slices.Contains([]string{"3.1", "3.2"}, doc.OpenAPIMajorMinor()) +} -// IsOpenAPI3_0 returns true if the document is OpenAPI 3.0.x -func (doc *T) IsOpenAPI3_0() bool { - return doc.Version() == "3.0" +func errFieldFor31Plus(field string) error { + return fmt.Errorf("field %s is for OpenAPI >=3.1", field) } -// IsOpenAPI3_1 returns true if the document is OpenAPI 3.1.x -func (doc *T) IsOpenAPI3_1() bool { - return doc.Version() == "3.1" +func errValueOfFieldFor31Plus(value any, field string) error { + return fmt.Errorf("value %q of field %s is for OpenAPI >=3.1", value, field) } -// Version returns the major.minor version of the OpenAPI document -func (doc *T) Version() string { - if doc == nil || doc.OpenAPI == "" { +// OpenAPIMajorMinor returns 3.y of the OpenAPI "3.y" or "3.y.z" version of the document. +// Returns the empty string for invalid OpenAPI version strings. +func (doc *T) OpenAPIMajorMinor() string { + if doc == nil { return "" } - // Extract major.minor (e.g., "3.0" from "3.0.3") - if len(doc.OpenAPI) >= 3 { - return doc.OpenAPI[0:3] + switch doc.OpenAPI { + case "3", "3.0", "3.0.0", "3.0.1", "3.0.2", "3.0.3", "3.0.4": + return "3.0" + case "3.1", "3.1.0", "3.1.1", "3.1.2": + return "3.1" + case "3.2", "3.2.0": + return "3.2" + default: + return "" } - return doc.OpenAPI } +var _ jsonpointer.JSONPointable = (*T)(nil) + // JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable func (doc *T) JSONLookup(token string) (any, error) { switch token { @@ -111,16 +124,14 @@ func (doc *T) MarshalYAML() (any, error) { if doc == nil { return nil, nil } - m := make(map[string]any, 4+len(doc.Extensions)) + m := make(map[string]any, 10+len(doc.Extensions)) maps.Copy(m, doc.Extensions) m["openapi"] = doc.OpenAPI if x := doc.Components; x != nil { m["components"] = x } m["info"] = doc.Info - if doc.Paths != nil { - m["paths"] = doc.Paths - } + m["paths"] = doc.Paths if x := doc.Security; len(x) != 0 { m["security"] = x } @@ -133,7 +144,6 @@ func (doc *T) MarshalYAML() (any, error) { if x := doc.ExternalDocs; x != nil { m["externalDocs"] = x } - // OpenAPI 3.1 fields if x := doc.Webhooks; len(x) != 0 { m["webhooks"] = x } @@ -159,7 +169,6 @@ func (doc *T) UnmarshalJSON(data []byte) error { delete(x.Extensions, "servers") delete(x.Extensions, "tags") delete(x.Extensions, "externalDocs") - // OpenAPI 3.1 fields delete(x.Extensions, "webhooks") delete(x.Extensions, "jsonSchemaDialect") if len(x.Extensions) == 0 { @@ -250,10 +259,12 @@ func (doc *T) GetSchemaValidationOptions() []SchemaValidationOption { // Validate returns an error if T does not comply with the OpenAPI spec. // Validations Options can be provided to modify the validation behavior. +// +// By default, doc.OpenAPI's field dictates whether "JSON Schema Draft 2020-12" validation +// is enabled. func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { - // Auto-enable JSON Schema 2020-12 validation for OpenAPI 3.1 documents - if doc.IsOpenAPI3_1() { - opts = append([]ValidationOption{EnableJSONSchema2020Validation()}, opts...) + if doc.IsOpenAPI31OrLater() { + opts = append(opts, IsOpenAPI31OrLater()) } ctx = WithValidationOptions(ctx, opts...) @@ -261,6 +272,13 @@ func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { return errors.New("value of openapi must be a non-empty string") } + if doc.Webhooks != nil && !doc.IsOpenAPI31OrLater() { + return errFieldFor31Plus("webhooks") + } + if doc.JSONSchemaDialect != "" && !doc.IsOpenAPI31OrLater() { + return errFieldFor31Plus("jsonschemadialect") + } + var wrap func(error) error wrap = func(e error) error { return fmt.Errorf("invalid components: %w", e) } @@ -284,7 +302,7 @@ func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { if err := v.Validate(ctx); err != nil { return wrap(err) } - } else if !doc.IsOpenAPI3_1() { + } else if doc.IsOpenAPI30() { return wrap(errors.New("must be an object")) } @@ -316,28 +334,25 @@ func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { } } - // OpenAPI 3.1 jsonSchemaDialect validation + wrap = func(e error) error { return fmt.Errorf("invalid webhooks: %w", e) } + for _, name := range componentNames(doc.Webhooks) { + pathItem := doc.Webhooks[name] + if pathItem == nil { + return wrap(fmt.Errorf("webhook %q is nil", name)) + } + if err := pathItem.Validate(ctx); err != nil { + return wrap(fmt.Errorf("webhook %q: %w", name, err)) + } + } + + wrap = func(e error) error { return fmt.Errorf("invalid jsonSchemaDialect: %w", e) } if doc.JSONSchemaDialect != "" { u, err := url.Parse(doc.JSONSchemaDialect) if err != nil { - return fmt.Errorf("invalid jsonSchemaDialect: %w", err) + return wrap(err) } if u.Scheme == "" { - return fmt.Errorf("invalid jsonSchemaDialect: must be an absolute URI with a scheme") - } - } - - // OpenAPI 3.1 webhooks validation - if doc.Webhooks != nil { - wrap = func(e error) error { return fmt.Errorf("invalid webhooks: %w", e) } - for _, name := range componentNames(doc.Webhooks) { - pathItem := doc.Webhooks[name] - if pathItem == nil { - return wrap(fmt.Errorf("webhook %q is nil", name)) - } - if err := pathItem.Validate(ctx); err != nil { - return wrap(fmt.Errorf("webhook %q: %w", name, err)) - } + return wrap(errors.New("must be an absolute URI with a scheme")) } } diff --git a/openapi3/openapi3_test.go b/openapi3/openapi3_test.go index bb42df569..587d59d5e 100644 --- a/openapi3/openapi3_test.go +++ b/openapi3/openapi3_test.go @@ -1,18 +1,20 @@ -package openapi3 +package openapi3_test import ( "context" "encoding/json" + "fmt" "strings" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/oasdiff/yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRefsJSON(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() t.Log("Marshal *T to JSON") data, err := json.Marshal(spec()) @@ -20,7 +22,7 @@ func TestRefsJSON(t *testing.T) { require.NotEmpty(t, data) t.Log("Unmarshal *T from JSON") - docA := &T{} + docA := &openapi3.T{} err = json.Unmarshal(specJSON, &docA) require.NoError(t, err) require.NotEmpty(t, data) @@ -50,7 +52,7 @@ func TestRefsJSON(t *testing.T) { } func TestRefsYAML(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() t.Log("Marshal *T to YAML") data, err := yaml.Marshal(spec()) @@ -58,7 +60,7 @@ func TestRefsYAML(t *testing.T) { require.NotEmpty(t, data) t.Log("Unmarshal *T from YAML") - docA := &T{} + docA := &openapi3.T{} err = yaml.Unmarshal(specYAML, &docA) require.NoError(t, err) require.NotEmpty(t, data) @@ -236,54 +238,54 @@ var specJSON = []byte(` } `) -func spec() *T { - parameter := &Parameter{ +func spec() *openapi3.T { + parameter := &openapi3.Parameter{ Description: "Some parameter", Name: "example", In: "query", - Schema: &SchemaRef{ + Schema: &openapi3.SchemaRef{ Ref: "#/components/schemas/someSchema", }, } - requestBody := &RequestBody{ + requestBody := &openapi3.RequestBody{ Description: "Some request body", - Content: NewContent(), + Content: openapi3.NewContent(), } responseDescription := "Some response" - response := &Response{ + response := &openapi3.Response{ Description: &responseDescription, } - schema := &Schema{ + schema := &openapi3.Schema{ Description: "Some schema", } example := map[string]string{"name": "Some example"} - return &T{ + return &openapi3.T{ OpenAPI: "3.0", - Info: &Info{ + Info: &openapi3.Info{ Title: "MyAPI", Version: "0.1", }, - Paths: NewPaths( - WithPath("/hello", &PathItem{ - Post: &Operation{ - Parameters: Parameters{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/hello", &openapi3.PathItem{ + Post: &openapi3.Operation{ + Parameters: openapi3.Parameters{ { Ref: "#/components/parameters/someParameter", Value: parameter, }, }, - RequestBody: &RequestBodyRef{ + RequestBody: &openapi3.RequestBodyRef{ Ref: "#/components/requestBodies/someRequestBody", Value: requestBody, }, - Responses: NewResponses( - WithStatus(200, &ResponseRef{ + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ Ref: "#/components/responses/someResponse", Value: response, }), ), }, - Parameters: Parameters{ + Parameters: openapi3.Parameters{ { Ref: "#/components/parameters/someParameter", Value: parameter, @@ -291,31 +293,31 @@ func spec() *T { }, }), ), - Components: &Components{ - Parameters: ParametersMap{ + Components: &openapi3.Components{ + Parameters: openapi3.ParametersMap{ "someParameter": {Value: parameter}, }, - RequestBodies: RequestBodies{ + RequestBodies: openapi3.RequestBodies{ "someRequestBody": {Value: requestBody}, }, - Responses: ResponseBodies{ + Responses: openapi3.ResponseBodies{ "someResponse": {Value: response}, }, - Schemas: Schemas{ + Schemas: openapi3.Schemas{ "someSchema": {Value: schema}, }, - Headers: Headers{ + Headers: openapi3.Headers{ "someHeader": {Ref: "#/components/headers/otherHeader"}, - "otherHeader": {Value: &Header{Parameter{Schema: &SchemaRef{Value: NewStringSchema()}}}}, + "otherHeader": {Value: &openapi3.Header{openapi3.Parameter{Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}}}}, }, - Examples: Examples{ + Examples: openapi3.Examples{ "someExample": {Ref: "#/components/examples/otherExample"}, - "otherExample": {Value: NewExample(example)}, + "otherExample": {Value: openapi3.NewExample(example)}, }, - SecuritySchemes: SecuritySchemes{ + SecuritySchemes: openapi3.SecuritySchemes{ "someSecurityScheme": {Ref: "#/components/securitySchemes/otherSecurityScheme"}, "otherSecurityScheme": { - Value: &SecurityScheme{ + Value: &openapi3.SecurityScheme{ Description: "Some security scheme", Type: "apiKey", In: "query", @@ -429,7 +431,7 @@ components: for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { - doc := &T{} + doc := &openapi3.T{} err := yaml.Unmarshal([]byte(tt.spec), &doc) require.NoError(t, err) @@ -444,21 +446,21 @@ components: } func TestAddRemoveServer(t *testing.T) { - testServerLines := []*Server{{URL: "test0.com"}, {URL: "test1.com"}, {URL: "test3.com"}} + testServerLines := []*openapi3.Server{{URL: "test0.com"}, {URL: "test1.com"}, {URL: "test3.com"}} - doc3 := &T{ + doc3 := &openapi3.T{ OpenAPI: "3.0.3", - Components: &Components{}, + Components: &openapi3.Components{}, } assert.Empty(t, doc3.Servers) - doc3.AddServer(&Server{URL: "testserver1.com"}) + doc3.AddServer(&openapi3.Server{URL: "testserver1.com"}) assert.NotEmpty(t, doc3.Servers) assert.Len(t, doc3.Servers, 1) - doc3.Servers = Servers{} + doc3.Servers = openapi3.Servers{} assert.Empty(t, doc3.Servers) @@ -467,12 +469,38 @@ func TestAddRemoveServer(t *testing.T) { assert.NotEmpty(t, doc3.Servers) assert.Len(t, doc3.Servers, 3) - doc3.Servers = Servers{} + doc3.Servers = openapi3.Servers{} doc3.AddServers(testServerLines...) assert.NotEmpty(t, doc3.Servers) assert.Len(t, doc3.Servers, 3) - doc3.Servers = Servers{} + doc3.Servers = openapi3.Servers{} +} + +func TestOpenAPIMajorMinor(t *testing.T) { + var doc *openapi3.T + require.Equal(t, "", doc.OpenAPIMajorMinor()) + require.False(t, doc.IsOpenAPI30()) + require.False(t, doc.IsOpenAPI31OrLater()) + + doc = &openapi3.T{} + require.Equal(t, "", doc.OpenAPIMajorMinor()) + require.False(t, doc.IsOpenAPI30()) + require.False(t, doc.IsOpenAPI31OrLater()) + + semvers := []string{"3", "3.0", "3.0.0", "3.0.1", "3.0.2", "3.0.3", "3.0.4", "3.1", "3.1.0", "3.1.1", "3.1.2", "3.2", "3.2.0"} + mms := []string{"3.0", "3.0", "3.0", "3.0", "3.0", "3.0", "3.0", "3.1", "3.1", "3.1", "3.1", "3.2", "3.2"} + three0s := []bool{true, true, true, true, true, true, true, false, false, false, false, false, false} + three1plusses := []bool{false, false, false, false, false, false, false, true, true, true, true, true, true} + for i := range len(semvers) { + t.Run(fmt.Sprintf("openapi:%s", semvers[i]), func(t *testing.T) { + t.Parallel() + doc := &openapi3.T{OpenAPI: semvers[i]} + require.Equal(t, mms[i], doc.OpenAPIMajorMinor()) + require.Equal(t, three0s[i], doc.IsOpenAPI30()) + require.Equal(t, three1plusses[i], doc.IsOpenAPI31OrLater()) + }) + } } diff --git a/openapi3/openapi3_version_test.go b/openapi3/openapi3_version_test.go index d6cfabef4..c3b1da3ab 100644 --- a/openapi3/openapi3_version_test.go +++ b/openapi3/openapi3_version_test.go @@ -1,80 +1,31 @@ -package openapi3 +package openapi3_test import ( "context" "encoding/json" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) -var ctx = context.Background() - -func TestDocumentVersionDetection(t *testing.T) { - t.Run("IsOpenAPI3_0", func(t *testing.T) { - doc := &T{OpenAPI: "3.0.0"} - require.True(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - - doc = &T{OpenAPI: "3.0.3"} - require.True(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - - doc = &T{OpenAPI: "3.0.1"} - require.True(t, doc.IsOpenAPI3_0()) - }) - - t.Run("IsOpenAPI3_1", func(t *testing.T) { - doc := &T{OpenAPI: "3.1.0"} - require.True(t, doc.IsOpenAPI3_1()) - require.False(t, doc.IsOpenAPI3_0()) - - doc = &T{OpenAPI: "3.1.1"} - require.True(t, doc.IsOpenAPI3_1()) - require.False(t, doc.IsOpenAPI3_0()) - }) - - t.Run("Version", func(t *testing.T) { - doc := &T{OpenAPI: "3.0.3"} - require.Equal(t, "3.0", doc.Version()) - - doc = &T{OpenAPI: "3.1.0"} - require.Equal(t, "3.1", doc.Version()) - - doc = &T{OpenAPI: "3.1"} - require.Equal(t, "3.1", doc.Version()) - }) - - t.Run("nil or empty document", func(t *testing.T) { - var doc *T - require.False(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - require.Equal(t, "", doc.Version()) - - doc = &T{} - require.False(t, doc.IsOpenAPI3_0()) - require.False(t, doc.IsOpenAPI3_1()) - require.Equal(t, "", doc.Version()) - }) -} - func TestWebhooksField(t *testing.T) { t.Run("serialize webhooks in OpenAPI 3.1", func(t *testing.T) { - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.1.0", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), - Webhooks: map[string]*PathItem{ + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{ "newPet": { - Post: &Operation{ + Post: &openapi3.Operation{ Summary: "New pet webhook", - Responses: NewResponses( - WithStatus(200, &ResponseRef{ - Value: &Response{ - Description: Ptr("Success"), + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: openapi3.Ptr("Success"), }, }), ), @@ -113,11 +64,11 @@ func TestWebhooksField(t *testing.T) { } }`) - var doc T + var doc openapi3.T err := json.Unmarshal(jsonData, &doc) require.NoError(t, err) - require.True(t, doc.IsOpenAPI3_1()) + require.True(t, doc.IsOpenAPI31OrLater()) require.NotNil(t, doc.Webhooks) require.Contains(t, doc.Webhooks, "newPet") require.NotNil(t, doc.Webhooks["newPet"].Post) @@ -134,29 +85,29 @@ func TestWebhooksField(t *testing.T) { "paths": {} }`) - var doc T + var doc openapi3.T err := json.Unmarshal(jsonData, &doc) require.NoError(t, err) - require.True(t, doc.IsOpenAPI3_0()) + require.True(t, doc.IsOpenAPI30()) require.Nil(t, doc.Webhooks) }) t.Run("validate webhooks", func(t *testing.T) { - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.1.0", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), - Webhooks: map[string]*PathItem{ + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{ "validWebhook": { - Post: &Operation{ - Responses: NewResponses( - WithStatus(200, &ResponseRef{ - Value: &Response{ - Description: Ptr("Success"), + Post: &openapi3.Operation{ + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: openapi3.Ptr("Success"), }, }), ), @@ -166,24 +117,24 @@ func TestWebhooksField(t *testing.T) { } // Should validate successfully - err := doc.Validate(ctx) + err := doc.Validate(context.Background()) require.NoError(t, err) }) t.Run("validate fails with nil webhook", func(t *testing.T) { - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.1.0", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), - Webhooks: map[string]*PathItem{ + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{ "invalidWebhook": nil, }, } - err := doc.Validate(ctx) + err := doc.Validate(context.Background()) require.Error(t, err) require.ErrorContains(t, err, "webhook") require.ErrorContains(t, err, "invalidWebhook") @@ -191,16 +142,16 @@ func TestWebhooksField(t *testing.T) { } func TestJSONLookupWithWebhooks(t *testing.T) { - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.1.0", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), - Webhooks: map[string]*PathItem{ + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{ "test": { - Post: &Operation{ + Post: &openapi3.Operation{ Summary: "Test webhook", }, }, @@ -211,44 +162,44 @@ func TestJSONLookupWithWebhooks(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - webhooks, ok := result.(map[string]*PathItem) + webhooks, ok := result.(map[string]*openapi3.PathItem) require.True(t, ok) require.Contains(t, webhooks, "test") } func TestVersionBasedBehavior(t *testing.T) { t.Run("detect and handle OpenAPI 3.0", func(t *testing.T) { - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.0.3", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), + Paths: openapi3.NewPaths(), } - if doc.IsOpenAPI3_0() { + if doc.IsOpenAPI30() { // OpenAPI 3.0 specific logic require.Nil(t, doc.Webhooks) } }) t.Run("detect and handle OpenAPI 3.1", func(t *testing.T) { - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.1.0", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), - Webhooks: map[string]*PathItem{ + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{ "test": { - Post: &Operation{ + Post: &openapi3.Operation{ Summary: "Test", - Responses: NewResponses( - WithStatus(200, &ResponseRef{ - Value: &Response{ - Description: Ptr("OK"), + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: openapi3.Ptr("OK"), }, }), ), @@ -257,7 +208,7 @@ func TestVersionBasedBehavior(t *testing.T) { }, } - if doc.IsOpenAPI3_1() { + if doc.IsOpenAPI31OrLater() { // OpenAPI 3.1 specific logic require.NotNil(t, doc.Webhooks) require.Contains(t, doc.Webhooks, "test") @@ -268,30 +219,30 @@ func TestVersionBasedBehavior(t *testing.T) { func TestMigrationScenario(t *testing.T) { t.Run("upgrade document from 3.0 to 3.1", func(t *testing.T) { // Start with 3.0 document - doc := &T{ + doc := &openapi3.T{ OpenAPI: "3.0.3", - Info: &Info{ + Info: &openapi3.Info{ Title: "Test API", Version: "1.0.0", }, - Paths: NewPaths(), + Paths: openapi3.NewPaths(), } - require.True(t, doc.IsOpenAPI3_0()) + require.True(t, doc.IsOpenAPI30()) require.Nil(t, doc.Webhooks) // Upgrade to 3.1 doc.OpenAPI = "3.1.0" // Add 3.1 features - doc.Webhooks = map[string]*PathItem{ + doc.Webhooks = map[string]*openapi3.PathItem{ "newEvent": { - Post: &Operation{ + Post: &openapi3.Operation{ Summary: "New event notification", - Responses: NewResponses( - WithStatus(200, &ResponseRef{ - Value: &Response{ - Description: Ptr("Processed"), + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: openapi3.Ptr("Processed"), }, }), ), @@ -299,11 +250,11 @@ func TestMigrationScenario(t *testing.T) { }, } - require.True(t, doc.IsOpenAPI3_1()) + require.True(t, doc.IsOpenAPI31OrLater()) require.NotNil(t, doc.Webhooks) // Validate the upgraded document - err := doc.Validate(ctx) + err := doc.Validate(context.Background()) require.NoError(t, err) }) } diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index 9e89c1a4b..e6ed20421 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -1,14 +1,17 @@ -package openapi3 +package openapi3_test import ( "context" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) +const originKey = "__origin__" + func TestOrigin_Info(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -18,7 +21,7 @@ func TestOrigin_Info(t *testing.T) { require.NotNil(t, doc.Info.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 2, Column: 1, @@ -27,7 +30,7 @@ func TestOrigin_Info(t *testing.T) { doc.Info.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 3, Column: 3, @@ -36,7 +39,7 @@ func TestOrigin_Info(t *testing.T) { doc.Info.Origin.Fields["title"]) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 4, Column: 3, @@ -46,7 +49,7 @@ func TestOrigin_Info(t *testing.T) { } func TestOrigin_Paths(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -56,7 +59,7 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, doc.Paths.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 5, Column: 1, @@ -68,7 +71,7 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 13, Column: 3, @@ -78,7 +81,7 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, base.Get.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 14, Column: 5, @@ -88,7 +91,7 @@ func TestOrigin_Paths(t *testing.T) { } func TestOrigin_RequestBody(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -99,7 +102,7 @@ func TestOrigin_RequestBody(t *testing.T) { base := doc.Paths.Find("/subscribe").Post.RequestBody.Value require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/request_body.yaml", Line: 8, Column: 7, @@ -109,7 +112,7 @@ func TestOrigin_RequestBody(t *testing.T) { require.NotNil(t, base.Content["application/json"].Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/request_body.yaml", Line: 10, Column: 11, @@ -119,7 +122,7 @@ func TestOrigin_RequestBody(t *testing.T) { } func TestOrigin_Responses(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -130,7 +133,7 @@ func TestOrigin_Responses(t *testing.T) { base := doc.Paths.Find("/partner-api/test/another-method").Get.Responses require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 17, Column: 7, @@ -142,7 +145,7 @@ func TestOrigin_Responses(t *testing.T) { // ResponseRef.Origin is populated with the same data as Value.Origin require.NotNil(t, base.Value("200").Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 18, Column: 9, @@ -150,7 +153,7 @@ func TestOrigin_Responses(t *testing.T) { }, base.Value("200").Origin.Key) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 18, Column: 9, @@ -159,7 +162,7 @@ func TestOrigin_Responses(t *testing.T) { base.Value("200").Value.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/simple.yaml", Line: 19, Column: 11, @@ -169,7 +172,7 @@ func TestOrigin_Responses(t *testing.T) { } func TestOrigin_Parameters(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -180,7 +183,7 @@ func TestOrigin_Parameters(t *testing.T) { base := doc.Paths.Find("/api/test").Get.Parameters[0].Value require.NotNil(t, base) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, @@ -189,7 +192,7 @@ func TestOrigin_Parameters(t *testing.T) { base.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/parameters.yaml", Line: 10, Column: 11, @@ -198,7 +201,7 @@ func TestOrigin_Parameters(t *testing.T) { base.Origin.Fields["in"]) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, @@ -208,7 +211,7 @@ func TestOrigin_Parameters(t *testing.T) { } func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -221,7 +224,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { require.NotNil(t, base.Schema.Value.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/additional_properties.yaml", Line: 14, Column: 17, @@ -230,7 +233,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { base.Schema.Value.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/additional_properties.yaml", Line: 15, Column: 19, @@ -240,7 +243,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { } func TestOrigin_ExternalDocs(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -252,7 +255,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/external_docs.yaml", Line: 13, Column: 1, @@ -261,7 +264,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { base.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/external_docs.yaml", Line: 14, Column: 3, @@ -270,7 +273,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { base.Origin.Fields["description"]) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/external_docs.yaml", Line: 15, Column: 3, @@ -280,7 +283,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { } func TestOrigin_Security(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -292,7 +295,7 @@ func TestOrigin_Security(t *testing.T) { require.NotNil(t, base) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/security.yaml", Line: 29, Column: 5, @@ -301,7 +304,7 @@ func TestOrigin_Security(t *testing.T) { base.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/security.yaml", Line: 30, Column: 7, @@ -310,7 +313,7 @@ func TestOrigin_Security(t *testing.T) { base.Origin.Fields["type"]) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/security.yaml", Line: 31, Column: 7, @@ -319,7 +322,7 @@ func TestOrigin_Security(t *testing.T) { base.Flows.Origin.Key) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/security.yaml", Line: 32, Column: 9, @@ -328,7 +331,7 @@ func TestOrigin_Security(t *testing.T) { base.Flows.Implicit.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/security.yaml", Line: 33, Column: 11, @@ -338,7 +341,7 @@ func TestOrigin_Security(t *testing.T) { } func TestOrigin_Example(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -349,7 +352,7 @@ func TestOrigin_Example(t *testing.T) { base := doc.Paths.Find("/subscribe").Post.RequestBody.Value.Content["application/json"].Examples["bar"].Value require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/example.yaml", Line: 14, Column: 15, @@ -358,7 +361,7 @@ func TestOrigin_Example(t *testing.T) { base.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/example.yaml", Line: 15, Column: 17, @@ -373,7 +376,7 @@ func TestOrigin_Example(t *testing.T) { } func TestOrigin_XML(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -384,7 +387,7 @@ func TestOrigin_XML(t *testing.T) { base := doc.Paths.Find("/subscribe").Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["name"].Value.XML require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/xml.yaml", Line: 21, Column: 19, @@ -393,7 +396,7 @@ func TestOrigin_XML(t *testing.T) { base.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/xml.yaml", Line: 22, Column: 21, @@ -402,7 +405,7 @@ func TestOrigin_XML(t *testing.T) { base.Origin.Fields["namespace"]) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/xml.yaml", Line: 23, Column: 21, @@ -417,7 +420,7 @@ func TestOrigin_XML(t *testing.T) { // These fields have no dedicated UnmarshalJSON; extractOrigins strips // __origin__ before JSON marshaling so it never reaches these values. func TestOrigin_AnyFieldsStripped(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/any_fields.yaml") require.NoError(t, err) @@ -456,7 +459,7 @@ func TestOrigin_AnyFieldsStripped(t *testing.T) { } func TestOrigin_ExampleWithArrayValue(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/example_with_array.yaml") require.NoError(t, err) @@ -490,7 +493,7 @@ components: examples: - {y: value} ` - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromData([]byte(data)) @@ -530,7 +533,7 @@ components: type: string ` - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true _, err := loader.LoadFromData([]byte(data)) @@ -545,7 +548,7 @@ components: // from the yaml3 decoder but it was never stripped, causing spurious diffs // between specs loaded from different file paths. func TestOrigin_ExtensionValuesStripped(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/extensions.yaml") @@ -571,7 +574,7 @@ func TestOrigin_ExtensionValuesStripped(t *testing.T) { } func TestOrigin_WithExternalRef(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true @@ -583,7 +586,7 @@ func TestOrigin_WithExternalRef(t *testing.T) { base := doc.Paths.Find("/subscribe").Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["name"].Value require.NotNil(t, base.XML.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/external-schema.yaml", Line: 2, Column: 1, @@ -592,7 +595,7 @@ func TestOrigin_WithExternalRef(t *testing.T) { base.XML.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/external-schema.yaml", Line: 3, Column: 3, @@ -601,7 +604,7 @@ func TestOrigin_WithExternalRef(t *testing.T) { base.XML.Origin.Fields["namespace"]) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/external-schema.yaml", Line: 4, Column: 3, @@ -616,7 +619,7 @@ func TestOrigin_WithExternalRef(t *testing.T) { // root mapping of a document was skipped. This test covers the fix in yaml3's // document() decoder that injects __origin__ for the root mapping too. func TestOrigin_WithExternalRefRootOrigin(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true loader.IncludeOrigin = true loader.Context = context.Background() @@ -630,7 +633,7 @@ func TestOrigin_WithExternalRefRootOrigin(t *testing.T) { // Root schema Origin must now be set (fixed in yaml3 document() injection) require.NotNil(t, base.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/external-schema.yaml", Line: 1, Column: 1, @@ -639,7 +642,7 @@ func TestOrigin_WithExternalRefRootOrigin(t *testing.T) { base.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/external-schema.yaml", Line: 1, Column: 1, @@ -653,7 +656,7 @@ func TestOrigin_WithExternalRefRootOrigin(t *testing.T) { // The if k == originKey blocks in their UnmarshalJSON were removed; this // confirms extractOrigins strips __origin__ before it reaches those iterators. func TestOrigin_MaplikeNoOriginKey(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/simple.yaml") require.NoError(t, err) @@ -667,7 +670,7 @@ func TestOrigin_MaplikeNoOriginKey(t *testing.T) { } func TestOrigin_NoSpuriousOriginsInComponents(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/components.yaml") @@ -690,7 +693,7 @@ func TestOrigin_NoSpuriousOriginsInComponents(t *testing.T) { // These locations are used by NewSourceFromSequenceItem to pinpoint // breaking changes to individual required field names. func TestOrigin_RequiredSequence(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/required_sequence.yaml") @@ -707,14 +710,14 @@ func TestOrigin_RequiredSequence(t *testing.T) { require.True(t, ok, "Origin.Sequences must contain 'required'") require.Len(t, seqLocs, 2) - require.Equal(t, Location{ + require.Equal(t, openapi3.Location{ File: "testdata/origin/required_sequence.yaml", Line: 14, Column: 19, Name: "name", }, seqLocs[0]) - require.Equal(t, Location{ + require.Equal(t, openapi3.Location{ File: "testdata/origin/required_sequence.yaml", Line: 15, Column: 19, @@ -726,7 +729,7 @@ func TestOrigin_RequiredSequence(t *testing.T) { // without error and carries origin metadata from the anchor definition. // Multiple aliases of the same anchor must not produce duplicate __origin__ keys. func TestOrigin_YAMLAlias(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/alias.yaml") @@ -737,7 +740,7 @@ func TestOrigin_YAMLAlias(t *testing.T) { alias2 := doc.Components.Schemas["Alias2"].Value // All three point to the same anchor node, so origin reflects the anchor location. - anchorLoc := &Location{ + anchorLoc := &openapi3.Location{ File: "testdata/origin/alias.yaml", Line: 7, Column: 5, @@ -750,7 +753,7 @@ func TestOrigin_YAMLAlias(t *testing.T) { // TestOrigin_Headers verifies that response header origin is tracked correctly. func TestOrigin_Headers(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/headers.yaml") @@ -759,7 +762,7 @@ func TestOrigin_Headers(t *testing.T) { headers := doc.Paths.Find("/items").Get.Responses.Value("200").Value.Headers require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/headers.yaml", Line: 12, Column: 13, @@ -768,7 +771,7 @@ func TestOrigin_Headers(t *testing.T) { headers["X-Rate-Limit"].Value.Origin.Key) require.Equal(t, - Location{ + openapi3.Location{ File: "testdata/origin/headers.yaml", Line: 13, Column: 15, @@ -777,7 +780,7 @@ func TestOrigin_Headers(t *testing.T) { headers["X-Rate-Limit"].Value.Origin.Fields["description"]) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/headers.yaml", Line: 16, Column: 13, @@ -791,7 +794,7 @@ func TestOrigin_Headers(t *testing.T) { // strings ("200":). Bare integers produce map[any]any in the // YAML decoder, which required a dedicated fix in extractOrigins. func TestOrigin_IntegerStatusCode(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/parameters.yaml") @@ -800,7 +803,7 @@ func TestOrigin_IntegerStatusCode(t *testing.T) { resp200 := doc.Paths.Find("/api/test").Get.Responses.Value("200").Value require.NotNil(t, resp200.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/parameters.yaml", Line: 14, Column: 9, @@ -811,7 +814,7 @@ func TestOrigin_IntegerStatusCode(t *testing.T) { resp201 := doc.Paths.Find("/api/test").Post.Responses.Value("201").Value require.NotNil(t, resp201.Origin) require.Equal(t, - &Location{ + &openapi3.Location{ File: "testdata/origin/parameters.yaml", Line: 18, Column: 9, @@ -823,7 +826,7 @@ func TestOrigin_IntegerStatusCode(t *testing.T) { // TestOrigin_Disabled verifies that all Origin fields are nil when // IncludeOrigin is false (the default), ensuring no overhead in the common case. func TestOrigin_Disabled(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() // IncludeOrigin defaults to false doc, err := loader.LoadFromFile("testdata/origin/required_sequence.yaml") @@ -842,7 +845,7 @@ func TestOrigin_Disabled(t *testing.T) { // mapping-valued fields were missing from the origin and source // location lookups returned nil. func TestOrigin_MappingFields(t *testing.T) { - loader := NewLoader() + loader := openapi3.NewLoader() loader.IncludeOrigin = true doc, err := loader.LoadFromFile("testdata/origin/mapping_fields.yaml") @@ -856,7 +859,7 @@ func TestOrigin_MappingFields(t *testing.T) { // dependentRequired is a map[string][]string — mapping-valued require.Contains(t, schema.Origin.Fields, "dependentRequired") - require.Equal(t, Location{ + require.Equal(t, openapi3.Location{ File: file, Line: 18, Column: 21, @@ -865,7 +868,7 @@ func TestOrigin_MappingFields(t *testing.T) { // dependentSchemas is a Schemas map — mapping-valued require.Contains(t, schema.Origin.Fields, "dependentSchemas") - require.Equal(t, Location{ + require.Equal(t, openapi3.Location{ File: file, Line: 22, Column: 21, @@ -874,7 +877,7 @@ func TestOrigin_MappingFields(t *testing.T) { // patternProperties is a Schemas map — mapping-valued require.Contains(t, schema.Origin.Fields, "patternProperties") - require.Equal(t, Location{ + require.Equal(t, openapi3.Location{ File: file, Line: 25, Column: 21, diff --git a/openapi3/refs.go b/openapi3/refs.go index cba03f164..58d75e3c3 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -89,9 +89,9 @@ func (x *CallbackRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if CallbackRef does not comply with the OpenAPI spec. -func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if CallbackRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *CallbackRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -113,12 +113,22 @@ func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) er if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if CallbackRef does not comply with the OpenAPI spec. +func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -218,9 +228,9 @@ func (x *ExampleRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if ExampleRef does not comply with the OpenAPI spec. -func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if ExampleRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *ExampleRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -242,12 +252,22 @@ func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) err if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if ExampleRef does not comply with the OpenAPI spec. +func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -347,9 +367,9 @@ func (x *HeaderRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if HeaderRef does not comply with the OpenAPI spec. -func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if HeaderRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *HeaderRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -371,12 +391,22 @@ func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) erro if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if HeaderRef does not comply with the OpenAPI spec. +func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -476,9 +506,9 @@ func (x *LinkRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if LinkRef does not comply with the OpenAPI spec. -func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if LinkRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *LinkRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -500,12 +530,22 @@ func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if LinkRef does not comply with the OpenAPI spec. +func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -605,9 +645,9 @@ func (x *ParameterRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if ParameterRef does not comply with the OpenAPI spec. -func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if ParameterRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *ParameterRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -629,12 +669,22 @@ func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) e if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if ParameterRef does not comply with the OpenAPI spec. +func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -734,9 +784,9 @@ func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if RequestBodyRef does not comply with the OpenAPI spec. -func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if RequestBodyRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *RequestBodyRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -758,12 +808,22 @@ func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if RequestBodyRef does not comply with the OpenAPI spec. +func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -863,9 +923,9 @@ func (x *ResponseRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if ResponseRef does not comply with the OpenAPI spec. -func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if ResponseRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *ResponseRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -887,12 +947,22 @@ func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) er if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if ResponseRef does not comply with the OpenAPI spec. +func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -1011,9 +1081,9 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if SchemaRef does not comply with the OpenAPI spec. -func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if SchemaRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *SchemaRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -1035,14 +1105,24 @@ func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) erro if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { - if !getValidationOptions(ctx).jsonSchema2020ValidationEnabled { + if !validationOpts.isOpenAPI31OrLater { return fmt.Errorf("extra sibling fields: %+v", extras) } } + return nil +} + +// Validate returns an error if SchemaRef does not comply with the OpenAPI spec. +func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) @@ -1142,9 +1222,9 @@ func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if SecuritySchemeRef does not comply with the OpenAPI spec. -func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if SecuritySchemeRef has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *SecuritySchemeRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -1166,12 +1246,22 @@ func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOpti if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { return fmt.Errorf("extra sibling fields: %+v", extras) } + return nil +} + +// Validate returns an error if SecuritySchemeRef does not comply with the OpenAPI spec. +func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) diff --git a/openapi3/refs.tmpl b/openapi3/refs.tmpl index 788796f47..3a5e74ae8 100644 --- a/openapi3/refs.tmpl +++ b/openapi3/refs.tmpl @@ -112,9 +112,9 @@ func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if {{ $type.Name }}Ref does not comply with the OpenAPI spec. -func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOption) error { - ctx = WithValidationOptions(ctx, opts...) +// validateExtras returns an error if {{ $type.Name }}Ref has sibling fields +// alongside $ref that are not allowed by the validation options. +func (x *{{ $type.Name }}Ref) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) var extras []string @@ -136,18 +136,28 @@ func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOp if _, ok := allowed[ex]; !ok { extras = append(extras, ex) } + // extras in the Extensions checked below } } if len(extras) != 0 { {{- if eq $type.Name "Schema" }} - if !getValidationOptions(ctx).jsonSchema2020ValidationEnabled { + if !validationOpts.isOpenAPI31OrLater { return fmt.Errorf("extra sibling fields: %+v", extras) } {{- else }} return fmt.Errorf("extra sibling fields: %+v", extras) {{- end }} } + return nil +} + +// Validate returns an error if {{ $type.Name }}Ref does not comply with the OpenAPI spec. +func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if err := x.validateExtras(ctx); err != nil { + return err + } if v := x.Value; v != nil { return v.Validate(ctx) diff --git a/openapi3/schema.go b/openapi3/schema.go index c37d3c89b..2433695a4 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -80,6 +80,7 @@ func (s SchemaRefs) JSONLookup(token string) (any, error) { // Schema is specified by OpenAPI/Swagger 3.0 standard. // See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object +// and https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#schema-object type Schema struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -100,10 +101,8 @@ type Schema struct { // Array-related, here for struct compactness UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` // Number-related, here for struct compactness - // In OpenAPI 3.0: boolean modifier for minimum/maximum - // In OpenAPI 3.1: number representing the actual exclusive bound - ExclusiveMin ExclusiveBound `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` - ExclusiveMax ExclusiveBound `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + ExclusiveMin ExclusiveBound `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` // Number for v3.1+ otherwise boolean + ExclusiveMax ExclusiveBound `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` // Number for v3.1+ otherwise boolean // Properties Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` @@ -135,42 +134,36 @@ type Schema struct { AdditionalProperties AdditionalProperties `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` - // OpenAPI 3.1 / JSON Schema 2020-12 fields - Const any `json:"const,omitempty" yaml:"const,omitempty"` - Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"` - PrefixItems SchemaRefs `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"` - Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"` - MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"` - MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"` - PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` - DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` - PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` - UnevaluatedItems BoolSchema `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"` - UnevaluatedProperties BoolSchema `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"` - - // JSON Schema 2020-12 conditional keywords - If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"` - Then *SchemaRef `json:"then,omitempty" yaml:"then,omitempty"` - Else *SchemaRef `json:"else,omitempty" yaml:"else,omitempty"` - - // JSON Schema 2020-12 dependent requirements - DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` - - // JSON Schema 2020-12 core keywords - Defs Schemas `json:"$defs,omitempty" yaml:"$defs,omitempty"` - SchemaDialect string `json:"$schema,omitempty" yaml:"$schema,omitempty"` - Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` - - // JSON Schema 2020-12 identity/referencing keywords - SchemaID string `json:"$id,omitempty" yaml:"$id,omitempty"` - Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"` - DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` - DynamicAnchor string `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"` - - // JSON Schema 2020-12 content vocabulary - ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` - ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` - ContentSchema *SchemaRef `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` + Const any `json:"const,omitempty" yaml:"const,omitempty"` // OpenAPI >=3.1 + Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"` // OpenAPI >=3.1 + PrefixItems SchemaRefs `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"` // OpenAPI >=3.1 + Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"` // OpenAPI >=3.1 + MinContains *uint64 `json:"minContains,omitempty" yaml:"minContains,omitempty"` // OpenAPI >=3.1 + MaxContains *uint64 `json:"maxContains,omitempty" yaml:"maxContains,omitempty"` // OpenAPI >=3.1 + PatternProperties Schemas `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` // OpenAPI >=3.1 + DependentSchemas Schemas `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` // OpenAPI >=3.1 + PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` // OpenAPI >=3.1 + UnevaluatedItems BoolSchema `json:"unevaluatedItems,omitempty" yaml:"unevaluatedItems,omitempty"` // OpenAPI >=3.1 + UnevaluatedProperties BoolSchema `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"` // OpenAPI >=3.1 + + If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"` // OpenAPI >=3.1 + Then *SchemaRef `json:"then,omitempty" yaml:"then,omitempty"` // OpenAPI >=3.1 + Else *SchemaRef `json:"else,omitempty" yaml:"else,omitempty"` // OpenAPI >=3.1 + + DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` // OpenAPI >=3.1 + + Defs Schemas `json:"$defs,omitempty" yaml:"$defs,omitempty"` // OpenAPI >=3.1 + SchemaDialect string `json:"$schema,omitempty" yaml:"$schema,omitempty"` // OpenAPI >=3.1 + Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` // OpenAPI >=3.1 + + SchemaID string `json:"$id,omitempty" yaml:"$id,omitempty"` // OpenAPI >=3.1 + Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"` // OpenAPI >=3.1 + DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` // OpenAPI >=3.1 + DynamicAnchor string `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"` // OpenAPI >=3.1 + + ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` // OpenAPI >=3.1 + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` // OpenAPI >=3.1 + ContentSchema *SchemaRef `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` // OpenAPI >=3.1 } // Types represents the type(s) of a schema. @@ -494,7 +487,7 @@ func (schema Schema) MarshalJSON() ([]byte, error) { // MarshalYAML returns the YAML encoding of Schema. func (schema Schema) MarshalYAML() (any, error) { - m := make(map[string]any, 36+len(schema.Extensions)) + m := make(map[string]any, 61+len(schema.Extensions)) maps.Copy(m, schema.Extensions) if x := schema.OneOf; len(x) != 0 { @@ -539,15 +532,11 @@ func (schema Schema) MarshalYAML() (any, error) { m["uniqueItems"] = x } // Number-related - if schema.ExclusiveMin.IsSet() { - if v, _ := schema.ExclusiveMin.MarshalYAML(); v != nil { - m["exclusiveMinimum"] = v - } + if x := schema.ExclusiveMin; x.IsSet() { + m["exclusiveMinimum"] = x } - if schema.ExclusiveMax.IsSet() { - if v, _ := schema.ExclusiveMax.MarshalYAML(); v != nil { - m["exclusiveMaximum"] = v - } + if x := schema.ExclusiveMax; x.IsSet() { + m["exclusiveMaximum"] = x } // Properties if x := schema.Nullable; x { @@ -1434,6 +1423,146 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, return stack, errors.New("a property MUST NOT be marked as both readOnly and writeOnly being true") } + // Reject fields that only exist in OAS 3.1 / JSON Schema 2020-12 when the + // document is OAS 3.0. Fields explicitly allowed via AllowExtraSiblingFields + // are skipped (opt-in escape hatch for 3.0 docs that reference external + // JSON Schema documents). Once 3.0 moves to its own schema validator this + // block becomes a no-op. + if !validationOpts.isOpenAPI31OrLater { + allowed := validationOpts.extraSiblingFieldsAllowed + reject := func(field string) error { + if _, ok := allowed[field]; ok { + return nil + } + return errFieldFor31Plus(field) + } + if schema.Const != nil { + if err := reject("const"); err != nil { + return stack, err + } + } + if len(schema.Examples) != 0 { + if err := reject("examples"); err != nil { + return stack, err + } + } + if len(schema.PrefixItems) != 0 { + if err := reject("prefixItems"); err != nil { + return stack, err + } + } + if schema.Contains != nil { + if err := reject("contains"); err != nil { + return stack, err + } + } + if schema.MinContains != nil { + if err := reject("minContains"); err != nil { + return stack, err + } + } + if schema.MaxContains != nil { + if err := reject("maxContains"); err != nil { + return stack, err + } + } + if len(schema.PatternProperties) != 0 { + if err := reject("patternProperties"); err != nil { + return stack, err + } + } + if len(schema.DependentSchemas) != 0 { + if err := reject("dependentSchemas"); err != nil { + return stack, err + } + } + if schema.PropertyNames != nil { + if err := reject("propertyNames"); err != nil { + return stack, err + } + } + if schema.UnevaluatedItems.Has != nil || schema.UnevaluatedItems.Schema != nil { + if err := reject("unevaluatedItems"); err != nil { + return stack, err + } + } + if schema.UnevaluatedProperties.Has != nil || schema.UnevaluatedProperties.Schema != nil { + if err := reject("unevaluatedProperties"); err != nil { + return stack, err + } + } + if schema.If != nil { + if err := reject("if"); err != nil { + return stack, err + } + } + if schema.Then != nil { + if err := reject("then"); err != nil { + return stack, err + } + } + if schema.Else != nil { + if err := reject("else"); err != nil { + return stack, err + } + } + if len(schema.DependentRequired) != 0 { + if err := reject("dependentRequired"); err != nil { + return stack, err + } + } + if len(schema.Defs) != 0 { + if err := reject("$defs"); err != nil { + return stack, err + } + } + if schema.SchemaDialect != "" { + if err := reject("$schema"); err != nil { + return stack, err + } + } + if schema.Comment != "" { + if err := reject("$comment"); err != nil { + return stack, err + } + } + if schema.SchemaID != "" { + if err := reject("$id"); err != nil { + return stack, err + } + } + if schema.Anchor != "" { + if err := reject("$anchor"); err != nil { + return stack, err + } + } + if schema.DynamicRef != "" { + if err := reject("$dynamicRef"); err != nil { + return stack, err + } + } + if schema.DynamicAnchor != "" { + if err := reject("$dynamicAnchor"); err != nil { + return stack, err + } + } + if schema.ContentMediaType != "" { + if err := reject("contentMediaType"); err != nil { + return stack, err + } + } + if schema.ContentEncoding != "" { + if err := reject("contentEncoding"); err != nil { + return stack, err + } + } + if schema.ContentSchema != nil { + if err := reject("contentSchema"); err != nil { + return stack, err + } + } + } + for _, item := range schema.OneOf { v := item.Value if v == nil { @@ -1577,6 +1706,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } if ref := schema.Items; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1590,6 +1722,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, for _, name := range componentNames(schema.Properties) { ref := schema.Properties[name] + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1605,6 +1740,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, return stack, errors.New("additionalProperties are set to both boolean and schema") } if ref := schema.AdditionalProperties.Schema; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1618,6 +1756,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, // OpenAPI 3.1 / JSON Schema 2020-12 sub-schemas for _, ref := range schema.PrefixItems { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1629,6 +1770,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } } if ref := schema.Contains; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1641,6 +1785,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } for _, name := range componentNames(schema.PatternProperties) { ref := schema.PatternProperties[name] + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1653,6 +1800,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } for _, name := range componentNames(schema.DependentSchemas) { ref := schema.DependentSchemas[name] + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1665,6 +1815,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } for _, name := range componentNames(schema.Defs) { ref := schema.Defs[name] + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1676,6 +1829,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } } if ref := schema.PropertyNames; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1690,6 +1846,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, return stack, errors.New("unevaluatedItems is set to both boolean and schema") } if ref := schema.UnevaluatedItems.Schema; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1704,6 +1863,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, return stack, errors.New("unevaluatedProperties is set to both boolean and schema") } if ref := schema.UnevaluatedProperties.Schema; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1715,6 +1877,9 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } } if ref := schema.ContentSchema; ref != nil { + if err := ref.validateExtras(ctx); err != nil { + return stack, err + } v := ref.Value if v == nil { return stack, foundUnresolvedRef(ref.Ref) @@ -1733,11 +1898,7 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } if v := schema.Default; v != nil && !validationOpts.schemaDefaultsValidationDisabled { - opts := []SchemaValidationOption{} - if validationOpts.jsonSchema2020ValidationEnabled { - opts = append(opts, EnableJSONSchema2020()) - } - if err := schema.VisitJSON(v, opts...); err != nil { + if err := validateExampleValue(ctx, v, schema); err != nil { return stack, fmt.Errorf("invalid default: %w", err) } } @@ -1781,14 +1942,14 @@ func (schema *Schema) IsMatchingJSONObject(value map[string]any) bool { return schema.visitJSON(settings, value) == nil } +// VisitJSON applies a Schema to the given data, considering opts. +// To validate data against an OpenAPIv3.1+ schema, be sure to pass the EnableJSONSchema2020() option. func (schema *Schema) VisitJSON(value any, opts ...SchemaValidationOption) error { settings := newSchemaValidationSettings(opts...) - // Use JSON Schema 2020-12 validator if enabled if settings.useJSONSchema2020 { - return schema.visitJSONWithJSONSchema(settings, value) + return schema.useJSONSchema2020(settings, value) } - return schema.visitJSON(settings, value) } diff --git a/openapi3/schema_const_test.go b/openapi3/schema_const_test.go index 7c3e4ccba..1bbf420a2 100644 --- a/openapi3/schema_const_test.go +++ b/openapi3/schema_const_test.go @@ -1,14 +1,15 @@ -package openapi3 +package openapi3_test import ( "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestSchemaConst_BuiltInValidator(t *testing.T) { t.Run("string const", func(t *testing.T) { - schema := &Schema{ + schema := &openapi3.Schema{ Const: "production", } @@ -21,7 +22,7 @@ func TestSchemaConst_BuiltInValidator(t *testing.T) { }) t.Run("number const", func(t *testing.T) { - schema := &Schema{ + schema := &openapi3.Schema{ Const: float64(42), } @@ -33,7 +34,7 @@ func TestSchemaConst_BuiltInValidator(t *testing.T) { }) t.Run("boolean const", func(t *testing.T) { - schema := &Schema{ + schema := &openapi3.Schema{ Const: true, } @@ -45,8 +46,8 @@ func TestSchemaConst_BuiltInValidator(t *testing.T) { }) t.Run("null const", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"null"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"null"}, Const: nil, } @@ -56,7 +57,7 @@ func TestSchemaConst_BuiltInValidator(t *testing.T) { }) t.Run("object const", func(t *testing.T) { - schema := &Schema{ + schema := &openapi3.Schema{ Const: map[string]any{"key": "value"}, } @@ -68,8 +69,8 @@ func TestSchemaConst_BuiltInValidator(t *testing.T) { }) t.Run("const with type constraint", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Const: "fixed", } @@ -81,12 +82,12 @@ func TestSchemaConst_BuiltInValidator(t *testing.T) { }) t.Run("const with multiError", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Const: "fixed", } - err := schema.VisitJSON("other", MultiErrors()) + err := schema.VisitJSON("other", openapi3.MultiErrors()) require.Error(t, err) }) } diff --git a/openapi3/schema_if_then_else_test.go b/openapi3/schema_if_then_else_test.go index bd56d8cee..372a27ebe 100644 --- a/openapi3/schema_if_then_else_test.go +++ b/openapi3/schema_if_then_else_test.go @@ -1,24 +1,25 @@ -package openapi3 +package openapi3_test import ( "context" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestSchemaIfThenElse_BuiltInValidator(t *testing.T) { t.Run("schema with if/then/else is not empty", func(t *testing.T) { - schema := &Schema{ - If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - Then: &SchemaRef{Value: &Schema{MinLength: 3}}, - Else: &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + Then: &openapi3.SchemaRef{Value: &openapi3.Schema{MinLength: 3}}, + Else: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, } require.False(t, schema.IsEmpty()) }) t.Run("schema with dependentRequired is not empty", func(t *testing.T) { - schema := &Schema{ + schema := &openapi3.Schema{ DependentRequired: map[string][]string{ "creditCard": {"billingAddress"}, }, @@ -30,56 +31,56 @@ func TestSchemaIfThenElse_BuiltInValidator(t *testing.T) { func TestSchemaIfThenElse_JSONSchema2020(t *testing.T) { t.Run("if/then/else conditional validation", func(t *testing.T) { // If type is string, then minLength=3; else must be number - schema := &Schema{ - If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - Then: &SchemaRef{Value: &Schema{MinLength: 3}}, - Else: &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + Then: &openapi3.SchemaRef{Value: &openapi3.Schema{MinLength: 3}}, + Else: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, } // String with length >= 3 → passes if+then - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) // Number → fails if, passes else - err = schema.VisitJSON(float64(42), EnableJSONSchema2020()) + err = schema.VisitJSON(float64(42), openapi3.EnableJSONSchema2020()) require.NoError(t, err) // Short string → passes if, fails then - err = schema.VisitJSON("ab", EnableJSONSchema2020()) + err = schema.VisitJSON("ab", openapi3.EnableJSONSchema2020()) require.Error(t, err) // Boolean → fails if, fails else - err = schema.VisitJSON(true, EnableJSONSchema2020()) + err = schema.VisitJSON(true, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("if/then without else", func(t *testing.T) { // If type is string, then minLength=5 - schema := &Schema{ - If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - Then: &SchemaRef{Value: &Schema{MinLength: 5}}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + Then: &openapi3.SchemaRef{Value: &openapi3.Schema{MinLength: 5}}, } // String with length >= 5 → passes - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) // Short string → fails then - err = schema.VisitJSON("hi", EnableJSONSchema2020()) + err = schema.VisitJSON("hi", openapi3.EnableJSONSchema2020()) require.Error(t, err) // Number → fails if, no else so passes - err = schema.VisitJSON(float64(42), EnableJSONSchema2020()) + err = schema.VisitJSON(float64(42), openapi3.EnableJSONSchema2020()) require.NoError(t, err) }) t.Run("dependentRequired validation", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - Properties: Schemas{ - "name": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - "creditCard": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - "billingAddress": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "creditCard": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "billingAddress": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, }, DependentRequired: map[string][]string{ "creditCard": {"billingAddress"}, @@ -91,36 +92,36 @@ func TestSchemaIfThenElse_JSONSchema2020(t *testing.T) { "name": "John", "creditCard": "1234", "billingAddress": "123 Main St", - }, EnableJSONSchema2020()) + }, openapi3.EnableJSONSchema2020()) require.NoError(t, err) // No creditCard → passes (dependency not triggered) err = schema.VisitJSON(map[string]any{ "name": "John", - }, EnableJSONSchema2020()) + }, openapi3.EnableJSONSchema2020()) require.NoError(t, err) // Has creditCard but no billingAddress → fails err = schema.VisitJSON(map[string]any{ "name": "John", "creditCard": "1234", - }, EnableJSONSchema2020()) + }, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) } func TestSchemaIfThenElse_MarshalRoundTrip(t *testing.T) { t.Run("if/then/else round-trip", func(t *testing.T) { - schema := &Schema{ - If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - Then: &SchemaRef{Value: &Schema{MinLength: 3}}, - Else: &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + Then: &openapi3.SchemaRef{Value: &openapi3.Schema{MinLength: 3}}, + Else: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, } data, err := schema.MarshalJSON() require.NoError(t, err) - var roundTripped Schema + var roundTripped openapi3.Schema err = roundTripped.UnmarshalJSON(data) require.NoError(t, err) @@ -133,8 +134,8 @@ func TestSchemaIfThenElse_MarshalRoundTrip(t *testing.T) { }) t.Run("dependentRequired round-trip", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, DependentRequired: map[string][]string{ "creditCard": {"billingAddress", "cvv"}, }, @@ -143,7 +144,7 @@ func TestSchemaIfThenElse_MarshalRoundTrip(t *testing.T) { data, err := schema.MarshalJSON() require.NoError(t, err) - var roundTripped Schema + var roundTripped openapi3.Schema err = roundTripped.UnmarshalJSON(data) require.NoError(t, err) @@ -153,15 +154,15 @@ func TestSchemaIfThenElse_MarshalRoundTrip(t *testing.T) { }) t.Run("no extensions leak", func(t *testing.T) { - schema := &Schema{ - If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, DependentRequired: map[string][]string{"a": {"b"}}, } data, err := schema.MarshalJSON() require.NoError(t, err) - var roundTripped Schema + var roundTripped openapi3.Schema err = roundTripped.UnmarshalJSON(data) require.NoError(t, err) @@ -172,39 +173,39 @@ func TestSchemaIfThenElse_MarshalRoundTrip(t *testing.T) { func TestSchemaIfThenElse_Validate(t *testing.T) { t.Run("unresolved if ref fails validation", func(t *testing.T) { - schema := &Schema{ - If: &SchemaRef{Ref: "#/components/schemas/Missing"}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Ref: "#/components/schemas/Missing"}, } - err := schema.Validate(context.Background()) + err := schema.Validate(context.Background(), openapi3.IsOpenAPI31OrLater()) require.Error(t, err) require.ErrorContains(t, err, "unresolved ref") }) t.Run("unresolved then ref fails validation", func(t *testing.T) { - schema := &Schema{ - Then: &SchemaRef{Ref: "#/components/schemas/Missing"}, + schema := &openapi3.Schema{ + Then: &openapi3.SchemaRef{Ref: "#/components/schemas/Missing"}, } - err := schema.Validate(context.Background()) + err := schema.Validate(context.Background(), openapi3.IsOpenAPI31OrLater()) require.Error(t, err) require.ErrorContains(t, err, "unresolved ref") }) t.Run("unresolved else ref fails validation", func(t *testing.T) { - schema := &Schema{ - Else: &SchemaRef{Ref: "#/components/schemas/Missing"}, + schema := &openapi3.Schema{ + Else: &openapi3.SchemaRef{Ref: "#/components/schemas/Missing"}, } - err := schema.Validate(context.Background()) + err := schema.Validate(context.Background(), openapi3.IsOpenAPI31OrLater()) require.Error(t, err) require.ErrorContains(t, err, "unresolved ref") }) t.Run("valid if/then/else passes validation", func(t *testing.T) { - schema := &Schema{ - If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - Then: &SchemaRef{Value: &Schema{MinLength: 1}}, - Else: &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, + schema := &openapi3.Schema{ + If: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + Then: &openapi3.SchemaRef{Value: &openapi3.Schema{MinLength: 1}}, + Else: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, } - err := schema.Validate(context.Background()) + err := schema.Validate(context.Background(), openapi3.IsOpenAPI31OrLater()) require.NoError(t, err) }) } diff --git a/openapi3/schema_jsonschema_validator.go b/openapi3/schema_jsonschema_validator.go index f66609322..7b3443d55 100644 --- a/openapi3/schema_jsonschema_validator.go +++ b/openapi3/schema_jsonschema_validator.go @@ -197,8 +197,8 @@ func formatValidationError(verr *jsonschema.ValidationError, parentPath string) } } -// visitJSONWithJSONSchema validates using the JSON Schema 2020-12 validator -func (schema *Schema) visitJSONWithJSONSchema(settings *schemaValidationSettings, value any) error { +// useJSONSchema2020 validates using the JSON Schema 2020-12 validator +func (schema *Schema) useJSONSchema2020(settings *schemaValidationSettings, value any) error { validator, err := newJSONSchemaValidator(schema) if err != nil { // Fall back to built-in validator if compilation fails diff --git a/openapi3/schema_jsonschema_validator_test.go b/openapi3/schema_jsonschema_validator_test.go index 9cfd874f4..1a7c3bf42 100644 --- a/openapi3/schema_jsonschema_validator_test.go +++ b/openapi3/schema_jsonschema_validator_test.go @@ -1,49 +1,50 @@ -package openapi3 +package openapi3_test import ( "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestJSONSchema2020Validator_Basic(t *testing.T) { t.Run("string validation", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, } - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(123, EnableJSONSchema2020()) + err = schema.VisitJSON(123, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("number validation", func(t *testing.T) { min := 0.0 max := 100.0 - schema := &Schema{ - Type: &Types{"number"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"number"}, Min: &min, Max: &max, } - err := schema.VisitJSON(50.0, EnableJSONSchema2020()) + err := schema.VisitJSON(50.0, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(150.0, EnableJSONSchema2020()) + err = schema.VisitJSON(150.0, openapi3.EnableJSONSchema2020()) require.Error(t, err) - err = schema.VisitJSON(-10.0, EnableJSONSchema2020()) + err = schema.VisitJSON(-10.0, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("object validation", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - Properties: Schemas{ - "name": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - "age": &SchemaRef{Value: &Schema{Type: &Types{"integer"}}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "age": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}}}, }, Required: []string{"name"}, } @@ -61,65 +62,65 @@ func TestJSONSchema2020Validator_Basic(t *testing.T) { }) t.Run("array validation", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - Items: &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, }}, } - err := schema.VisitJSON([]any{"a", "b", "c"}, EnableJSONSchema2020()) + err := schema.VisitJSON([]any{"a", "b", "c"}, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON([]any{"a", 1, "c"}, EnableJSONSchema2020()) + err = schema.VisitJSON([]any{"a", 1, "c"}, openapi3.EnableJSONSchema2020()) require.Error(t, err) // item 1 is not a string }) } func TestJSONSchema2020Validator_OpenAPI31Features(t *testing.T) { t.Run("type array with null", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string", "null"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, } - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(nil, EnableJSONSchema2020()) + err = schema.VisitJSON(nil, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(123, EnableJSONSchema2020()) + err = schema.VisitJSON(123, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("nullable conversion", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, } - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(nil, EnableJSONSchema2020()) + err = schema.VisitJSON(nil, openapi3.EnableJSONSchema2020()) require.NoError(t, err) }) t.Run("const validation", func(t *testing.T) { - schema := &Schema{ + schema := &openapi3.Schema{ Const: "fixed-value", } - err := schema.VisitJSON("fixed-value", EnableJSONSchema2020()) + err := schema.VisitJSON("fixed-value", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON("other-value", EnableJSONSchema2020()) + err = schema.VisitJSON("other-value", openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("examples field", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Examples: []any{ "example1", "example2", @@ -127,7 +128,7 @@ func TestJSONSchema2020Validator_OpenAPI31Features(t *testing.T) { } // Examples don't affect validation, just ensure schema is valid - err := schema.VisitJSON("any-value", EnableJSONSchema2020()) + err := schema.VisitJSON("any-value", openapi3.EnableJSONSchema2020()) require.NoError(t, err) }) } @@ -136,100 +137,100 @@ func TestJSONSchema2020Validator_ExclusiveMinMax(t *testing.T) { t.Run("exclusive minimum as boolean (OpenAPI 3.0 style)", func(t *testing.T) { min := 0.0 boolTrue := true - schema := &Schema{ - Type: &Types{"number"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"number"}, Min: &min, - ExclusiveMin: ExclusiveBound{Bool: &boolTrue}, + ExclusiveMin: openapi3.ExclusiveBound{Bool: &boolTrue}, } - err := schema.VisitJSON(0.1, EnableJSONSchema2020()) + err := schema.VisitJSON(0.1, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(0.0, EnableJSONSchema2020()) + err = schema.VisitJSON(0.0, openapi3.EnableJSONSchema2020()) require.Error(t, err) // should be exclusive }) t.Run("exclusive maximum as boolean (OpenAPI 3.0 style)", func(t *testing.T) { max := 100.0 boolTrue := true - schema := &Schema{ - Type: &Types{"number"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"number"}, Max: &max, - ExclusiveMax: ExclusiveBound{Bool: &boolTrue}, + ExclusiveMax: openapi3.ExclusiveBound{Bool: &boolTrue}, } - err := schema.VisitJSON(99.9, EnableJSONSchema2020()) + err := schema.VisitJSON(99.9, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(100.0, EnableJSONSchema2020()) + err = schema.VisitJSON(100.0, openapi3.EnableJSONSchema2020()) require.Error(t, err) // should be exclusive }) } func TestJSONSchema2020Validator_ComplexSchemas(t *testing.T) { t.Run("oneOf", func(t *testing.T) { - schema := &Schema{ - OneOf: SchemaRefs{ - &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, + schema := &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, }, } - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(42, EnableJSONSchema2020()) + err = schema.VisitJSON(42, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(true, EnableJSONSchema2020()) + err = schema.VisitJSON(true, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("anyOf", func(t *testing.T) { - schema := &Schema{ - AnyOf: SchemaRefs{ - &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, + schema := &openapi3.Schema{ + AnyOf: openapi3.SchemaRefs{ + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, }, } - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(42, EnableJSONSchema2020()) + err = schema.VisitJSON(42, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(true, EnableJSONSchema2020()) + err = schema.VisitJSON(true, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("allOf", func(t *testing.T) { min := 0.0 max := 100.0 - schema := &Schema{ - AllOf: SchemaRefs{ - &SchemaRef{Value: &Schema{Type: &Types{"number"}}}, - &SchemaRef{Value: &Schema{Min: &min}}, - &SchemaRef{Value: &Schema{Max: &max}}, + schema := &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{ + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Min: &min}}, + &openapi3.SchemaRef{Value: &openapi3.Schema{Max: &max}}, }, } - err := schema.VisitJSON(50.0, EnableJSONSchema2020()) + err := schema.VisitJSON(50.0, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(150.0, EnableJSONSchema2020()) + err = schema.VisitJSON(150.0, openapi3.EnableJSONSchema2020()) require.Error(t, err) // exceeds max }) t.Run("not", func(t *testing.T) { - schema := &Schema{ - Not: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + schema := &openapi3.Schema{ + Not: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, } - err := schema.VisitJSON(42, EnableJSONSchema2020()) + err := schema.VisitJSON(42, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON("hello", EnableJSONSchema2020()) + err = schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.Error(t, err) }) } @@ -237,12 +238,12 @@ func TestJSONSchema2020Validator_ComplexSchemas(t *testing.T) { func TestJSONSchema2020Validator_Fallback(t *testing.T) { t.Run("fallback on compilation error", func(t *testing.T) { // Create a schema that might cause compilation issues - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, } // Should not panic, even if there's an issue - err := schema.VisitJSON("test", EnableJSONSchema2020()) + err := schema.VisitJSON("test", openapi3.EnableJSONSchema2020()) require.NoError(t, err) }) } @@ -254,63 +255,63 @@ func TestJSONSchema2020Validator_TransformRecursesInto31Fields(t *testing.T) { // to a type array for the JSON Schema 2020-12 validator to handle null. t.Run("prefixItems with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - PrefixItems: SchemaRefs{ - &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + PrefixItems: openapi3.SchemaRefs{ + &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, }}, }, } - err := schema.VisitJSON([]any{"hello"}, EnableJSONSchema2020()) + err := schema.VisitJSON([]any{"hello"}, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON([]any{nil}, EnableJSONSchema2020()) + err = schema.VisitJSON([]any{nil}, openapi3.EnableJSONSchema2020()) require.NoError(t, err, "null should be accepted after nullable conversion in prefixItems") }) t.Run("contains with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - Contains: &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Contains: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, }}, } - err := schema.VisitJSON([]any{nil}, EnableJSONSchema2020()) + err := schema.VisitJSON([]any{nil}, openapi3.EnableJSONSchema2020()) require.NoError(t, err, "null should match contains after nullable conversion") }) t.Run("patternProperties with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - PatternProperties: Schemas{ - "^x-": &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + PatternProperties: openapi3.Schemas{ + "^x-": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, }}, }, } - err := schema.VisitJSON(map[string]any{"x-val": nil}, EnableJSONSchema2020()) + err := schema.VisitJSON(map[string]any{"x-val": nil}, openapi3.EnableJSONSchema2020()) require.NoError(t, err, "null should be accepted after nullable conversion in patternProperties") }) t.Run("dependentSchemas with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - Properties: Schemas{ - "name": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, - "tag": &SchemaRef{Value: &Schema{Type: &Types{"string"}, Nullable: true}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "tag": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, Nullable: true}}, }, - DependentSchemas: Schemas{ - "name": &SchemaRef{Value: &Schema{ - Properties: Schemas{ - "tag": &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + DependentSchemas: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{ + "tag": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, }}, }, @@ -318,92 +319,92 @@ func TestJSONSchema2020Validator_TransformRecursesInto31Fields(t *testing.T) { }, } - err := schema.VisitJSON(map[string]any{"name": "foo", "tag": nil}, EnableJSONSchema2020()) + err := schema.VisitJSON(map[string]any{"name": "foo", "tag": nil}, openapi3.EnableJSONSchema2020()) require.NoError(t, err, "null should be accepted after nullable conversion in dependentSchemas") }) t.Run("propertyNames with nullable not applicable but transform should not crash", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - PropertyNames: &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + PropertyNames: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, MinLength: 1, }}, } - err := schema.VisitJSON(map[string]any{"abc": 1}, EnableJSONSchema2020()) + err := schema.VisitJSON(map[string]any{"abc": 1}, openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(map[string]any{"": 1}, EnableJSONSchema2020()) + err = schema.VisitJSON(map[string]any{"": 1}, openapi3.EnableJSONSchema2020()) require.Error(t, err, "empty property name should fail minLength") }) t.Run("unevaluatedItems with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - PrefixItems: SchemaRefs{ - &SchemaRef{Value: &Schema{Type: &Types{"integer"}}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + PrefixItems: openapi3.SchemaRefs{ + &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}}}, }, - UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + UnevaluatedItems: openapi3.BoolSchema{Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, }}}, } - err := schema.VisitJSON([]any{1, nil}, EnableJSONSchema2020()) + err := schema.VisitJSON([]any{1, nil}, openapi3.EnableJSONSchema2020()) require.NoError(t, err, "null should be accepted after nullable conversion in unevaluatedItems") }) t.Run("unevaluatedProperties with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - Properties: Schemas{ - "name": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, }, - UnevaluatedProperties: BoolSchema{Schema: &SchemaRef{Value: &Schema{ - Type: &Types{"string"}, + UnevaluatedProperties: openapi3.BoolSchema{Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Nullable: true, }}}, } - err := schema.VisitJSON(map[string]any{"name": "foo", "extra": nil}, EnableJSONSchema2020()) + err := schema.VisitJSON(map[string]any{"name": "foo", "extra": nil}, openapi3.EnableJSONSchema2020()) require.NoError(t, err, "null should be accepted after nullable conversion in unevaluatedProperties") }) t.Run("contentSchema with nullable nested schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, ContentMediaType: "application/json", - ContentSchema: &SchemaRef{Value: &Schema{ - Type: &Types{"object"}, + ContentSchema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, Nullable: true, }}, } // contentSchema transform should not crash and should handle nullable - err := schema.VisitJSON("null", EnableJSONSchema2020()) + err := schema.VisitJSON("null", openapi3.EnableJSONSchema2020()) require.NoError(t, err, "contentSchema transform should handle nullable nested schema") }) } func TestBuiltInValidatorStillWorks(t *testing.T) { t.Run("string validation with built-in", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, } - err := schema.VisitJSON("hello", EnableJSONSchema2020()) + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) require.NoError(t, err) - err = schema.VisitJSON(123, EnableJSONSchema2020()) + err = schema.VisitJSON(123, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) t.Run("object validation with built-in", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - Properties: Schemas{ - "name": &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, }, Required: []string{"name"}, } @@ -413,7 +414,7 @@ func TestBuiltInValidatorStillWorks(t *testing.T) { }) require.NoError(t, err) - err = schema.VisitJSON(map[string]any{}, EnableJSONSchema2020()) + err = schema.VisitJSON(map[string]any{}, openapi3.EnableJSONSchema2020()) require.Error(t, err) }) } diff --git a/openapi3/schema_types_test.go b/openapi3/schema_types_test.go index 6c86fbb88..6fd3bc54d 100644 --- a/openapi3/schema_types_test.go +++ b/openapi3/schema_types_test.go @@ -1,93 +1,94 @@ -package openapi3 +package openapi3_test import ( "encoding/json" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestTypes_HelperMethods(t *testing.T) { t.Run("IncludesNull", func(t *testing.T) { // Single type without null - types := &Types{"string"} + types := &openapi3.Types{"string"} require.False(t, types.IncludesNull()) // Type array with null - types = &Types{"string", "null"} + types = &openapi3.Types{"string", "null"} require.True(t, types.IncludesNull()) // Multiple types without null - types = &Types{"string", "number"} + types = &openapi3.Types{"string", "number"} require.False(t, types.IncludesNull()) // Nil types - var nilTypes *Types + var nilTypes *openapi3.Types require.False(t, nilTypes.IncludesNull()) }) t.Run("IsMultiple", func(t *testing.T) { // Single type - types := &Types{"string"} + types := &openapi3.Types{"string"} require.False(t, types.IsMultiple()) // Multiple types - types = &Types{"string", "null"} + types = &openapi3.Types{"string", "null"} require.True(t, types.IsMultiple()) - types = &Types{"string", "number", "null"} + types = &openapi3.Types{"string", "number", "null"} require.True(t, types.IsMultiple()) // Empty types - types = &Types{} + types = &openapi3.Types{} require.False(t, types.IsMultiple()) // Nil types - var nilTypes *Types + var nilTypes *openapi3.Types require.False(t, nilTypes.IsMultiple()) }) t.Run("IsSingle", func(t *testing.T) { // Single type - types := &Types{"string"} + types := &openapi3.Types{"string"} require.True(t, types.IsSingle()) // Multiple types - types = &Types{"string", "null"} + types = &openapi3.Types{"string", "null"} require.False(t, types.IsSingle()) // Empty types - types = &Types{} + types = &openapi3.Types{} require.False(t, types.IsSingle()) // Nil types - var nilTypes *Types + var nilTypes *openapi3.Types require.False(t, nilTypes.IsSingle()) }) t.Run("IsEmpty", func(t *testing.T) { // Single type - types := &Types{"string"} + types := &openapi3.Types{"string"} require.False(t, types.IsEmpty()) // Multiple types - types = &Types{"string", "null"} + types = &openapi3.Types{"string", "null"} require.False(t, types.IsEmpty()) // Empty types - types = &Types{} + types = &openapi3.Types{} require.True(t, types.IsEmpty()) // Nil types - var nilTypes *Types + var nilTypes *openapi3.Types require.True(t, nilTypes.IsEmpty()) }) } func TestTypes_ArraySerialization(t *testing.T) { t.Run("single type serializes as string", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, } data, err := json.Marshal(schema) @@ -99,8 +100,8 @@ func TestTypes_ArraySerialization(t *testing.T) { }) t.Run("multiple types serialize as array", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string", "null"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, } data, err := json.Marshal(schema) @@ -113,7 +114,7 @@ func TestTypes_ArraySerialization(t *testing.T) { t.Run("deserialize string to single type", func(t *testing.T) { jsonData := []byte(`{"type":"string"}`) - var schema Schema + var schema openapi3.Schema err := json.Unmarshal(jsonData, &schema) require.NoError(t, err) @@ -125,7 +126,7 @@ func TestTypes_ArraySerialization(t *testing.T) { t.Run("deserialize array to multiple types", func(t *testing.T) { jsonData := []byte(`{"type":["string","null"]}`) - var schema Schema + var schema openapi3.Schema err := json.Unmarshal(jsonData, &schema) require.NoError(t, err) @@ -138,7 +139,7 @@ func TestTypes_ArraySerialization(t *testing.T) { func TestTypes_OpenAPI31Features(t *testing.T) { t.Run("type array with null", func(t *testing.T) { - types := &Types{"string", "null"} + types := &openapi3.Types{"string", "null"} require.True(t, types.Includes("string")) require.True(t, types.IncludesNull()) @@ -153,7 +154,7 @@ func TestTypes_OpenAPI31Features(t *testing.T) { }) t.Run("type array without null", func(t *testing.T) { - types := &Types{"string", "number"} + types := &openapi3.Types{"string", "number"} require.True(t, types.Includes("string")) require.True(t, types.Includes("number")) @@ -162,7 +163,7 @@ func TestTypes_OpenAPI31Features(t *testing.T) { }) t.Run("OpenAPI 3.0 style single type", func(t *testing.T) { - types := &Types{"string"} + types := &openapi3.Types{"string"} require.True(t, types.Is("string")) require.True(t, types.Includes("string")) @@ -174,7 +175,7 @@ func TestTypes_OpenAPI31Features(t *testing.T) { func TestTypes_EdgeCases(t *testing.T) { t.Run("nil types permits everything", func(t *testing.T) { - var types *Types + var types *openapi3.Types require.True(t, types.Permits("string")) require.True(t, types.Permits("number")) @@ -183,7 +184,7 @@ func TestTypes_EdgeCases(t *testing.T) { }) t.Run("empty slice of types", func(t *testing.T) { - types := &Types{} + types := &openapi3.Types{} require.False(t, types.Includes("string")) require.False(t, types.Permits("string")) @@ -193,13 +194,13 @@ func TestTypes_EdgeCases(t *testing.T) { }) t.Run("Slice method", func(t *testing.T) { - types := &Types{"string", "null"} + types := &openapi3.Types{"string", "null"} slice := types.Slice() require.Equal(t, []string{"string", "null"}, slice) // Nil types - var nilTypes *Types + var nilTypes *openapi3.Types require.Nil(t, nilTypes.Slice()) }) } @@ -207,22 +208,22 @@ func TestTypes_EdgeCases(t *testing.T) { func TestTypes_BackwardCompatibility(t *testing.T) { t.Run("existing Is method still works", func(t *testing.T) { // Single type - types := &Types{"string"} + types := &openapi3.Types{"string"} require.True(t, types.Is("string")) require.False(t, types.Is("number")) // Multiple types - Is should return false - types = &Types{"string", "null"} + types = &openapi3.Types{"string", "null"} require.False(t, types.Is("string")) require.False(t, types.Is("null")) }) t.Run("existing Includes method still works", func(t *testing.T) { - types := &Types{"string"} + types := &openapi3.Types{"string"} require.True(t, types.Includes("string")) require.False(t, types.Includes("number")) - types = &Types{"string", "null"} + types = &openapi3.Types{"string", "null"} require.True(t, types.Includes("string")) require.True(t, types.Includes("null")) require.False(t, types.Includes("number")) @@ -230,11 +231,11 @@ func TestTypes_BackwardCompatibility(t *testing.T) { t.Run("existing Permits method still works", func(t *testing.T) { // Nil types permits everything - var types *Types + var types *openapi3.Types require.True(t, types.Permits("anything")) // Specific types - types = &Types{"string"} + types = &openapi3.Types{"string"} require.True(t, types.Permits("string")) require.False(t, types.Permits("number")) }) diff --git a/openapi3/schema_validate_31_test.go b/openapi3/schema_validate_31_test.go index 79675073b..bf91a6d2d 100644 --- a/openapi3/schema_validate_31_test.go +++ b/openapi3/schema_validate_31_test.go @@ -1,45 +1,44 @@ -package openapi3 +package openapi3_test import ( "context" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestSchemaValidate31SubSchemas(t *testing.T) { - ctx := context.Background() + ctx := openapi3.WithValidationOptions(context.Background(), openapi3.IsOpenAPI31OrLater()) // Helper: a schema with an invalid nested schema (pattern with bad regex) - invalidSchema := &Schema{ - Type: &Types{"string"}, + invalidSchema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, Pattern: "[invalid", } t.Run("prefixItems with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - PrefixItems: SchemaRefs{ - {Value: invalidSchema}, - }, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + PrefixItems: openapi3.SchemaRefs{{Value: invalidSchema}}, } err := schema.Validate(ctx) require.Error(t, err, "should detect invalid sub-schema in prefixItems") }) t.Run("contains with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - Contains: &SchemaRef{Value: invalidSchema}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Contains: &openapi3.SchemaRef{Value: invalidSchema}, } err := schema.Validate(ctx) require.Error(t, err, "should detect invalid sub-schema in contains") }) t.Run("patternProperties with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - PatternProperties: Schemas{ + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + PatternProperties: openapi3.Schemas{ "^x-": {Value: invalidSchema}, }, } @@ -48,9 +47,9 @@ func TestSchemaValidate31SubSchemas(t *testing.T) { }) t.Run("dependentSchemas with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - DependentSchemas: Schemas{ + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + DependentSchemas: openapi3.Schemas{ "name": {Value: invalidSchema}, }, } @@ -59,52 +58,52 @@ func TestSchemaValidate31SubSchemas(t *testing.T) { }) t.Run("propertyNames with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - PropertyNames: &SchemaRef{Value: invalidSchema}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + PropertyNames: &openapi3.SchemaRef{Value: invalidSchema}, } err := schema.Validate(ctx) require.Error(t, err, "should detect invalid sub-schema in propertyNames") }) t.Run("unevaluatedItems with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"array"}, - UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: invalidSchema}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + UnevaluatedItems: openapi3.BoolSchema{Schema: &openapi3.SchemaRef{Value: invalidSchema}}, } err := schema.Validate(ctx) require.Error(t, err, "should detect invalid sub-schema in unevaluatedItems") }) t.Run("unevaluatedProperties with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"object"}, - UnevaluatedProperties: BoolSchema{Schema: &SchemaRef{Value: invalidSchema}}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + UnevaluatedProperties: openapi3.BoolSchema{Schema: &openapi3.SchemaRef{Value: invalidSchema}}, } err := schema.Validate(ctx) require.Error(t, err, "should detect invalid sub-schema in unevaluatedProperties") }) t.Run("contentSchema with invalid sub-schema", func(t *testing.T) { - schema := &Schema{ - Type: &Types{"string"}, + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, ContentMediaType: "application/json", - ContentSchema: &SchemaRef{Value: invalidSchema}, + ContentSchema: &openapi3.SchemaRef{Value: invalidSchema}, } err := schema.Validate(ctx) require.Error(t, err, "should detect invalid sub-schema in contentSchema") }) t.Run("valid 3.1 sub-schemas pass validation", func(t *testing.T) { - validSubSchema := &Schema{Type: &Types{"string"}} - schema := &Schema{ - Type: &Types{"array"}, - Items: &SchemaRef{Value: validSubSchema}, - PrefixItems: SchemaRefs{ + validSubSchema := &openapi3.Schema{Type: &openapi3.Types{"string"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{Value: validSubSchema}, + PrefixItems: openapi3.SchemaRefs{ {Value: validSubSchema}, }, - Contains: &SchemaRef{Value: validSubSchema}, - UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: validSubSchema}}, + Contains: &openapi3.SchemaRef{Value: validSubSchema}, + UnevaluatedItems: openapi3.BoolSchema{Schema: &openapi3.SchemaRef{Value: validSubSchema}}, } err := schema.Validate(ctx) require.NoError(t, err, "valid sub-schemas should pass validation") diff --git a/openapi3/security_scheme.go b/openapi3/security_scheme.go index b90dce7fb..0afc7920b 100644 --- a/openapi3/security_scheme.go +++ b/openapi3/security_scheme.go @@ -11,6 +11,7 @@ import ( // SecurityScheme is specified by OpenAPI/Swagger standard version 3. // See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object +// and https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object type SecurityScheme struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -171,7 +172,9 @@ func (ss *SecurityScheme) Validate(ctx context.Context, opts ...ValidationOption return fmt.Errorf("no OIDC URL found for openIdConnect security scheme %q", ss.Name) } case "mutualTLS": - // OpenAPI 3.1: mutualTLS has no additional required fields + if !getValidationOptions(ctx).isOpenAPI31OrLater { + return errValueOfFieldFor31Plus(ss.Type, "type") + } default: return fmt.Errorf("security scheme 'type' can't be %q", ss.Type) } diff --git a/openapi3/testdata/schema31-ref-siblings.yml b/openapi3/testdata/schema31-ref-siblings.yml deleted file mode 100644 index 87dda65ae..000000000 --- a/openapi3/testdata/schema31-ref-siblings.yml +++ /dev/null @@ -1,29 +0,0 @@ -openapi: "3.1.0" -info: - title: Ref Sibling Test - version: "1.0" -paths: - /ping: - get: - operationId: getPing - responses: - "200": - description: ok - content: - application/json: - schema: - $ref: "#/components/schemas/PingResponse" -components: - schemas: - PingStatus: - type: string - enum: [ok, error] - PingResponse: - type: object - required: [message, status] - properties: - message: - type: string - status: - deprecated: true # sibling keyword alongside $ref — valid in OAS 3.1, ignored in 3.0 - $ref: "#/components/schemas/PingStatus" diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index ef76eafa5..0da1cc1b1 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -13,9 +13,10 @@ type ValidationOptions struct { schemaFormatValidationEnabled bool schemaPatternValidationDisabled bool schemaExtensionsInRefProhibited bool + jsonSchema2020ValidationEnabled bool + isOpenAPI31OrLater bool regexCompilerFunc RegexCompilerFunc extraSiblingFieldsAllowed map[string]struct{} - jsonSchema2020ValidationEnabled bool // Enables JSON Schema 2020-12 compliant validation for OpenAPI 3.1 } type validationOptionsKey struct{} @@ -32,11 +33,11 @@ func AllowExtraSiblingFields(fields ...string) ValidationOption { } } -// EnableJSONSchema2020Validation enables JSON Schema 2020-12 compliant validation for OpenAPI 3.1 documents. -// This option should be used with doc.Validate(). -func EnableJSONSchema2020Validation() ValidationOption { +// IsOpenAPI31OrLater enables "JSON Schema Draft 2020-12"-compliant validation (for OpenAPI 3.1 documents). +func IsOpenAPI31OrLater() ValidationOption { return func(options *ValidationOptions) { - options.jsonSchema2020ValidationEnabled = true + options.isOpenAPI31OrLater = true // To distinguish from v3.0 + options.jsonSchema2020ValidationEnabled = true // TODO: use even for v3.0 } } diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go index 46c2d76fe..e4d944286 100644 --- a/openapi3filter/validate_request.go +++ b/openapi3filter/validate_request.go @@ -234,13 +234,12 @@ func ValidateParameter(ctx context.Context, input *RequestValidationInput, param var opts []openapi3.SchemaValidationOption if options.MultiError { - opts = make([]openapi3.SchemaValidationOption, 0, 1) opts = append(opts, openapi3.MultiErrors()) } if options.customSchemaErrorFunc != nil { opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc)) } - if input.Route != nil && input.Route.Spec != nil && input.Route.Spec.IsOpenAPI3_1() { + if input.Route != nil && input.Route.Spec.IsOpenAPI31OrLater() { opts = append(opts, openapi3.EnableJSONSchema2020()) } if err = schema.VisitJSON(value, opts...); err != nil { @@ -333,7 +332,7 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req } defaultsSet := false - opts := make([]openapi3.SchemaValidationOption, 0, 4+len(options.SchemaValidationOptions)) + var opts []openapi3.SchemaValidationOption opts = append(opts, openapi3.VisitAsRequest()) if !options.SkipSettingDefaults { opts = append(opts, openapi3.DefaultsSet(func() { defaultsSet = true })) @@ -352,7 +351,7 @@ func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, req } // Append additional schema validation options (e.g., document-scoped format validators) opts = append(opts, options.SchemaValidationOptions...) - if input.Route != nil && input.Route.Spec != nil && input.Route.Spec.IsOpenAPI3_1() { + if input.Route != nil && input.Route.Spec.IsOpenAPI31OrLater() { opts = append(opts, openapi3.EnableJSONSchema2020()) } diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index 5c7179325..2e0128b91 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -63,7 +63,7 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error return &ResponseError{Input: input, Reason: "response has not been resolved"} } - opts := make([]openapi3.SchemaValidationOption, 0, 3+len(options.SchemaValidationOptions)) + var opts []openapi3.SchemaValidationOption if options.MultiError { opts = append(opts, openapi3.MultiErrors()) } @@ -75,7 +75,7 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error } // Append additional schema validation options (e.g., document-scoped format validators) opts = append(opts, options.SchemaValidationOptions...) - if route.Spec != nil && route.Spec.IsOpenAPI3_1() { + if route.Spec.IsOpenAPI31OrLater() { opts = append(opts, openapi3.EnableJSONSchema2020()) }