diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 449950500..7ab816198 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -2,9 +2,32 @@ package openapi3 // import "github.com/getkin/kin-openapi/openapi3" Package openapi3 parses and writes OpenAPI 3 specification documents. -See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md +Supports both OpenAPI 3.0 and OpenAPI 3.1: + - OpenAPI 3.0.x: + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md + - OpenAPI 3.1.x: + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md -Code generated by go generate; DO NOT EDIT. +OpenAPI 3.1 Features: + - Type arrays with null support (e.g., ["string", "null"]) + - JSON Schema 2020-12 keywords (const, examples, prefixItems, etc.) + - Webhooks for defining callback operations + - JSON Schema dialect specification + - SPDX license identifiers + +The implementation maintains 100% backward compatibility with OpenAPI 3.0. + +For OpenAPI 3.1 validation, use the JSON Schema 2020-12 validator option: + + schema.VisitJSON(value, openapi3.EnableJSONSchema2020()) + +Version detection is available via helper methods: + + if doc.IsOpenAPI3_1() { + // Handle OpenAPI 3.1 specific features + } + +Code generated by go generate using refs.tmpl; DO NOT EDIT refs.go. CONSTANTS @@ -229,19 +252,26 @@ func WithValidationOptions(ctx context.Context, opts ...ValidationOption) contex TYPES -type AdditionalProperties struct { +type AdditionalProperties = BoolSchema + AdditionalProperties is a type alias for BoolSchema, kept for backward + compatibility. + +type BoolSchema struct { Has *bool Schema *SchemaRef } + BoolSchema represents a JSON Schema keyword that can be either a boolean + or a schema object. Used for additionalProperties, unevaluatedProperties, + and unevaluatedItems. -func (addProps AdditionalProperties) MarshalJSON() ([]byte, error) - MarshalJSON returns the JSON encoding of AdditionalProperties. +func (bs BoolSchema) MarshalJSON() ([]byte, error) + MarshalJSON returns the JSON encoding of BoolSchema. -func (addProps AdditionalProperties) MarshalYAML() (any, error) - MarshalYAML returns the YAML encoding of AdditionalProperties. +func (bs BoolSchema) MarshalYAML() (any, error) + MarshalYAML returns the YAML encoding of BoolSchema. -func (addProps *AdditionalProperties) UnmarshalJSON(data []byte) error - UnmarshalJSON sets AdditionalProperties to a copy of data. +func (bs *BoolSchema) UnmarshalJSON(data []byte) error + UnmarshalJSON sets BoolSchema to a copy of data. type Callback struct { Extensions map[string]any `json:"-" yaml:"-"` @@ -567,6 +597,31 @@ func (m Examples) JSONLookup(token string) (any, error) func (examples *Examples) UnmarshalJSON(data []byte) (err error) UnmarshalJSON sets Examples to a copy of data. +type ExclusiveBound struct { + Bool *bool // For OpenAPI 3.0 style (modifier for min/max) + Value *float64 // For OpenAPI 3.1 style (actual bound value) +} + ExclusiveBound represents exclusiveMinimum/exclusiveMaximum which changed + type between OpenAPI versions. In OpenAPI 3.0 (JSON Schema draft-04): + boolean that modifies minimum/maximum In OpenAPI 3.1 (JSON Schema 2020-12): + number representing the actual exclusive bound + +func (eb ExclusiveBound) IsSet() bool + IsSet returns true if either Bool or Value is set. + +func (eb ExclusiveBound) IsTrue() bool + IsTrue returns true if the bound is set as a boolean true (OpenAPI 3.0 + style). + +func (eb ExclusiveBound) MarshalJSON() ([]byte, error) + MarshalJSON returns the JSON encoding of ExclusiveBound. + +func (eb ExclusiveBound) MarshalYAML() (any, error) + MarshalYAML returns the YAML encoding of ExclusiveBound. + +func (eb *ExclusiveBound) UnmarshalJSON(data []byte) error + UnmarshalJSON sets ExclusiveBound to a copy of data. + type ExternalDocs struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -686,7 +741,8 @@ type Info struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` - Title string `json:"title" yaml:"title"` // Required + Title string `json:"title" yaml:"title"` // Required + 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"` @@ -695,6 +751,8 @@ type Info struct { } Info is specified by OpenAPI/Swagger standard version 3. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#info-object + and + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#info-object func (info Info) MarshalJSON() ([]byte, error) MarshalJSON returns the JSON encoding of Info. @@ -717,9 +775,15 @@ type License struct { Name string `json:"name" yaml:"name"` // Required URL string `json:"url,omitempty" yaml:"url,omitempty"` + + // 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"` } 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 + and + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#license-object func (license License) MarshalJSON() ([]byte, error) MarshalJSON returns the JSON encoding of License. @@ -1613,8 +1677,10 @@ type Schema struct { // Array-related, here for struct compactness UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` // Number-related, here for struct compactness - ExclusiveMin bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` - ExclusiveMax bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + // 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"` // Properties Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` @@ -1645,6 +1711,43 @@ type Schema struct { MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` 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"` } 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 @@ -1735,8 +1838,16 @@ func (schema *Schema) WithDefault(defaultValue any) *Schema func (schema *Schema) WithEnum(values ...any) *Schema func (schema *Schema) WithExclusiveMax(value bool) *Schema + WithExclusiveMax sets exclusiveMaximum as a boolean (OpenAPI 3.0 style). + +func (schema *Schema) WithExclusiveMaxValue(value float64) *Schema + WithExclusiveMaxValue sets exclusiveMaximum as a number (OpenAPI 3.1 style). func (schema *Schema) WithExclusiveMin(value bool) *Schema + WithExclusiveMin sets exclusiveMinimum as a boolean (OpenAPI 3.0 style). + +func (schema *Schema) WithExclusiveMinValue(value float64) *Schema + WithExclusiveMinValue sets exclusiveMinimum as a number (OpenAPI 3.1 style). func (schema *Schema) WithFormat(value string) *Schema @@ -1881,6 +1992,12 @@ func EnableFormatValidation() SchemaValidationOption validating documents that mention schema formats that are not defined by the OpenAPIv3 specification. +func EnableJSONSchema2020() SchemaValidationOption + EnableJSONSchema2020 enables JSON Schema 2020-12 compliant validation. + This enables support for OpenAPI 3.1 and JSON Schema 2020-12 features. + When enabled, validation uses the jsonschema library instead of the built-in + validator. + func FailFast() SchemaValidationOption FailFast returns schema validation errors quicker. @@ -2168,17 +2285,27 @@ type T struct { 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 + 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"` + // Has unexported fields. } T is the root of an OpenAPI v3 document See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object + and + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object func (doc *T) AddOperation(path string, method string, operation *Operation) @@ -2204,6 +2331,12 @@ 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) IsOpenAPI3_1() bool + IsOpenAPI3_1 returns true if the document is OpenAPI 3.1.x + func (doc *T) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -2250,6 +2383,9 @@ func (doc *T) ValidateSchemaJSON(schema *Schema, value any, opts ...SchemaValida 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:"-"` @@ -2282,18 +2418,124 @@ func (tags Tags) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Tags does not comply with the OpenAPI spec. type Types []string + Types represents the type(s) of a schema. + + In OpenAPI 3.0, this is typically a single type (e.g., "string"). In OpenAPI + 3.1, it can be an array of types (e.g., ["string", "null"]). + + Serialization behavior: + - Single type: serializes as a string (e.g., "string") + - Multiple types: serializes as an array (e.g., ["string", "null"]) + - Accepts both string and array formats when unmarshaling + + Example OpenAPI 3.0 (single type): + + schema := &Schema{Type: &Types{"string"}} + // JSON: {"type": "string"} + + Example OpenAPI 3.1 (type array): + + schema := &Schema{Type: &Types{"string", "null"}} + // JSON: {"type": ["string", "null"]} func (pTypes *Types) Includes(typ string) bool + Includes returns true if the given type is included in the type array. + Returns false if types is nil. + + Example: + + types := &Types{"string", "null"} + types.Includes("string") // true + types.Includes("null") // true + types.Includes("number") // false + +func (types *Types) IncludesNull() bool + IncludesNull returns true if the type array includes "null". This is useful + for OpenAPI 3.1 where null is a first-class type. + + Example: + + types := &Types{"string", "null"} + types.IncludesNull() // true + + types = &Types{"string"} + types.IncludesNull() // false func (types *Types) Is(typ string) bool + Is returns true if the schema has exactly one type and it matches the given + type. This is useful for OpenAPI 3.0 style single-type checks. + + Example: + + types := &Types{"string"} + types.Is("string") // true + types.Is("number") // false + + types = &Types{"string", "null"} + types.Is("string") // false (multiple types) + +func (types *Types) IsEmpty() bool + IsEmpty returns true if no types are specified (nil or empty array). + When a schema has no type specified, it permits any type. + + Example: + + var nilTypes *Types + nilTypes.IsEmpty() // true + + types := &Types{} + types.IsEmpty() // true + + types = &Types{"string"} + types.IsEmpty() // false + +func (types *Types) IsMultiple() bool + IsMultiple returns true if multiple types are specified. This is an OpenAPI + 3.1 feature that enables type arrays. + + Example: + + types := &Types{"string"} + types.IsMultiple() // false + + types = &Types{"string", "null"} + types.IsMultiple() // true + +func (types *Types) IsSingle() bool + IsSingle returns true if exactly one type is specified. + + Example: + + types := &Types{"string"} + types.IsSingle() // true + + types = &Types{"string", "null"} + types.IsSingle() // false func (pTypes *Types) MarshalJSON() ([]byte, error) func (pTypes *Types) MarshalYAML() (any, error) func (types *Types) Permits(typ string) bool + Permits returns true if the given type is permitted. Returns true if types + is nil (any type allowed), otherwise checks if the type is included. + + Example: + + var nilTypes *Types + nilTypes.Permits("anything") // true (nil permits everything) + + types := &Types{"string"} + types.Permits("string") // true + types.Permits("number") // false func (types *Types) Slice() []string + Slice returns the types as a string slice. Returns nil if types is nil. + + Example: + + types := &Types{"string", "null"} + slice := types.Slice() // []string{"string", "null"} func (types *Types) UnmarshalJSON(data []byte) error @@ -2332,6 +2574,11 @@ 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 diff --git a/README.md b/README.md index 9c25c70f5..b9c522492 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,9 @@ for _, path := range doc.Paths.InMatchingOrder() { * `openapi3.Location` gained `File` and `Name` fields (`string` type, replacing previous `int`-only struct layout) * `openapi3.Origin` gained `Sequences` field (`map[string][]Location`, extending previous `map[string]Location`-only struct) +### v0.132.0 +* `openapi3.Schema.ExclusiveMin` and `openapi3.Schema.ExclusiveMax` fields changed from `bool` to `ExclusiveBound` (a union type holding `*bool` for OpenAPI 3.0 or `*float64` for OpenAPI 3.1). +* `openapi3.Schema.PrefixItems` field changed from `[]*SchemaRef` to `SchemaRefs`. ### v0.131.0 * No longer `openapi3filter.RegisterBodyDecoder` the `openapi3filter.ZipFileBodyDecoder` by default. diff --git a/cmd/validate/main.go b/cmd/validate/main.go index 751940708..f71cfc4a8 100644 --- a/cmd/validate/main.go +++ b/cmd/validate/main.go @@ -68,6 +68,10 @@ 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/go.mod b/go.mod index 035db2aca..890517fe7 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,9 @@ require ( github.com/gorilla/mux v1.8.0 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/oasdiff/yaml v0.0.9 - github.com/oasdiff/yaml3 v0.0.9 + github.com/oasdiff/yaml3 v0.0.12 github.com/perimeterx/marshmallow v1.1.5 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/stretchr/testify v1.9.0 github.com/woodsbury/decimal128 v1.3.0 ) @@ -20,5 +21,6 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 06caaea54..a7ec011d3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= @@ -20,20 +22,24 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= -github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go index 223767329..b222bed3d 100644 --- a/openapi2conv/openapi2_conv.go +++ b/openapi2conv/openapi2_conv.go @@ -269,8 +269,8 @@ func ToV3Parameter(components *openapi3.Components, parameter *openapi2.Paramete Enum: parameter.Enum, Min: parameter.Minimum, Max: parameter.Maximum, - ExclusiveMin: parameter.ExclusiveMin, - ExclusiveMax: parameter.ExclusiveMax, + ExclusiveMin: openapi3.ExclusiveBound{Bool: boolPtr(parameter.ExclusiveMin)}, + ExclusiveMax: openapi3.ExclusiveBound{Bool: boolPtr(parameter.ExclusiveMax)}, MinLength: parameter.MinLength, MaxLength: parameter.MaxLength, Default: parameter.Default, @@ -495,8 +495,8 @@ func ToV3SchemaRef(schema *openapi2.SchemaRef) *openapi3.SchemaRef { Example: schema.Value.Example, ExternalDocs: schema.Value.ExternalDocs, UniqueItems: schema.Value.UniqueItems, - ExclusiveMin: schema.Value.ExclusiveMin, - ExclusiveMax: schema.Value.ExclusiveMax, + ExclusiveMin: openapi3.ExclusiveBound{Bool: boolPtr(schema.Value.ExclusiveMin)}, + ExclusiveMax: openapi3.ExclusiveBound{Bool: boolPtr(schema.Value.ExclusiveMax)}, ReadOnly: schema.Value.ReadOnly, WriteOnly: schema.Value.WriteOnly, AllowEmptyValue: schema.Value.AllowEmptyValue, @@ -882,10 +882,10 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components Description: schema.Value.Description, Type: paramType, Enum: schema.Value.Enum, - Minimum: schema.Value.Min, - Maximum: schema.Value.Max, - ExclusiveMin: schema.Value.ExclusiveMin, - ExclusiveMax: schema.Value.ExclusiveMax, + Minimum: effectiveMin(schema.Value.Min, schema.Value.ExclusiveMin), + Maximum: effectiveMax(schema.Value.Max, schema.Value.ExclusiveMax), + ExclusiveMin: exclusiveBoundToBool(schema.Value.ExclusiveMin), + ExclusiveMax: exclusiveBoundToBool(schema.Value.ExclusiveMax), MinLength: schema.Value.MinLength, MaxLength: schema.Value.MaxLength, Default: schema.Value.Default, @@ -912,15 +912,15 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components Example: schema.Value.Example, ExternalDocs: schema.Value.ExternalDocs, UniqueItems: schema.Value.UniqueItems, - ExclusiveMin: schema.Value.ExclusiveMin, - ExclusiveMax: schema.Value.ExclusiveMax, + ExclusiveMin: exclusiveBoundToBool(schema.Value.ExclusiveMin), + ExclusiveMax: exclusiveBoundToBool(schema.Value.ExclusiveMax), ReadOnly: schema.Value.ReadOnly, WriteOnly: schema.Value.WriteOnly, AllowEmptyValue: schema.Value.AllowEmptyValue, Deprecated: schema.Value.Deprecated, XML: schema.Value.XML, - Min: schema.Value.Min, - Max: schema.Value.Max, + Min: effectiveMin(schema.Value.Min, schema.Value.ExclusiveMin), + Max: effectiveMax(schema.Value.Max, schema.Value.ExclusiveMax), MultipleOf: schema.Value.MultipleOf, MinLength: schema.Value.MinLength, MaxLength: schema.Value.MaxLength, @@ -1046,16 +1046,16 @@ func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameter In: "formData", Extensions: stripNonExtensions(val.Extensions), Enum: val.Enum, - ExclusiveMin: val.ExclusiveMin, - ExclusiveMax: val.ExclusiveMax, + ExclusiveMin: exclusiveBoundToBool(val.ExclusiveMin), + ExclusiveMax: exclusiveBoundToBool(val.ExclusiveMax), MinLength: val.MinLength, MaxLength: val.MaxLength, Default: val.Default, Items: v2Items, MinItems: val.MinItems, MaxItems: val.MaxItems, - Maximum: val.Max, - Minimum: val.Min, + Maximum: effectiveMax(val.Max, val.ExclusiveMax), + Minimum: effectiveMin(val.Min, val.ExclusiveMin), Pattern: val.Pattern, // CollectionFormat: val.CollectionFormat, // Format: val.Format, @@ -1352,3 +1352,39 @@ func compareParameters(a, b *openapi2.Parameter) int { } return cmp.Compare(a.Ref, b.Ref) } + +// boolPtr returns a pointer to a bool, or nil if the value is false (to avoid storing empty values) +func boolPtr(b bool) *bool { + if !b { + return nil + } + return &b +} + +// exclusiveBoundToBool converts an ExclusiveBound to a bool for OpenAPI 2.0 compatibility +// In OpenAPI 2.0, exclusiveMinimum/exclusiveMaximum are boolean modifiers +func exclusiveBoundToBool(eb openapi3.ExclusiveBound) bool { + if eb.Bool != nil { + return *eb.Bool + } + // If it's a number (OpenAPI 3.1 style), we return true to indicate exclusivity + return eb.Value != nil +} + +// effectiveMin returns the minimum value for OAS 2.0 conversion, considering ExclusiveBound. +// In OAS 3.1, exclusiveMinimum is a number. In OAS 2.0, it must be in the minimum field. +func effectiveMin(min *float64, eb openapi3.ExclusiveBound) *float64 { + if min != nil { + return min + } + // If OAS 3.1 style numeric exclusive bound with no minimum, use the bound value as minimum + return eb.Value +} + +// effectiveMax returns the maximum value for OAS 2.0 conversion, considering ExclusiveBound. +func effectiveMax(max *float64, eb openapi3.ExclusiveBound) *float64 { + if max != nil { + return max + } + return eb.Value +} diff --git a/openapi3/doc.go b/openapi3/doc.go index 41c9965c6..73d5aee1c 100644 --- a/openapi3/doc.go +++ b/openapi3/doc.go @@ -1,4 +1,25 @@ // Package openapi3 parses and writes OpenAPI 3 specification documents. // -// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md +// Supports both OpenAPI 3.0 and OpenAPI 3.1: +// - OpenAPI 3.0.x: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md +// - OpenAPI 3.1.x: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md +// +// OpenAPI 3.1 Features: +// - Type arrays with null support (e.g., ["string", "null"]) +// - JSON Schema 2020-12 keywords (const, examples, prefixItems, etc.) +// - Webhooks for defining callback operations +// - JSON Schema dialect specification +// - SPDX license identifiers +// +// The implementation maintains 100% backward compatibility with OpenAPI 3.0. +// +// For OpenAPI 3.1 validation, use the JSON Schema 2020-12 validator option: +// +// schema.VisitJSON(value, openapi3.EnableJSONSchema2020()) +// +// Version detection is available via helper methods: +// +// if doc.IsOpenAPI3_1() { +// // Handle OpenAPI 3.1 specific features +// } package openapi3 diff --git a/openapi3/example_jsonschema2020_test.go b/openapi3/example_jsonschema2020_test.go new file mode 100644 index 000000000..08771c655 --- /dev/null +++ b/openapi3/example_jsonschema2020_test.go @@ -0,0 +1,256 @@ +package openapi3_test + +import ( + "fmt" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Example demonstrates how to enable and use the JSON Schema 2020-12 validator +// with OpenAPI 3.1 features. +func Example_jsonSchema2020Validator() { + // Enable JSON Schema 2020-12 validator + + // Create a schema using OpenAPI 3.1 features + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Examples: []any{ + "John Doe", + "Jane Smith", + }, + }, + }, + "age": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + // Type array with null - OpenAPI 3.1 feature + Type: &openapi3.Types{"integer", "null"}, + }, + }, + "status": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + // Const keyword - OpenAPI 3.1 feature + Const: "active", + }, + }, + }, + Required: []string{"name", "status"}, + } + + // Valid data + validData := map[string]any{ + "name": "John Doe", + "age": 30, + "status": "active", + } + + if err := schema.VisitJSON(validData, openapi3.EnableJSONSchema2020()); err != nil { + fmt.Println("validation failed:", err) + } else { + fmt.Println("valid data passed") + } + + // Valid with null age + validWithNull := map[string]any{ + "name": "Jane Smith", + "age": nil, // null is allowed in type array + "status": "active", + } + + if err := schema.VisitJSON(validWithNull, openapi3.EnableJSONSchema2020()); err != nil { + fmt.Println("validation failed:", err) + } else { + fmt.Println("valid data with null passed") + } + + // Invalid: wrong const value + invalidData := map[string]any{ + "name": "Bob Wilson", + "age": 25, + "status": "inactive", // should be "active" + } + + if err := schema.VisitJSON(invalidData, openapi3.EnableJSONSchema2020()); err != nil { + fmt.Println("invalid data rejected") + } + + // Output: + // valid data passed + // valid data with null passed + // invalid data rejected +} + +// Example demonstrates type arrays with null support +func Example_typeArrayWithNull() { + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, + } + + // Both string and null are valid + if err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("string accepted") + } + + if err := schema.VisitJSON(nil, openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("null accepted") + } + + if err := schema.VisitJSON(123, openapi3.EnableJSONSchema2020()); err != nil { + fmt.Println("number rejected") + } + + // Output: + // string accepted + // null accepted + // number rejected +} + +// Example demonstrates the const keyword +func Example_constKeyword() { + + schema := &openapi3.Schema{ + Const: "production", + } + + if err := schema.VisitJSON("production", openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("const value accepted") + } + + if err := schema.VisitJSON("development", openapi3.EnableJSONSchema2020()); err != nil { + fmt.Println("other value rejected") + } + + // Output: + // const value accepted + // other value rejected +} + +// Example demonstrates the examples field +func Example_examplesField() { + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + // Examples array - OpenAPI 3.1 feature + Examples: []any{ + "red", + "green", + "blue", + }, + } + + // Examples don't affect validation, any string is valid + if err := schema.VisitJSON("yellow", openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("any string accepted") + } + + // Output: + // any string accepted +} + +// Example demonstrates backward compatibility with nullable +func Example_nullableBackwardCompatibility() { + + // OpenAPI 3.0 style nullable + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Nullable: true, + } + + // Automatically converted to type array ["string", "null"] + if err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("string accepted") + } + + if err := schema.VisitJSON(nil, openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("null accepted") + } + + // Output: + // string accepted + // null accepted +} + +// Example demonstrates complex nested schemas +func Example_complexNestedSchema() { + + min := 0.0 + max := 100.0 + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "user": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "email": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "email", + }, + }, + }, + Required: []string{"name", "email"}, + }, + }, + "score": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"number"}, + Min: &min, + Max: &max, + }, + }, + }, + Required: []string{"user", "score"}, + } + + validData := map[string]any{ + "user": map[string]any{ + "name": "John Doe", + "email": "john@example.com", + }, + "score": 85.5, + } + + if err := schema.VisitJSON(validData, openapi3.EnableJSONSchema2020()); err == nil { + fmt.Println("complex nested object validated") + } + + // Output: + // complex nested object validated +} + +// Example demonstrates using both validators for comparison +func Example_comparingValidators() { + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + MinLength: 5, + } + + testValue := "test" + + // Test with built-in validator (no option) + err1 := schema.VisitJSON(testValue) + if err1 != nil { + fmt.Println("built-in validator: rejected") + } + + // Test with JSON Schema 2020-12 validator + err2 := schema.VisitJSON(testValue, openapi3.EnableJSONSchema2020()) + if err2 != nil { + fmt.Println("visit JSON Schema 2020-12 validator: rejected") + } + + // Output: + // built-in validator: rejected + // visit JSON Schema 2020-12 validator: rejected +} diff --git a/openapi3/example_validation.go b/openapi3/example_validation.go index 0d105c92d..5c0568656 100644 --- a/openapi3/example_validation.go +++ b/openapi3/example_validation.go @@ -5,11 +5,17 @@ import "context" func validateExampleValue(ctx context.Context, input any, schema *Schema) error { opts := make([]SchemaValidationOption, 0, 2) - if vo := getValidationOptions(ctx); vo.examplesValidationAsReq { + vo := getValidationOptions(ctx) + if vo.examplesValidationAsReq { opts = append(opts, VisitAsRequest()) } else if vo.examplesValidationAsRes { opts = append(opts, VisitAsResponse()) } + + if vo.jsonSchema2020ValidationEnabled { + opts = append(opts, EnableJSONSchema2020()) + } + opts = append(opts, MultiErrors()) return schema.VisitJSON(input, opts...) diff --git a/openapi3/info.go b/openapi3/info.go index 288235d11..39416904e 100644 --- a/openapi3/info.go +++ b/openapi3/info.go @@ -8,11 +8,13 @@ import ( // Info is specified by OpenAPI/Swagger standard version 3. // See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#info-object +// and https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#info-object type Info struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` - Title string `json:"title" yaml:"title"` // Required + Title string `json:"title" yaml:"title"` // Required + 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"` @@ -34,11 +36,15 @@ func (info *Info) MarshalYAML() (any, error) { if info == nil { return nil, nil } - m := make(map[string]any, 6+len(info.Extensions)) + m := make(map[string]any, 7+len(info.Extensions)) for k, v := range info.Extensions { m[k] = v } m["title"] = info.Title + // OpenAPI 3.1 field + if x := info.Summary; x != "" { + m["summary"] = x + } if x := info.Description; x != "" { m["description"] = x } @@ -64,6 +70,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, "description") delete(x.Extensions, "termsOfService") delete(x.Extensions, "contact") diff --git a/openapi3/issue230_test.go b/openapi3/issue230_test.go new file mode 100644 index 000000000..bcede4275 --- /dev/null +++ b/openapi3/issue230_test.go @@ -0,0 +1,633 @@ +package openapi3_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" +) + +// TestBackwardCompatibility_OpenAPI30 ensures that existing OpenAPI 3.0 functionality is not broken +func TestBackwardCompatibility_OpenAPI30(t *testing.T) { + t.Run("load and validate OpenAPI 3.0 document", func(t *testing.T) { + spec := ` +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 + license: + name: MIT + url: https://opensource.org/licenses/MIT +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + nullable: true + required: + - id +` + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + 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()) + + // Verify structure + require.Equal(t, "Test API", doc.Info.Title) + require.NotNil(t, doc.Info.License) + require.Equal(t, "MIT", doc.Info.License.Name) + require.Equal(t, "https://opensource.org/licenses/MIT", doc.Info.License.URL) + require.Empty(t, doc.Info.License.Identifier) // 3.0 doesn't have this + + // Verify webhooks is nil for 3.0 + require.Nil(t, doc.Webhooks) + require.Empty(t, doc.JSONSchemaDialect) + + // Validate + err = doc.Validate(context.Background()) + require.NoError(t, err) + }) + + t.Run("nullable schema validation still works", func(t *testing.T) { + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Nullable: true, + } + + // Should accept string + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Should accept null + err = schema.VisitJSON(nil, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Should reject number + err = schema.VisitJSON(123, openapi3.EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("existing schema fields work", func(t *testing.T) { + min := 0.0 + max := 100.0 + schema := &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Min: &min, + Max: &max, + MinLength: 1, + } + + // Type checking + require.True(t, schema.Type.Is("integer")) + require.False(t, schema.Type.IsMultiple()) + + // Validation still works + err := schema.VisitJSON(50, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(150, openapi3.EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("serialization preserves 3.0 format", func(t *testing.T) { + doc := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: "Test", + Version: "1.0.0", + }, + Paths: openapi3.NewPaths(), + } + + data, err := json.Marshal(doc) + require.NoError(t, err) + + // Should not contain 3.1 fields + require.NotContains(t, string(data), "webhooks") + require.NotContains(t, string(data), "jsonSchemaDialect") + require.Contains(t, string(data), `"openapi":"3.0.3"`) + }) +} + +// TestOpenAPI31_NewFeatures tests all new OpenAPI 3.1 features +func TestOpenAPI31_NewFeatures(t *testing.T) { + t.Run("load and validate OpenAPI 3.1 document with webhooks", func(t *testing.T) { + spec := ` +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 + license: + name: MIT + identifier: MIT +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +paths: + /users: + get: + responses: + '200': + description: Success +webhooks: + newUser: + post: + summary: User created notification + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: integer + responses: + '200': + description: Processed +` + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + 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()) + + // Verify 3.1 fields + require.NotNil(t, doc.Webhooks) + require.Contains(t, doc.Webhooks, "newUser") + require.Equal(t, "https://json-schema.org/draft/2020-12/schema", doc.JSONSchemaDialect) + + // Verify license identifier + require.Equal(t, "MIT", doc.Info.License.Identifier) + + // Validate + err = doc.Validate(context.Background()) + require.NoError(t, err) + }) + + t.Run("type arrays with null", func(t *testing.T) { + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, + } + + // Type checks + require.True(t, schema.Type.IsMultiple()) + require.True(t, schema.Type.IncludesNull()) + require.True(t, schema.Type.Includes("string")) + + // Should accept string + err := schema.VisitJSON("hello", openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Should accept null (with new validator) + + err = schema.VisitJSON(nil, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Should reject number + err = schema.VisitJSON(123, openapi3.EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("const keyword validation", func(t *testing.T) { + + schema := &openapi3.Schema{ + Const: "production", + } + + err := schema.VisitJSON("production", openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON("development", openapi3.EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("examples array", func(t *testing.T) { + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Examples: []any{ + "example1", + "example2", + "example3", + }, + } + + require.Len(t, schema.Examples, 3) + + // Serialize and verify + data, err := json.Marshal(schema) + require.NoError(t, err) + require.Contains(t, string(data), "examples") + require.Contains(t, string(data), "example1") + }) + + t.Run("all new schema keywords serialize", func(t *testing.T) { + minContains := uint64(1) + maxContains := uint64(3) + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + PrefixItems: openapi3.SchemaRefs{ + {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + {Value: &openapi3.Schema{Type: &openapi3.Types{"number"}}}, + }, + Contains: &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + MinContains: &minContains, + MaxContains: &maxContains, + PropertyNames: &openapi3.SchemaRef{ + Value: &openapi3.Schema{Pattern: "^[a-z]+$"}, + }, + } + + data, err := json.Marshal(schema) + require.NoError(t, err) + + str := string(data) + require.Contains(t, str, "prefixItems") + require.Contains(t, str, "contains") + require.Contains(t, str, "minContains") + require.Contains(t, str, "maxContains") + require.Contains(t, str, "propertyNames") + }) + + t.Run("round-trip serialization preserves all fields", func(t *testing.T) { + doc := &openapi3.T{ + OpenAPI: "3.1.0", + JSONSchemaDialect: "https://json-schema.org/draft/2020-12/schema", + Info: &openapi3.Info{ + Title: "Test API", + Version: "1.0.0", + License: &openapi3.License{ + Name: "Apache-2.0", + Identifier: "Apache-2.0", + }, + }, + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{ + "test": { + Post: &openapi3.Operation{ + Summary: "Test webhook", + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: openapi3.Ptr("OK"), + }, + }), + ), + }, + }, + }, + } + + // Serialize + data, err := json.Marshal(doc) + require.NoError(t, err) + + // Deserialize + var doc2 openapi3.T + err = json.Unmarshal(data, &doc2) + require.NoError(t, err) + + // Verify all fields + require.Equal(t, "3.1.0", doc2.OpenAPI) + require.Equal(t, "https://json-schema.org/draft/2020-12/schema", doc2.JSONSchemaDialect) + require.Equal(t, "Apache-2.0", doc2.Info.License.Identifier) + require.NotNil(t, doc2.Webhooks) + require.Contains(t, doc2.Webhooks, "test") + }) +} + +// TestJSONSchema2020Validator_RealWorld tests the validator with realistic schemas +func TestJSONSchema2020Validator_RealWorld(t *testing.T) { + + t.Run("complex nested object with nullable", func(t *testing.T) { + min := 0.0 + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "user": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}}, + }, + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, + }, + }, + "age": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Min: &min, + }, + }, + }, + Required: []string{"id"}, + }, + }, + "tags": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array", "null"}, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + }, + }, + Required: []string{"user"}, + } + + // Valid data + validData := map[string]any{ + "user": map[string]any{ + "id": 1, + "name": "John", + "age": 30, + }, + "tags": []any{"tag1", "tag2"}, + } + err := schema.VisitJSON(validData, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Valid with null name + validDataNullName := map[string]any{ + "user": map[string]any{ + "id": 2, + "name": nil, + "age": 25, + }, + "tags": nil, + } + err = schema.VisitJSON(validDataNullName, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Invalid - missing required field + invalidData := map[string]any{ + "user": map[string]any{ + "name": "Jane", + }, + } + err = schema.VisitJSON(invalidData, openapi3.EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("oneOf with different types", func(t *testing.T) { + schema := &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Const: "email", + }, + }, + "email": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + Required: []string{"type", "email"}, + }, + }, + &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Const: "phone", + }, + }, + "phone": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + Required: []string{"type", "phone"}, + }, + }, + }, + } + + // Valid email + emailData := map[string]any{ + "type": "email", + "email": "test@example.com", + } + err := schema.VisitJSON(emailData, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Valid phone + phoneData := map[string]any{ + "type": "phone", + "phone": "+1234567890", + } + err = schema.VisitJSON(phoneData, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + + // Invalid - doesn't match any oneOf + invalidData := map[string]any{ + "type": "email", + // missing email field + } + err = schema.VisitJSON(invalidData, openapi3.EnableJSONSchema2020()) + require.Error(t, err) + }) +} + +// TestMigrationScenarios tests realistic migration paths +func TestMigrationScenarios(t *testing.T) { + t.Run("migrate nullable to type array", func(t *testing.T) { + // Old 3.0 style + schema30 := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Nullable: true, + } + + // New 3.1 style + schema31 := &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, + } + + // Both should accept null with new validator + + err := schema30.VisitJSON(nil) + require.NoError(t, err) + + err = schema31.VisitJSON(nil) + require.NoError(t, err) + + // Both should accept string + err = schema30.VisitJSON("test") + require.NoError(t, err) + + err = schema31.VisitJSON("test") + require.NoError(t, err) + }) + + t.Run("automatic version detection and configuration", func(t *testing.T) { + // Simulate loading 3.0 document + spec30 := []byte(`{"openapi":"3.0.3","info":{"title":"Test","version":"1.0.0"},"paths":{}}`) + var doc30 openapi3.T + err := json.Unmarshal(spec30, &doc30) + require.NoError(t, err) + + if doc30.IsOpenAPI3_1() { + } + + // Simulate loading 3.1 document + spec31 := []byte(`{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{}}`) + var doc31 openapi3.T + err = json.Unmarshal(spec31, &doc31) + require.NoError(t, err) + + if doc31.IsOpenAPI3_1() { + } + + // Cleanup + }) +} + +// TestEdgeCases tests edge cases and error conditions +func TestEdgeCases(t *testing.T) { + t.Run("empty types array", func(t *testing.T) { + schema := &openapi3.Schema{ + Type: &openapi3.Types{}, + } + + require.True(t, schema.Type.IsEmpty()) + require.False(t, schema.Type.IsSingle()) + require.False(t, schema.Type.IsMultiple()) + }) + + t.Run("nil vs empty webhooks", func(t *testing.T) { + doc30 := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0.0"}, + Paths: openapi3.NewPaths(), + } + + doc31Empty := &openapi3.T{ + OpenAPI: "3.1.0", + Info: &openapi3.Info{Title: "Test", Version: "1.0.0"}, + Paths: openapi3.NewPaths(), + Webhooks: map[string]*openapi3.PathItem{}, + } + + // Nil webhooks should not serialize + data30, _ := json.Marshal(doc30) + require.NotContains(t, string(data30), "webhooks") + + // Empty webhooks should not serialize + data31, _ := json.Marshal(doc31Empty) + require.NotContains(t, string(data31), "webhooks") + }) + + t.Run("license with both url and identifier", func(t *testing.T) { + license := &openapi3.License{ + Name: "MIT", + URL: "https://opensource.org/licenses/MIT", + Identifier: "MIT", + } + + // Should serialize both (spec says only one should be used, but library allows both) + data, err := json.Marshal(license) + require.NoError(t, err) + require.Contains(t, string(data), `"url"`) + 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{} + + require.True(t, schema.Type.Permits("string")) + require.True(t, schema.Type.Permits("number")) + require.True(t, schema.Type.Permits("anything")) + }) +} + +// TestPerformance checks for obvious performance issues +func TestPerformance(t *testing.T) { + t.Run("large schema compilation", func(t *testing.T) { + + // Create a large schema + properties := make(openapi3.Schemas) + for i := 0; i < 100; i++ { + properties[string(rune('a'+i%26))+string(rune('0'+i/26))] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + } + } + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: properties, + } + + // Should compile and validate without hanging + data := map[string]any{"a0": "test"} + err := schema.VisitJSON(data, openapi3.EnableJSONSchema2020()) + require.NoError(t, err) + }) + + t.Run("deeply nested schema", func(t *testing.T) { + // Create deeply nested schema (but not too deep to cause stack overflow) + schema := &openapi3.Schema{Type: &openapi3.Types{"object"}} + current := schema + + for i := 0; i < 10; i++ { + current.Properties = openapi3.Schemas{ + "nested": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + }, + }, + } + current = current.Properties["nested"].Value + } + + // Should serialize without issue + _, err := json.Marshal(schema) + require.NoError(t, err) + }) +} diff --git a/openapi3/license.go b/openapi3/license.go index 56be06c1d..f7cf22053 100644 --- a/openapi3/license.go +++ b/openapi3/license.go @@ -8,12 +8,17 @@ import ( // 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 +// and https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#license-object type License struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` Name string `json:"name" yaml:"name"` // Required URL string `json:"url,omitempty" yaml:"url,omitempty"` + + // 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"` } // MarshalJSON returns the JSON encoding of License. @@ -35,6 +40,10 @@ func (license License) MarshalYAML() (any, error) { if x := license.URL; x != "" { m["url"] = x } + // OpenAPI 3.1 field + if x := license.Identifier; x != "" { + m["identifier"] = x + } return m, nil } @@ -48,6 +57,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 if len(x.Extensions) == 0 { x.Extensions = nil } @@ -63,5 +73,9 @@ func (license *License) Validate(ctx context.Context, opts ...ValidationOption) return errors.New("value of license name must be a non-empty string") } + if license.URL != "" && license.Identifier != "" { + return errors.New("license must not specify both 'url' and 'identifier'") + } + return validateExtensions(ctx, license.Extensions) } diff --git a/openapi3/loader.go b/openapi3/loader.go index f0a13e595..f327e0ecb 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -270,6 +270,17 @@ 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 + } + } + return } @@ -941,6 +952,18 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat component.setRefPath(resolved.RefPath()) } defer loader.unvisitRef(ref, component.Value) + + // OAS 3.1 / JSON Schema 2020-12: apply sibling keywords from the original schema + // object on top of the resolved $ref value. In 3.1, siblings are not ignored — + // 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 { + // Work on a copy so we don't mutate a schema shared by other references. + schemaCopy := *component.Value + applySiblingSchemaFields(&schemaCopy, component.sibling, component.extra) + component.Value = &schemaCopy + } } value := component.Value if value == nil { @@ -1000,6 +1023,72 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat } } } + + // OpenAPI 3.1 / JSON Schema 2020-12 fields + for _, v := range value.PrefixItems { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.Contains; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, name := range componentNames(value.PatternProperties) { + v := value.PatternProperties[name] + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, name := range componentNames(value.DependentSchemas) { + v := value.DependentSchemas[name] + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, name := range componentNames(value.Defs) { + v := value.Defs[name] + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.PropertyNames; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.UnevaluatedItems.Schema; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.UnevaluatedProperties.Schema; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.If; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.Then; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.Else; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.ContentSchema; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + return nil } @@ -1261,3 +1350,31 @@ func (loader *Loader) resolvePathItemRef(doc *T, pathItem *PathItem, documentPat func unescapeRefString(ref string) string { return strings.ReplaceAll(strings.ReplaceAll(ref, "~1", "/"), "~0", "~") } + +// applySiblingSchemaFields overlays the fields listed in presentFields from sibling onto dst. +// It is used to honour keyword siblings of $ref in OpenAPI 3.1 / JSON Schema 2020-12, where +// sibling keywords are applied in addition to (not instead of) the referenced schema. +// Only fields that were explicitly present in the original YAML/JSON are applied; the presentFields +// slice (derived from SchemaRef.extra) carries this information. +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 "title": + dst.Title = sibling.Title + case "readOnly": + dst.ReadOnly = sibling.ReadOnly + case "writeOnly": + dst.WriteOnly = sibling.WriteOnly + case "example": + dst.Example = sibling.Example + case "externalDocs": + dst.ExternalDocs = sibling.ExternalDocs + case "default": + dst.Default = sibling.Default + } + } +} diff --git a/openapi3/loader_31_conditional_test.go b/openapi3/loader_31_conditional_test.go new file mode 100644 index 000000000..c6673bbfd --- /dev/null +++ b/openapi3/loader_31_conditional_test.go @@ -0,0 +1,36 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveConditionalSchemaRefs(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromFile("testdata/schema31_conditional.yml") + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + // Verify if/then/else refs are resolved + conditional := doc.Components.Schemas["ConditionalField"].Value + require.NotNil(t, conditional.If) + require.NotNil(t, conditional.If.Value) + require.True(t, conditional.If.Value.Type.Is("string")) + + require.NotNil(t, conditional.Then) + require.NotNil(t, conditional.Then.Value) + require.Equal(t, uint64(3), conditional.Then.Value.MinLength) + + require.NotNil(t, conditional.Else) + require.NotNil(t, conditional.Else.Value) + require.True(t, conditional.Else.Value.Type.Is("number")) + + // Verify dependentRequired is loaded + payment := doc.Components.Schemas["PaymentInfo"].Value + require.Equal(t, map[string][]string{ + "creditCard": {"billingAddress"}, + }, payment.DependentRequired) +} diff --git a/openapi3/loader_31_schema_refs_test.go b/openapi3/loader_31_schema_refs_test.go new file mode 100644 index 000000000..04b24322b --- /dev/null +++ b/openapi3/loader_31_schema_refs_test.go @@ -0,0 +1,119 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestOAS31_RefSiblingKeyword verifies that sibling keywords alongside $ref are honoured +// when loading an OpenAPI 3.1 document. +// +// In OpenAPI 3.0 / JSON Schema draft-07, $ref replaces its entire object so any sibling +// keywords (e.g. deprecated, description) are silently ignored. +// In OpenAPI 3.1 / JSON Schema 2020-12, $ref and sibling keywords are both applied, so +// a property like: +// +// status: +// deprecated: true +// $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 TestResolveSchemaRefsIn31Fields(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromFile("testdata/schema31refs.yml") + require.NoError(t, err) + + schemas := doc.Components.Schemas + + // prefixItems refs should be resolved + tupleArray := schemas["TupleArray"].Value + require.NotNil(t, tupleArray) + require.Len(t, tupleArray.PrefixItems, 2) + require.Equal(t, "#/components/schemas/StringType", tupleArray.PrefixItems[0].Ref) + require.NotNil(t, tupleArray.PrefixItems[0].Value, "prefixItems[0] $ref should be resolved") + require.Equal(t, "string", tupleArray.PrefixItems[0].Value.Type.Slice()[0]) + require.Equal(t, "#/components/schemas/IntegerType", tupleArray.PrefixItems[1].Ref) + require.NotNil(t, tupleArray.PrefixItems[1].Value, "prefixItems[1] $ref should be resolved") + require.Equal(t, "integer", tupleArray.PrefixItems[1].Value.Type.Slice()[0]) + + // contains ref should be resolved + arrayContains := schemas["ArrayWithContains"].Value + require.NotNil(t, arrayContains) + require.Equal(t, "#/components/schemas/StringType", arrayContains.Contains.Ref) + require.NotNil(t, arrayContains.Contains.Value, "contains $ref should be resolved") + require.Equal(t, "string", arrayContains.Contains.Value.Type.Slice()[0]) + + // patternProperties refs should be resolved + patternProps := schemas["ObjectWithPatternProperties"].Value + require.NotNil(t, patternProps) + pp := patternProps.PatternProperties["^x-"] + require.NotNil(t, pp) + require.Equal(t, "#/components/schemas/StringType", pp.Ref) + require.NotNil(t, pp.Value, "patternProperties $ref should be resolved") + + // dependentSchemas refs should be resolved + depSchemas := schemas["ObjectWithDependentSchemas"].Value + require.NotNil(t, depSchemas) + ds := depSchemas.DependentSchemas["name"] + require.NotNil(t, ds) + require.Equal(t, "#/components/schemas/NonNegative", ds.Ref) + require.NotNil(t, ds.Value, "dependentSchemas $ref should be resolved") + + // propertyNames ref should be resolved + propNames := schemas["ObjectWithPropertyNames"].Value + require.NotNil(t, propNames) + require.Equal(t, "#/components/schemas/NamePattern", propNames.PropertyNames.Ref) + require.NotNil(t, propNames.PropertyNames.Value, "propertyNames $ref should be resolved") + + // unevaluatedItems ref should be resolved + unItems := schemas["ArrayWithUnevaluatedItems"].Value + require.NotNil(t, unItems) + require.NotNil(t, unItems.UnevaluatedItems.Schema) + require.Equal(t, "#/components/schemas/StringType", unItems.UnevaluatedItems.Schema.Ref) + require.NotNil(t, unItems.UnevaluatedItems.Schema.Value, "unevaluatedItems $ref should be resolved") + + // unevaluatedProperties ref should be resolved + unProps := schemas["ObjectWithUnevaluatedProperties"].Value + require.NotNil(t, unProps) + require.NotNil(t, unProps.UnevaluatedProperties.Schema) + require.Equal(t, "#/components/schemas/StringType", unProps.UnevaluatedProperties.Schema.Ref) + require.NotNil(t, unProps.UnevaluatedProperties.Schema.Value, "unevaluatedProperties $ref should be resolved") + + // if/then/else refs should be resolved + ifThenElse := schemas["ObjectWithIfThenElse"].Value + require.NotNil(t, ifThenElse) + require.Equal(t, "#/components/schemas/StringType", ifThenElse.If.Ref) + require.NotNil(t, ifThenElse.If.Value, "if $ref should be resolved") + require.Equal(t, "string", ifThenElse.If.Value.Type.Slice()[0]) + require.Equal(t, "#/components/schemas/IntegerType", ifThenElse.Then.Ref) + require.NotNil(t, ifThenElse.Then.Value, "then $ref should be resolved") + require.Equal(t, "integer", ifThenElse.Then.Value.Type.Slice()[0]) + require.Equal(t, "#/components/schemas/NonNegative", ifThenElse.Else.Ref) + require.NotNil(t, ifThenElse.Else.Value, "else $ref should be resolved") + + // contentSchema ref should be resolved + contentSchema := schemas["StringWithContentSchema"].Value + require.NotNil(t, contentSchema) + require.Equal(t, "#/components/schemas/NonNegative", contentSchema.ContentSchema.Ref) + require.NotNil(t, contentSchema.ContentSchema.Value, "contentSchema $ref should be resolved") +} diff --git a/openapi3/media_type.go b/openapi3/media_type.go index 6373a7b65..b1fbd3ccb 100644 --- a/openapi3/media_type.go +++ b/openapi3/media_type.go @@ -109,6 +109,7 @@ func (mediaType *MediaType) UnmarshalJSON(data []byte) error { if len(x.Extensions) == 0 { x.Extensions = nil } + delete(x.Encoding, originKey) *mediaType = MediaType(x) return nil } diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go index ed8a016c5..6e8c7e044 100644 --- a/openapi3/openapi3.go +++ b/openapi3/openapi3.go @@ -12,18 +12,27 @@ import ( // T is the root of an OpenAPI v3 document // See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object +// and https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object 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" yaml:"paths"` // Required + 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"` + visited visitedComponent url *url.URL @@ -36,6 +45,28 @@ type T struct { var _ jsonpointer.JSONPointable = (*T)(nil) +// IsOpenAPI3_0 returns true if the document is OpenAPI 3.0.x +func (doc *T) IsOpenAPI3_0() bool { + return doc.Version() == "3.0" +} + +// IsOpenAPI3_1 returns true if the document is OpenAPI 3.1.x +func (doc *T) IsOpenAPI3_1() bool { + return doc.Version() == "3.1" +} + +// Version returns the major.minor version of the OpenAPI document +func (doc *T) Version() string { + if doc == nil || doc.OpenAPI == "" { + return "" + } + // Extract major.minor (e.g., "3.0" from "3.0.3") + if len(doc.OpenAPI) >= 3 { + return doc.OpenAPI[0:3] + } + return doc.OpenAPI +} + // JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable func (doc *T) JSONLookup(token string) (any, error) { switch token { @@ -55,6 +86,10 @@ func (doc *T) JSONLookup(token string) (any, error) { return doc.Tags, nil case "externalDocs": return doc.ExternalDocs, nil + case "webhooks": + return doc.Webhooks, nil + case "jsonSchemaDialect": + return doc.JSONSchemaDialect, nil } v, _, err := jsonpointer.GetForToken(doc.Extensions, token) @@ -84,7 +119,9 @@ func (doc *T) MarshalYAML() (any, error) { m["components"] = x } m["info"] = doc.Info - m["paths"] = doc.Paths + if doc.Paths != nil { + m["paths"] = doc.Paths + } if x := doc.Security; len(x) != 0 { m["security"] = x } @@ -97,6 +134,13 @@ 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 + } + if x := doc.JSONSchemaDialect; x != "" { + m["jsonSchemaDialect"] = x + } return m, nil } @@ -116,9 +160,13 @@ 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 { x.Extensions = nil } + delete(x.Webhooks, originKey) *doc = T(x) return nil } @@ -204,6 +252,10 @@ 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. 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...) + } ctx = WithValidationOptions(ctx, opts...) if doc.OpenAPI == "" { @@ -233,7 +285,7 @@ func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { if err := v.Validate(ctx); err != nil { return wrap(err) } - } else { + } else if !doc.IsOpenAPI3_1() { return wrap(errors.New("must be an object")) } @@ -265,6 +317,31 @@ func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { } } + // OpenAPI 3.1 jsonSchemaDialect validation + if doc.JSONSchemaDialect != "" { + u, err := url.Parse(doc.JSONSchemaDialect) + if err != nil { + return fmt.Errorf("invalid jsonSchemaDialect: %w", 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 validateExtensions(ctx, doc.Extensions) } diff --git a/openapi3/openapi3_version_test.go b/openapi3/openapi3_version_test.go new file mode 100644 index 000000000..d6cfabef4 --- /dev/null +++ b/openapi3/openapi3_version_test.go @@ -0,0 +1,309 @@ +package openapi3 + +import ( + "context" + "encoding/json" + "testing" + + "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{ + OpenAPI: "3.1.0", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + Webhooks: map[string]*PathItem{ + "newPet": { + Post: &Operation{ + Summary: "New pet webhook", + Responses: NewResponses( + WithStatus(200, &ResponseRef{ + Value: &Response{ + Description: Ptr("Success"), + }, + }), + ), + }, + }, + }, + } + + data, err := json.Marshal(doc) + require.NoError(t, err) + + // Should contain webhooks + require.Contains(t, string(data), `"webhooks"`) + require.Contains(t, string(data), `"newPet"`) + }) + + t.Run("deserialize webhooks from OpenAPI 3.1", func(t *testing.T) { + jsonData := []byte(`{ + "openapi": "3.1.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": {}, + "webhooks": { + "newPet": { + "post": { + "summary": "New pet webhook", + "responses": { + "200": { + "description": "Success" + } + } + } + } + } + }`) + + var doc T + err := json.Unmarshal(jsonData, &doc) + require.NoError(t, err) + + require.True(t, doc.IsOpenAPI3_1()) + require.NotNil(t, doc.Webhooks) + require.Contains(t, doc.Webhooks, "newPet") + require.NotNil(t, doc.Webhooks["newPet"].Post) + require.Equal(t, "New pet webhook", doc.Webhooks["newPet"].Post.Summary) + }) + + t.Run("OpenAPI 3.0 without webhooks", func(t *testing.T) { + jsonData := []byte(`{ + "openapi": "3.0.3", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": {} + }`) + + var doc T + err := json.Unmarshal(jsonData, &doc) + require.NoError(t, err) + + require.True(t, doc.IsOpenAPI3_0()) + require.Nil(t, doc.Webhooks) + }) + + t.Run("validate webhooks", func(t *testing.T) { + doc := &T{ + OpenAPI: "3.1.0", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + Webhooks: map[string]*PathItem{ + "validWebhook": { + Post: &Operation{ + Responses: NewResponses( + WithStatus(200, &ResponseRef{ + Value: &Response{ + Description: Ptr("Success"), + }, + }), + ), + }, + }, + }, + } + + // Should validate successfully + err := doc.Validate(ctx) + require.NoError(t, err) + }) + + t.Run("validate fails with nil webhook", func(t *testing.T) { + doc := &T{ + OpenAPI: "3.1.0", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + Webhooks: map[string]*PathItem{ + "invalidWebhook": nil, + }, + } + + err := doc.Validate(ctx) + require.Error(t, err) + require.ErrorContains(t, err, "webhook") + require.ErrorContains(t, err, "invalidWebhook") + }) +} + +func TestJSONLookupWithWebhooks(t *testing.T) { + doc := &T{ + OpenAPI: "3.1.0", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + Webhooks: map[string]*PathItem{ + "test": { + Post: &Operation{ + Summary: "Test webhook", + }, + }, + }, + } + + result, err := doc.JSONLookup("webhooks") + require.NoError(t, err) + require.NotNil(t, result) + + webhooks, ok := result.(map[string]*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{ + OpenAPI: "3.0.3", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + } + + if doc.IsOpenAPI3_0() { + // OpenAPI 3.0 specific logic + require.Nil(t, doc.Webhooks) + } + }) + + t.Run("detect and handle OpenAPI 3.1", func(t *testing.T) { + doc := &T{ + OpenAPI: "3.1.0", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + Webhooks: map[string]*PathItem{ + "test": { + Post: &Operation{ + Summary: "Test", + Responses: NewResponses( + WithStatus(200, &ResponseRef{ + Value: &Response{ + Description: Ptr("OK"), + }, + }), + ), + }, + }, + }, + } + + if doc.IsOpenAPI3_1() { + // OpenAPI 3.1 specific logic + require.NotNil(t, doc.Webhooks) + require.Contains(t, doc.Webhooks, "test") + } + }) +} + +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{ + OpenAPI: "3.0.3", + Info: &Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: NewPaths(), + } + + require.True(t, doc.IsOpenAPI3_0()) + require.Nil(t, doc.Webhooks) + + // Upgrade to 3.1 + doc.OpenAPI = "3.1.0" + + // Add 3.1 features + doc.Webhooks = map[string]*PathItem{ + "newEvent": { + Post: &Operation{ + Summary: "New event notification", + Responses: NewResponses( + WithStatus(200, &ResponseRef{ + Value: &Response{ + Description: Ptr("Processed"), + }, + }), + ), + }, + }, + } + + require.True(t, doc.IsOpenAPI3_1()) + require.NotNil(t, doc.Webhooks) + + // Validate the upgraded document + err := doc.Validate(ctx) + require.NoError(t, err) + }) +} diff --git a/openapi3/origin.go b/openapi3/origin.go index 1a6d12eb1..432ecabdf 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -179,7 +179,7 @@ func applyOriginsToStruct(val reflect.Value, ptr reflect.Value, tree *yaml.Origi // Handle wrapper types whose inner struct has no json tag: // - *Ref types (e.g. SchemaRef, ResponseRef) have a "Value" field - // - AdditionalProperties has a "Schema" field + // - BoolSchema (AdditionalProperties, UnevaluatedProperties, UnevaluatedItems) has a "Schema" field // The origin tree data applies to the inner struct, not a sub-key. for _, fieldName := range []string{"Value", "Schema"} { vf := val.FieldByName(fieldName) diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index d30efce67..9e89c1a4b 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -475,6 +475,40 @@ func TestOrigin_ExampleWithArrayValue(t *testing.T) { // TestOrigin_OriginExistsInProperties verifies that loading fails when a specification // contains a property named "__origin__", highlighting a limitation in the current implementation. +func TestOrigin_ConstAndExamplesStripped(t *testing.T) { + var data = ` +openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Foo: + type: object + const: {x: reuven} + examples: + - {y: value} +` + loader := NewLoader() + loader.IncludeOrigin = true + + doc, err := loader.LoadFromData([]byte(data)) + require.NoError(t, err) + + schema := doc.Components.Schemas["Foo"].Value + require.NotNil(t, schema) + + constMap, ok := schema.Const.(map[string]any) + require.True(t, ok) + require.NotContains(t, constMap, originKey) + + require.Len(t, schema.Examples, 1) + exampleMap, ok := schema.Examples[0].(map[string]any) + require.True(t, ok) + require.NotContains(t, exampleMap, originKey) +} + func TestOrigin_OriginExistsInProperties(t *testing.T) { var data = ` paths: @@ -800,3 +834,50 @@ func TestOrigin_Disabled(t *testing.T) { require.Nil(t, doc.Info.Origin) require.Nil(t, doc.Paths.Origin) } + +// TestOrigin_MappingFields verifies that mapping-valued schema fields +// (dependentRequired, dependentSchemas, patternProperties) have their +// key locations tracked in Origin.Fields. Before yaml3 v0.0.12, +// buildOriginSeq only tracked scalar and sequence values, so these +// mapping-valued fields were missing from the origin and source +// location lookups returned nil. +func TestOrigin_MappingFields(t *testing.T) { + loader := NewLoader() + loader.IncludeOrigin = true + + doc, err := loader.LoadFromFile("testdata/origin/mapping_fields.yaml") + require.NoError(t, err) + + schema := doc.Paths.Find("/test").Get.Responses.Value("200").Value. + Content["application/json"].Schema.Value.Properties["metadata"].Value + require.NotNil(t, schema.Origin) + + file := "testdata/origin/mapping_fields.yaml" + + // dependentRequired is a map[string][]string — mapping-valued + require.Contains(t, schema.Origin.Fields, "dependentRequired") + require.Equal(t, Location{ + File: file, + Line: 18, + Column: 21, + Name: "dependentRequired", + }, schema.Origin.Fields["dependentRequired"]) + + // dependentSchemas is a Schemas map — mapping-valued + require.Contains(t, schema.Origin.Fields, "dependentSchemas") + require.Equal(t, Location{ + File: file, + Line: 22, + Column: 21, + Name: "dependentSchemas", + }, schema.Origin.Fields["dependentSchemas"]) + + // patternProperties is a Schemas map — mapping-valued + require.Contains(t, schema.Origin.Fields, "patternProperties") + require.Equal(t, Location{ + File: file, + Line: 25, + Column: 21, + Name: "patternProperties", + }, schema.Origin.Fields["patternProperties"]) +} diff --git a/openapi3/refs.go b/openapi3/refs.go index 76b7e0d2d..d7ad78b1c 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -1,4 +1,4 @@ -// Code generated by go generate; DO NOT EDIT. +// Code generated by go generate using refs.tmpl; DO NOT EDIT refs.go. package openapi3 import ( @@ -990,6 +990,9 @@ type SchemaRef struct { Ref string Value *Schema extra []string + // sibling holds keyword siblings of a $ref (OAS 3.1 / JSON Schema 2020-12). + // It is populated during unmarshal and applied to Value after $ref resolution. + sibling *Schema refPath *url.URL } @@ -1046,6 +1049,22 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } slices.Sort(x.extra) + // OAS 3.1 / JSON Schema 2020-12: sibling keywords alongside $ref are valid + // and must be merged with the resolved reference. Parse the full object so + // the sibling fields are available after $ref resolution in resolveSchemaRef. + hasSiblings := false + for k := range extra { + if !strings.HasPrefix(k, "x-") { + hasSiblings = true + break + } + } + if hasSiblings { + var sibling Schema + if err := json.Unmarshal(data, &sibling); err == nil { + x.sibling = &sibling + } + } for k := range extra { if !strings.HasPrefix(k, "x-") { delete(extra, k) @@ -1093,7 +1112,9 @@ func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) erro } if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + if !getValidationOptions(ctx).jsonSchema2020ValidationEnabled { + return fmt.Errorf("extra sibling fields: %+v", extras) + } } if v := x.Value; v != nil { diff --git a/openapi3/refs.tmpl b/openapi3/refs.tmpl index df33a6586..d0c7471a5 100644 --- a/openapi3/refs.tmpl +++ b/openapi3/refs.tmpl @@ -1,4 +1,4 @@ -// Code generated by go generate; DO NOT EDIT. +// Code generated by go generate using refs.tmpl; DO NOT EDIT refs.go. package {{ .Package }} import ( @@ -24,6 +24,11 @@ type {{ $type.Name }}Ref struct { Ref string Value *{{ $type.Name }} extra []string +{{- if eq $type.Name "Schema" }} + // sibling holds keyword siblings of a $ref (OAS 3.1 / JSON Schema 2020-12). + // It is populated during unmarshal and applied to Value after $ref resolution. + sibling *Schema +{{- end }} refPath *url.URL } @@ -80,6 +85,24 @@ func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } slices.Sort(x.extra) +{{- if eq $type.Name "Schema" }} + // OAS 3.1 / JSON Schema 2020-12: sibling keywords alongside $ref are valid + // and must be merged with the resolved reference. Parse the full object so + // the sibling fields are available after $ref resolution in resolveSchemaRef. + hasSiblings := false + for k := range extra { + if !strings.HasPrefix(k, "x-") { + hasSiblings = true + break + } + } + if hasSiblings { + var sibling Schema + if err := json.Unmarshal(data, &sibling); err == nil { + x.sibling = &sibling + } + } +{{- end }} for k := range extra { if !strings.HasPrefix(k, "x-") { delete(extra, k) @@ -127,7 +150,13 @@ func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOp } if len(extras) != 0 { +{{- if eq $type.Name "Schema" }} + if !getValidationOptions(ctx).jsonSchema2020ValidationEnabled { + return fmt.Errorf("extra sibling fields: %+v", extras) + } +{{- else }} return fmt.Errorf("extra sibling fields: %+v", extras) +{{- end }} } if v := x.Value; v != nil { diff --git a/openapi3/schema.go b/openapi3/schema.go index 986734e1d..7b78a749e 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -100,8 +100,10 @@ type Schema struct { // Array-related, here for struct compactness UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` // Number-related, here for struct compactness - ExclusiveMin bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` - ExclusiveMax bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + // 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"` // Properties Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` @@ -132,14 +134,88 @@ type Schema struct { MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` 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"` +} + +// Types represents the type(s) of a schema. +// +// In OpenAPI 3.0, this is typically a single type (e.g., "string"). +// In OpenAPI 3.1, it can be an array of types (e.g., ["string", "null"]). +// +// Serialization behavior: +// - Single type: serializes as a string (e.g., "string") +// - Multiple types: serializes as an array (e.g., ["string", "null"]) +// - Accepts both string and array formats when unmarshaling +// +// Example OpenAPI 3.0 (single type): +// +// schema := &Schema{Type: &Types{"string"}} +// // JSON: {"type": "string"} +// +// Example OpenAPI 3.1 (type array): +// +// schema := &Schema{Type: &Types{"string", "null"}} +// // JSON: {"type": ["string", "null"]} type Types []string +// Is returns true if the schema has exactly one type and it matches the given type. +// This is useful for OpenAPI 3.0 style single-type checks. +// +// Example: +// +// types := &Types{"string"} +// types.Is("string") // true +// types.Is("number") // false +// +// types = &Types{"string", "null"} +// types.Is("string") // false (multiple types) func (types *Types) Is(typ string) bool { return types != nil && len(*types) == 1 && (*types)[0] == typ } +// Slice returns the types as a string slice. +// Returns nil if types is nil. +// +// Example: +// +// types := &Types{"string", "null"} +// slice := types.Slice() // []string{"string", "null"} func (types *Types) Slice() []string { if types == nil { return nil @@ -147,6 +223,15 @@ func (types *Types) Slice() []string { return *types } +// Includes returns true if the given type is included in the type array. +// Returns false if types is nil. +// +// Example: +// +// types := &Types{"string", "null"} +// types.Includes("string") // true +// types.Includes("null") // true +// types.Includes("number") // false func (pTypes *Types) Includes(typ string) bool { if pTypes == nil { return false @@ -160,6 +245,17 @@ func (pTypes *Types) Includes(typ string) bool { return false } +// Permits returns true if the given type is permitted. +// Returns true if types is nil (any type allowed), otherwise checks if the type is included. +// +// Example: +// +// var nilTypes *Types +// nilTypes.Permits("anything") // true (nil permits everything) +// +// types := &Types{"string"} +// types.Permits("string") // true +// types.Permits("number") // false func (types *Types) Permits(typ string) bool { if types == nil { return true @@ -167,6 +263,64 @@ func (types *Types) Permits(typ string) bool { return types.Includes(typ) } +// IncludesNull returns true if the type array includes "null". +// This is useful for OpenAPI 3.1 where null is a first-class type. +// +// Example: +// +// types := &Types{"string", "null"} +// types.IncludesNull() // true +// +// types = &Types{"string"} +// types.IncludesNull() // false +func (types *Types) IncludesNull() bool { + return types.Includes(TypeNull) +} + +// IsMultiple returns true if multiple types are specified. +// This is an OpenAPI 3.1 feature that enables type arrays. +// +// Example: +// +// types := &Types{"string"} +// types.IsMultiple() // false +// +// types = &Types{"string", "null"} +// types.IsMultiple() // true +func (types *Types) IsMultiple() bool { + return types != nil && len(*types) > 1 +} + +// IsSingle returns true if exactly one type is specified. +// +// Example: +// +// types := &Types{"string"} +// types.IsSingle() // true +// +// types = &Types{"string", "null"} +// types.IsSingle() // false +func (types *Types) IsSingle() bool { + return types != nil && len(*types) == 1 +} + +// IsEmpty returns true if no types are specified (nil or empty array). +// When a schema has no type specified, it permits any type. +// +// Example: +// +// var nilTypes *Types +// nilTypes.IsEmpty() // true +// +// types := &Types{} +// types.IsEmpty() // true +// +// types = &Types{"string"} +// types.IsEmpty() // false +func (types *Types) IsEmpty() bool { + return types == nil || len(*types) == 0 +} + func (pTypes *Types) MarshalJSON() ([]byte, error) { x, err := pTypes.MarshalYAML() if err != nil { @@ -203,36 +357,41 @@ func (types *Types) UnmarshalJSON(data []byte) error { return nil } -type AdditionalProperties struct { +// BoolSchema represents a JSON Schema keyword that can be either a boolean or a schema object. +// Used for additionalProperties, unevaluatedProperties, and unevaluatedItems. +type BoolSchema struct { Has *bool Schema *SchemaRef } -// MarshalYAML returns the YAML encoding of AdditionalProperties. -func (addProps AdditionalProperties) MarshalYAML() (any, error) { - if x := addProps.Has; x != nil { +// AdditionalProperties is a type alias for BoolSchema, kept for backward compatibility. +type AdditionalProperties = BoolSchema + +// MarshalYAML returns the YAML encoding of BoolSchema. +func (bs BoolSchema) MarshalYAML() (any, error) { + if x := bs.Has; x != nil { if *x { return true, nil } return false, nil } - if x := addProps.Schema; x != nil { + if x := bs.Schema; x != nil { return x.MarshalYAML() } return nil, nil } -// MarshalJSON returns the JSON encoding of AdditionalProperties. -func (addProps AdditionalProperties) MarshalJSON() ([]byte, error) { - x, err := addProps.MarshalYAML() +// MarshalJSON returns the JSON encoding of BoolSchema. +func (bs BoolSchema) MarshalJSON() ([]byte, error) { + x, err := bs.MarshalYAML() if err != nil { return nil, err } return json.Marshal(x) } -// UnmarshalJSON sets AdditionalProperties to a copy of data. -func (addProps *AdditionalProperties) UnmarshalJSON(data []byte) error { +// UnmarshalJSON sets BoolSchema to a copy of data. +func (bs *BoolSchema) UnmarshalJSON(data []byte) error { var x any if err := json.Unmarshal(data, &x); err != nil { return unmarshalError(err) @@ -240,19 +399,79 @@ func (addProps *AdditionalProperties) UnmarshalJSON(data []byte) error { switch y := x.(type) { case nil: case bool: - addProps.Has = &y + bs.Has = &y case map[string]any: if len(y) == 0 { - addProps.Schema = &SchemaRef{Value: &Schema{}} + bs.Schema = &SchemaRef{Value: &Schema{}} } else { buf := new(bytes.Buffer) _ = json.NewEncoder(buf).Encode(y) - if err := json.NewDecoder(buf).Decode(&addProps.Schema); err != nil { + if err := json.NewDecoder(buf).Decode(&bs.Schema); err != nil { return err } } default: - return errors.New("cannot unmarshal additionalProperties: value must be either a schema object or a boolean") + return errors.New("cannot unmarshal: value must be either a schema object or a boolean") + } + return nil +} + +// ExclusiveBound represents exclusiveMinimum/exclusiveMaximum which changed type between OpenAPI versions. +// In OpenAPI 3.0 (JSON Schema draft-04): boolean that modifies minimum/maximum +// In OpenAPI 3.1 (JSON Schema 2020-12): number representing the actual exclusive bound +type ExclusiveBound struct { + Bool *bool // For OpenAPI 3.0 style (modifier for min/max) + Value *float64 // For OpenAPI 3.1 style (actual bound value) +} + +// IsSet returns true if either Bool or Value is set. +func (eb ExclusiveBound) IsSet() bool { + return eb.Bool != nil || eb.Value != nil +} + +// IsTrue returns true if the bound is set as a boolean true (OpenAPI 3.0 style). +func (eb ExclusiveBound) IsTrue() bool { + return eb.Bool != nil && *eb.Bool +} + +// MarshalYAML returns the YAML encoding of ExclusiveBound. +func (eb ExclusiveBound) MarshalYAML() (any, error) { + if eb.Value != nil { + return *eb.Value, nil + } + if eb.Bool != nil { + return *eb.Bool, nil + } + return nil, nil +} + +// MarshalJSON returns the JSON encoding of ExclusiveBound. +func (eb ExclusiveBound) MarshalJSON() ([]byte, error) { + x, err := eb.MarshalYAML() + if err != nil { + return nil, err + } + if x == nil { + return nil, nil + } + return json.Marshal(x) +} + +// UnmarshalJSON sets ExclusiveBound to a copy of data. +func (eb *ExclusiveBound) UnmarshalJSON(data []byte) error { + var x any + if err := json.Unmarshal(data, &x); err != nil { + return unmarshalError(err) + } + switch y := x.(type) { + case nil: + // nothing to do + case bool: + eb.Bool = &y + case float64: + eb.Value = &y + default: + return errors.New("cannot unmarshal exclusiveMinimum/exclusiveMaximum: value must be either a number or a boolean") } return nil } @@ -322,11 +541,15 @@ func (schema Schema) MarshalYAML() (any, error) { m["uniqueItems"] = x } // Number-related - if x := schema.ExclusiveMin; x { - m["exclusiveMinimum"] = x + if schema.ExclusiveMin.IsSet() { + if v, _ := schema.ExclusiveMin.MarshalYAML(); v != nil { + m["exclusiveMinimum"] = v + } } - if x := schema.ExclusiveMax; x { - m["exclusiveMaximum"] = x + if schema.ExclusiveMax.IsSet() { + if v, _ := schema.ExclusiveMax.MarshalYAML(); v != nil { + m["exclusiveMaximum"] = v + } } // Properties if x := schema.Nullable; x { @@ -401,6 +624,83 @@ func (schema Schema) MarshalYAML() (any, error) { m["discriminator"] = x } + // OpenAPI 3.1 / JSON Schema 2020-12 fields + if x := schema.Const; x != nil { + m["const"] = x + } + if x := schema.Examples; len(x) != 0 { + m["examples"] = x + } + if x := schema.PrefixItems; len(x) != 0 { + m["prefixItems"] = x + } + if x := schema.Contains; x != nil { + m["contains"] = x + } + if x := schema.MinContains; x != nil { + m["minContains"] = x + } + if x := schema.MaxContains; x != nil { + m["maxContains"] = x + } + if x := schema.PatternProperties; len(x) != 0 { + m["patternProperties"] = x + } + if x := schema.DependentSchemas; len(x) != 0 { + m["dependentSchemas"] = x + } + if x := schema.PropertyNames; x != nil { + m["propertyNames"] = x + } + if x := schema.UnevaluatedItems; x.Has != nil || x.Schema != nil { + m["unevaluatedItems"] = &x + } + if x := schema.UnevaluatedProperties; x.Has != nil || x.Schema != nil { + m["unevaluatedProperties"] = &x + } + if x := schema.If; x != nil { + m["if"] = x + } + if x := schema.Then; x != nil { + m["then"] = x + } + if x := schema.Else; x != nil { + m["else"] = x + } + if x := schema.DependentRequired; len(x) != 0 { + m["dependentRequired"] = x + } + if x := schema.Defs; len(x) != 0 { + m["$defs"] = x + } + if x := schema.SchemaDialect; x != "" { + m["$schema"] = x + } + if x := schema.Comment; x != "" { + m["$comment"] = x + } + if x := schema.SchemaID; x != "" { + m["$id"] = x + } + if x := schema.Anchor; x != "" { + m["$anchor"] = x + } + if x := schema.DynamicRef; x != "" { + m["$dynamicRef"] = x + } + if x := schema.DynamicAnchor; x != "" { + m["$dynamicAnchor"] = x + } + if x := schema.ContentMediaType; x != "" { + m["contentMediaType"] = x + } + if x := schema.ContentEncoding; x != "" { + m["contentEncoding"] = x + } + if x := schema.ContentSchema; x != nil { + m["contentSchema"] = x + } + return m, nil } @@ -462,6 +762,33 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { delete(x.Extensions, "additionalProperties") delete(x.Extensions, "discriminator") + // OpenAPI 3.1 / JSON Schema 2020-12 fields + delete(x.Extensions, "const") + delete(x.Extensions, "examples") + delete(x.Extensions, "prefixItems") + delete(x.Extensions, "contains") + delete(x.Extensions, "minContains") + delete(x.Extensions, "maxContains") + delete(x.Extensions, "patternProperties") + delete(x.Extensions, "dependentSchemas") + delete(x.Extensions, "propertyNames") + delete(x.Extensions, "unevaluatedItems") + delete(x.Extensions, "unevaluatedProperties") + delete(x.Extensions, "if") + delete(x.Extensions, "then") + delete(x.Extensions, "else") + delete(x.Extensions, "dependentRequired") + delete(x.Extensions, "$defs") + delete(x.Extensions, "$schema") + delete(x.Extensions, "$comment") + delete(x.Extensions, "$id") + delete(x.Extensions, "$anchor") + delete(x.Extensions, "$dynamicRef") + delete(x.Extensions, "$dynamicAnchor") + delete(x.Extensions, "contentMediaType") + delete(x.Extensions, "contentEncoding") + delete(x.Extensions, "contentSchema") + if len(x.Extensions) == 0 { x.Extensions = nil } @@ -570,6 +897,104 @@ func (schema Schema) JSONLookup(token string) (any, error) { return schema.MaxProps, nil case "discriminator": return schema.Discriminator, nil + + // OpenAPI 3.1 / JSON Schema 2020-12 fields + case "const": + return schema.Const, nil + case "examples": + return schema.Examples, nil + case "prefixItems": + return schema.PrefixItems, nil + case "contains": + if schema.Contains != nil { + if schema.Contains.Ref != "" { + return &Ref{Ref: schema.Contains.Ref}, nil + } + return schema.Contains.Value, nil + } + case "minContains": + return schema.MinContains, nil + case "maxContains": + return schema.MaxContains, nil + case "patternProperties": + return schema.PatternProperties, nil + case "dependentSchemas": + return schema.DependentSchemas, nil + case "propertyNames": + if schema.PropertyNames != nil { + if schema.PropertyNames.Ref != "" { + return &Ref{Ref: schema.PropertyNames.Ref}, nil + } + return schema.PropertyNames.Value, nil + } + case "unevaluatedItems": + if ui := schema.UnevaluatedItems.Has; ui != nil { + return *ui, nil + } + if ui := schema.UnevaluatedItems.Schema; ui != nil { + if ui.Ref != "" { + return &Ref{Ref: ui.Ref}, nil + } + return ui.Value, nil + } + case "unevaluatedProperties": + if up := schema.UnevaluatedProperties.Has; up != nil { + return *up, nil + } + if up := schema.UnevaluatedProperties.Schema; up != nil { + if up.Ref != "" { + return &Ref{Ref: up.Ref}, nil + } + return up.Value, nil + } + case "if": + if schema.If != nil { + if schema.If.Ref != "" { + return &Ref{Ref: schema.If.Ref}, nil + } + return schema.If.Value, nil + } + case "then": + if schema.Then != nil { + if schema.Then.Ref != "" { + return &Ref{Ref: schema.Then.Ref}, nil + } + return schema.Then.Value, nil + } + case "else": + if schema.Else != nil { + if schema.Else.Ref != "" { + return &Ref{Ref: schema.Else.Ref}, nil + } + return schema.Else.Value, nil + } + case "dependentRequired": + return schema.DependentRequired, nil + case "$defs": + return schema.Defs, nil + case "$schema": + return schema.SchemaDialect, nil + case "$comment": + return schema.Comment, nil + case "$id": + return schema.SchemaID, nil + case "$anchor": + return schema.Anchor, nil + case "$dynamicRef": + return schema.DynamicRef, nil + case "$dynamicAnchor": + return schema.DynamicAnchor, nil + case "contentMediaType": + return schema.ContentMediaType, nil + case "contentEncoding": + return schema.ContentEncoding, nil + case "contentSchema": + if schema.ContentSchema != nil { + if schema.ContentSchema.Ref != "" { + return &Ref{Ref: schema.ContentSchema.Ref}, nil + } + return schema.ContentSchema.Value, nil + } } v, _, err := jsonpointer.GetForToken(schema.Extensions, token) @@ -699,13 +1124,27 @@ func (schema *Schema) WithMax(value float64) *Schema { return schema } +// WithExclusiveMin sets exclusiveMinimum as a boolean (OpenAPI 3.0 style). func (schema *Schema) WithExclusiveMin(value bool) *Schema { - schema.ExclusiveMin = value + schema.ExclusiveMin = ExclusiveBound{Bool: &value} return schema } +// WithExclusiveMax sets exclusiveMaximum as a boolean (OpenAPI 3.0 style). func (schema *Schema) WithExclusiveMax(value bool) *Schema { - schema.ExclusiveMax = value + schema.ExclusiveMax = ExclusiveBound{Bool: &value} + return schema +} + +// WithExclusiveMinValue sets exclusiveMinimum as a number (OpenAPI 3.1 style). +func (schema *Schema) WithExclusiveMinValue(value float64) *Schema { + schema.ExclusiveMin = ExclusiveBound{Value: &value} + return schema +} + +// WithExclusiveMaxValue sets exclusiveMaximum as a number (OpenAPI 3.1 style). +func (schema *Schema) WithExclusiveMaxValue(value float64) *Schema { + schema.ExclusiveMax = ExclusiveBound{Value: &value} return schema } @@ -855,19 +1294,20 @@ func (schema *Schema) WithAdditionalProperties(v *Schema) *Schema { } func (schema *Schema) PermitsNull() bool { - return schema.Nullable || schema.Type.Includes("null") + return schema.Nullable || schema.Type.IncludesNull() } // IsEmpty tells whether schema is equivalent to the empty schema `{}`. func (schema *Schema) IsEmpty() bool { if schema.Type != nil || schema.Format != "" || len(schema.Enum) != 0 || - schema.UniqueItems || schema.ExclusiveMin || schema.ExclusiveMax || + schema.UniqueItems || schema.ExclusiveMin.IsSet() || schema.ExclusiveMax.IsSet() || schema.Nullable || schema.ReadOnly || schema.WriteOnly || schema.AllowEmptyValue || schema.Min != nil || schema.Max != nil || schema.MultipleOf != nil || schema.MinLength != 0 || schema.MaxLength != nil || schema.Pattern != "" || schema.MinItems != 0 || schema.MaxItems != nil || len(schema.Required) != 0 || - schema.MinProps != 0 || schema.MaxProps != nil { + schema.MinProps != 0 || schema.MaxProps != nil || + schema.Const != nil { return false } if n := schema.Not; n != nil && n.Value != nil && !n.Value.IsEmpty() { @@ -882,11 +1322,50 @@ func (schema *Schema) IsEmpty() bool { if items := schema.Items; items != nil && items.Value != nil && !items.Value.IsEmpty() { return false } + for _, s := range schema.PrefixItems { + if ss := s.Value; ss != nil && !ss.IsEmpty() { + return false + } + } + if c := schema.Contains; c != nil && c.Value != nil && !c.Value.IsEmpty() { + return false + } + if schema.MinContains != nil || schema.MaxContains != nil { + return false + } for _, s := range schema.Properties { if ss := s.Value; ss != nil && !ss.IsEmpty() { return false } } + for _, s := range schema.PatternProperties { + if ss := s.Value; ss != nil && !ss.IsEmpty() { + return false + } + } + for _, s := range schema.DependentSchemas { + if ss := s.Value; ss != nil && !ss.IsEmpty() { + return false + } + } + if pn := schema.PropertyNames; pn != nil && pn.Value != nil && !pn.Value.IsEmpty() { + return false + } + if ui := schema.UnevaluatedItems.Schema; ui != nil && ui.Value != nil && !ui.Value.IsEmpty() { + return false + } + if uih := schema.UnevaluatedItems.Has; uih != nil && !*uih { + return false + } + if up := schema.UnevaluatedProperties.Schema; up != nil && up.Value != nil && !up.Value.IsEmpty() { + return false + } + if uph := schema.UnevaluatedProperties.Has; uph != nil && !*uph { + return false + } + if len(schema.Examples) != 0 { + return false + } for _, s := range schema.OneOf { if ss := s.Value; ss != nil && !ss.IsEmpty() { return false @@ -902,12 +1381,42 @@ func (schema *Schema) IsEmpty() bool { return false } } + if f := schema.If; f != nil && f.Value != nil && !f.Value.IsEmpty() { + return false + } + if t := schema.Then; t != nil && t.Value != nil && !t.Value.IsEmpty() { + return false + } + if e := schema.Else; e != nil && e.Value != nil && !e.Value.IsEmpty() { + return false + } + if len(schema.DependentRequired) != 0 { + return false + } + if len(schema.Defs) != 0 { + return false + } + if schema.SchemaDialect != "" || schema.Comment != "" { + return false + } + if schema.SchemaID != "" || schema.Anchor != "" || schema.DynamicRef != "" || schema.DynamicAnchor != "" { + return false + } + if schema.ContentMediaType != "" || schema.ContentEncoding != "" { + return false + } + if cs := schema.ContentSchema; cs != nil && cs.Value != nil && !cs.Value.IsEmpty() { + return false + } return true } // Validate returns an error if Schema does not comply with the OpenAPI spec. func (schema *Schema) Validate(ctx context.Context, opts ...ValidationOption) error { + // Apply document-level validation options to the context ctx = WithValidationOptions(ctx, opts...) + + // Perform schema validation with the options in context _, err := schema.validate(ctx, []*Schema{}) return err } @@ -975,6 +1484,37 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } } + if ref := schema.If; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if ref := schema.Then; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if ref := schema.Else; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + for _, schemaType := range schema.Type.Slice() { switch schemaType { case TypeBoolean: @@ -1025,10 +1565,14 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } } case TypeArray: - if schema.Items == nil { + if schema.Items == nil && !validationOpts.jsonSchema2020ValidationEnabled && len(schema.PrefixItems) == 0 { return stack, errors.New("when schema type is 'array', schema 'items' must be non-null") } case TypeObject: + case TypeNull: + if !validationOpts.jsonSchema2020ValidationEnabled { + return stack, fmt.Errorf("unsupported 'type' value %q", schemaType) + } default: return stack, fmt.Errorf("unsupported 'type' value %q", schemaType) } @@ -1079,6 +1623,116 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } } + // OpenAPI 3.1 / JSON Schema 2020-12 sub-schemas + for _, ref := range schema.PrefixItems { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if ref := schema.Contains; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + for _, name := range componentNames(schema.PatternProperties) { + ref := schema.PatternProperties[name] + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + for _, name := range componentNames(schema.DependentSchemas) { + ref := schema.DependentSchemas[name] + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + for _, name := range componentNames(schema.Defs) { + ref := schema.Defs[name] + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if ref := schema.PropertyNames; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if schema.UnevaluatedItems.Has != nil && schema.UnevaluatedItems.Schema != nil { + return stack, errors.New("unevaluatedItems is set to both boolean and schema") + } + if ref := schema.UnevaluatedItems.Schema; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if schema.UnevaluatedProperties.Has != nil && schema.UnevaluatedProperties.Schema != nil { + return stack, errors.New("unevaluatedProperties is set to both boolean and schema") + } + if ref := schema.UnevaluatedProperties.Schema; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if ref := schema.ContentSchema; ref != nil { + v := ref.Value + if v == nil { + return stack, foundUnresolvedRef(ref.Ref) + } + + var err error + if stack, err = v.validate(ctx, stack); err != nil { + return stack, err + } + } + if v := schema.ExternalDocs; v != nil { if err := v.Validate(ctx); err != nil { return stack, fmt.Errorf("invalid external docs: %w", err) @@ -1086,7 +1740,11 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, } if v := schema.Default; v != nil && !validationOpts.schemaDefaultsValidationDisabled { - if err := schema.VisitJSON(v); err != nil { + opts := []SchemaValidationOption{} + if validationOpts.jsonSchema2020ValidationEnabled { + opts = append(opts, EnableJSONSchema2020()) + } + if err := schema.VisitJSON(v, opts...); err != nil { return stack, fmt.Errorf("invalid default: %w", err) } } @@ -1132,6 +1790,12 @@ func (schema *Schema) IsMatchingJSONObject(value map[string]any) bool { 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.visitJSON(settings, value) } @@ -1171,6 +1835,9 @@ func (schema *Schema) visitJSON(settings *schemaValidationSettings, value any) ( if err = schema.visitEnumOperation(settings, value); err != nil { return } + if err = schema.visitConstOperation(settings, value); err != nil { + return + } switch value := value.(type) { case nil: @@ -1272,6 +1939,39 @@ func (schema *Schema) visitEnumOperation(settings *schemaValidationSettings, val return } +func (schema *Schema) visitConstOperation(settings *schemaValidationSettings, value any) (err error) { + if schema.Const == nil { + return + } + var match bool + switch c := value.(type) { + case json.Number: + var f float64 + if f, err = strconv.ParseFloat(c.String(), 64); err != nil { + return err + } + match = reflect.DeepEqual(schema.Const, f) + case int64: + match = reflect.DeepEqual(schema.Const, float64(c)) + default: + match = reflect.DeepEqual(schema.Const, value) + } + if !match { + if settings.failfast { + return errSchema + } + constVal, _ := json.Marshal(schema.Const) + return &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "const", + Reason: fmt.Sprintf("value must be %s", string(constVal)), + customizeMessageError: settings.customizeMessageError, + } + } + return +} + func (schema *Schema) visitNotOperation(settings *schemaValidationSettings, value any) (err error) { if ref := schema.Not; ref != nil { v := ref.Value @@ -1295,41 +1995,54 @@ func (schema *Schema) visitNotOperation(settings *schemaValidationSettings, valu // If the XOF operations pass successfully, abort further run of validation, as they will already be satisfied (unless the schema // itself is badly specified +// resolveDiscriminatorRef resolves the discriminator reference for oneOf/anyOf validation. +// Returns the discriminator ref string and any error encountered during resolution. +func (schema *Schema) resolveDiscriminatorRef(value any) (string, error) { + if schema.Discriminator == nil { + return "", nil + } + pn := schema.Discriminator.PropertyName + valuemap, okcheck := value.(map[string]any) + if !okcheck { + return "", nil + } + discriminatorVal, okcheck := valuemap[pn] + if !okcheck { + return "", &SchemaError{ + Schema: schema, + SchemaField: "discriminator", + Reason: fmt.Sprintf("input does not contain the discriminator property %q", pn), + } + } + + discriminatorValString, okcheck := discriminatorVal.(string) + if !okcheck { + return "", &SchemaError{ + Value: discriminatorVal, + Schema: schema, + SchemaField: "discriminator", + Reason: fmt.Sprintf("value of discriminator property %q is not a string", pn), + } + } + + if discriminatorRef, okcheck := schema.Discriminator.Mapping[discriminatorValString]; len(schema.Discriminator.Mapping) > 0 && !okcheck { + return "", &SchemaError{ + Value: discriminatorVal, + Schema: schema, + SchemaField: "discriminator", + Reason: fmt.Sprintf("discriminator property %q has invalid value", pn), + } + } else { + return discriminatorRef.Ref, nil + } +} + func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, value any) (err error, run bool) { var visitedOneOf, visitedAnyOf, visitedAllOf bool if v := schema.OneOf; len(v) > 0 { - var discriminatorRef MappingRef - if schema.Discriminator != nil { - pn := schema.Discriminator.PropertyName - if valuemap, okcheck := value.(map[string]any); okcheck { - discriminatorVal, okcheck := valuemap[pn] - if !okcheck { - return &SchemaError{ - Schema: schema, - SchemaField: "discriminator", - Reason: fmt.Sprintf("input does not contain the discriminator property %q", pn), - }, false - } - - discriminatorValString, okcheck := discriminatorVal.(string) - if !okcheck { - return &SchemaError{ - Value: discriminatorVal, - Schema: schema, - SchemaField: "discriminator", - Reason: fmt.Sprintf("value of discriminator property %q is not a string", pn), - }, false - } - - if discriminatorRef, okcheck = schema.Discriminator.Mapping[discriminatorValString]; len(schema.Discriminator.Mapping) > 0 && !okcheck { - return &SchemaError{ - Value: discriminatorVal, - Schema: schema, - SchemaField: "discriminator", - Reason: fmt.Sprintf("discriminator property %q has invalid value", pn), - }, false - } - } + discriminatorRef, err := schema.resolveDiscriminatorRef(value) + if err != nil { + return err, false } var ( @@ -1344,7 +2057,7 @@ func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, val return foundUnresolvedRef(item.Ref), false } - if discriminatorRef.Ref != "" && discriminatorRef.Ref != item.Ref { + if discriminatorRef != "" && discriminatorRef != item.Ref { continue } @@ -1391,6 +2104,11 @@ func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, val } if v := schema.AnyOf; len(v) > 0 { + discriminatorRef, err := schema.resolveDiscriminatorRef(value) + if err != nil { + return err, false + } + var ( ok = false matchedAnyOfIdx = 0 @@ -1401,6 +2119,11 @@ func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, val if v == nil { return foundUnresolvedRef(item.Ref), false } + + if discriminatorRef != "" && discriminatorRef != item.Ref { + continue + } + // make a deep copy to protect origin value from being injected default value that defined in mismatched anyOf schema if settings.asreq || settings.asrep { tempValue = deepcopy.Copy(value) @@ -1582,39 +2305,73 @@ func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value } // "exclusiveMinimum" - if v := schema.ExclusiveMin; v && !(*schema.Min < value) { - if settings.failfast { - return errSchema - } - err := &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "exclusiveMinimum", - Reason: fmt.Sprintf("number must be more than %g", *schema.Min), - customizeMessageError: settings.customizeMessageError, + // OpenAPI 3.0: boolean modifier for minimum + // OpenAPI 3.1: number representing the actual exclusive bound + if eb := schema.ExclusiveMin; eb.IsSet() { + var exclusiveMinBound float64 + var valid bool + if eb.Value != nil { + // OpenAPI 3.1 style: exclusiveMinimum is the bound itself + exclusiveMinBound = *eb.Value + valid = value > exclusiveMinBound + } else if eb.Bool != nil && *eb.Bool && schema.Min != nil { + // OpenAPI 3.0 style: exclusiveMinimum modifies minimum + exclusiveMinBound = *schema.Min + valid = value > exclusiveMinBound + } else { + valid = true } - if !settings.multiError { - return err + if !valid { + if settings.failfast { + return errSchema + } + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "exclusiveMinimum", + Reason: fmt.Sprintf("number must be more than %g", exclusiveMinBound), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err + } + me = append(me, err) } - me = append(me, err) } // "exclusiveMaximum" - if v := schema.ExclusiveMax; v && !(*schema.Max > value) { - if settings.failfast { - return errSchema - } - err := &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "exclusiveMaximum", - Reason: fmt.Sprintf("number must be less than %g", *schema.Max), - customizeMessageError: settings.customizeMessageError, + // OpenAPI 3.0: boolean modifier for maximum + // OpenAPI 3.1: number representing the actual exclusive bound + if eb := schema.ExclusiveMax; eb.IsSet() { + var exclusiveMaxBound float64 + var valid bool + if eb.Value != nil { + // OpenAPI 3.1 style: exclusiveMaximum is the bound itself + exclusiveMaxBound = *eb.Value + valid = value < exclusiveMaxBound + } else if eb.Bool != nil && *eb.Bool && schema.Max != nil { + // OpenAPI 3.0 style: exclusiveMaximum modifies maximum + exclusiveMaxBound = *schema.Max + valid = value < exclusiveMaxBound + } else { + valid = true } - if !settings.multiError { - return err + if !valid { + if settings.failfast { + return errSchema + } + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "exclusiveMaximum", + Reason: fmt.Sprintf("number must be less than %g", exclusiveMaxBound), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err + } + me = append(me, err) } - me = append(me, err) } // "minimum" diff --git a/openapi3/schema_const_test.go b/openapi3/schema_const_test.go new file mode 100644 index 000000000..7c3e4ccba --- /dev/null +++ b/openapi3/schema_const_test.go @@ -0,0 +1,92 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSchemaConst_BuiltInValidator(t *testing.T) { + t.Run("string const", func(t *testing.T) { + schema := &Schema{ + Const: "production", + } + + err := schema.VisitJSON("production") + require.NoError(t, err) + + err = schema.VisitJSON("development") + require.Error(t, err) + require.ErrorContains(t, err, "const") + }) + + t.Run("number const", func(t *testing.T) { + schema := &Schema{ + Const: float64(42), + } + + err := schema.VisitJSON(float64(42)) + require.NoError(t, err) + + err = schema.VisitJSON(float64(43)) + require.Error(t, err) + }) + + t.Run("boolean const", func(t *testing.T) { + schema := &Schema{ + Const: true, + } + + err := schema.VisitJSON(true) + require.NoError(t, err) + + err = schema.VisitJSON(false) + require.Error(t, err) + }) + + t.Run("null const", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"null"}, + Const: nil, + } + + // nil const means "not set", so this should pass as empty schema + err := schema.VisitJSON(nil) + require.NoError(t, err) + }) + + t.Run("object const", func(t *testing.T) { + schema := &Schema{ + Const: map[string]any{"key": "value"}, + } + + err := schema.VisitJSON(map[string]any{"key": "value"}) + require.NoError(t, err) + + err = schema.VisitJSON(map[string]any{"key": "other"}) + require.Error(t, err) + }) + + t.Run("const with type constraint", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"string"}, + Const: "fixed", + } + + err := schema.VisitJSON("fixed") + require.NoError(t, err) + + err = schema.VisitJSON("other") + require.Error(t, err) + }) + + t.Run("const with multiError", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"string"}, + Const: "fixed", + } + + err := schema.VisitJSON("other", MultiErrors()) + require.Error(t, err) + }) +} diff --git a/openapi3/schema_if_then_else_test.go b/openapi3/schema_if_then_else_test.go new file mode 100644 index 000000000..bd56d8cee --- /dev/null +++ b/openapi3/schema_if_then_else_test.go @@ -0,0 +1,210 @@ +package openapi3 + +import ( + "context" + "testing" + + "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"}}}, + } + require.False(t, schema.IsEmpty()) + }) + + t.Run("schema with dependentRequired is not empty", func(t *testing.T) { + schema := &Schema{ + DependentRequired: map[string][]string{ + "creditCard": {"billingAddress"}, + }, + } + require.False(t, schema.IsEmpty()) + }) +} + +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"}}}, + } + + // String with length >= 3 → passes if+then + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + // Number → fails if, passes else + err = schema.VisitJSON(float64(42), EnableJSONSchema2020()) + require.NoError(t, err) + + // Short string → passes if, fails then + err = schema.VisitJSON("ab", EnableJSONSchema2020()) + require.Error(t, err) + + // Boolean → fails if, fails else + err = schema.VisitJSON(true, 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}}, + } + + // String with length >= 5 → passes + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + // Short string → fails then + err = schema.VisitJSON("hi", EnableJSONSchema2020()) + require.Error(t, err) + + // Number → fails if, no else so passes + err = schema.VisitJSON(float64(42), 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"}}}, + }, + DependentRequired: map[string][]string{ + "creditCard": {"billingAddress"}, + }, + } + + // Has creditCard and billingAddress → passes + err := schema.VisitJSON(map[string]any{ + "name": "John", + "creditCard": "1234", + "billingAddress": "123 Main St", + }, EnableJSONSchema2020()) + require.NoError(t, err) + + // No creditCard → passes (dependency not triggered) + err = schema.VisitJSON(map[string]any{ + "name": "John", + }, EnableJSONSchema2020()) + require.NoError(t, err) + + // Has creditCard but no billingAddress → fails + err = schema.VisitJSON(map[string]any{ + "name": "John", + "creditCard": "1234", + }, 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"}}}, + } + + data, err := schema.MarshalJSON() + require.NoError(t, err) + + var roundTripped Schema + err = roundTripped.UnmarshalJSON(data) + require.NoError(t, err) + + require.NotNil(t, roundTripped.If) + require.NotNil(t, roundTripped.Then) + require.NotNil(t, roundTripped.Else) + require.True(t, roundTripped.If.Value.Type.Is("string")) + require.Equal(t, uint64(3), roundTripped.Then.Value.MinLength) + require.True(t, roundTripped.Else.Value.Type.Is("number")) + }) + + t.Run("dependentRequired round-trip", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"object"}, + DependentRequired: map[string][]string{ + "creditCard": {"billingAddress", "cvv"}, + }, + } + + data, err := schema.MarshalJSON() + require.NoError(t, err) + + var roundTripped Schema + err = roundTripped.UnmarshalJSON(data) + require.NoError(t, err) + + require.Equal(t, map[string][]string{ + "creditCard": {"billingAddress", "cvv"}, + }, roundTripped.DependentRequired) + }) + + t.Run("no extensions leak", func(t *testing.T) { + schema := &Schema{ + If: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + DependentRequired: map[string][]string{"a": {"b"}}, + } + + data, err := schema.MarshalJSON() + require.NoError(t, err) + + var roundTripped Schema + err = roundTripped.UnmarshalJSON(data) + require.NoError(t, err) + + // if/then/else/dependentRequired should not leak into extensions + require.Nil(t, roundTripped.Extensions) + }) +} + +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"}, + } + err := schema.Validate(context.Background()) + 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"}, + } + err := schema.Validate(context.Background()) + 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"}, + } + err := schema.Validate(context.Background()) + 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"}}}, + } + err := schema.Validate(context.Background()) + require.NoError(t, err) + }) +} diff --git a/openapi3/schema_jsonschema_validator.go b/openapi3/schema_jsonschema_validator.go new file mode 100644 index 000000000..f66609322 --- /dev/null +++ b/openapi3/schema_jsonschema_validator.go @@ -0,0 +1,209 @@ +package openapi3 + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// jsonSchemaValidator wraps the santhosh-tekuri/jsonschema validator +type jsonSchemaValidator struct { + compiler *jsonschema.Compiler + schema *jsonschema.Schema +} + +// newJSONSchemaValidator creates a new validator using JSON Schema 2020-12 +func newJSONSchemaValidator(schema *Schema) (*jsonSchemaValidator, error) { + // Convert OpenAPI Schema to JSON Schema format + schemaBytes, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("failed to marshal schema: %w", err) + } + + var schemaMap map[string]any + if err := json.Unmarshal(schemaBytes, &schemaMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal schema: %w", err) + } + + // OpenAPI 3.1 specific transformations + transformOpenAPIToJSONSchema(schemaMap) + + // Create compiler + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + + // Add the schema + schemaURL := "https://example.com/schema.json" + if err := compiler.AddResource(schemaURL, schemaMap); err != nil { + return nil, fmt.Errorf("failed to add schema resource: %w", err) + } + + // Compile the schema + compiledSchema, err := compiler.Compile(schemaURL) + if err != nil { + return nil, fmt.Errorf("failed to compile schema: %w", err) + } + + return &jsonSchemaValidator{ + compiler: compiler, + schema: compiledSchema, + }, nil +} + +// transformOpenAPIToJSONSchema converts OpenAPI 3.0/3.1 specific keywords to JSON Schema format +func transformOpenAPIToJSONSchema(schema map[string]any) { + // Handle nullable - in OpenAPI 3.0, nullable is a boolean flag + // In OpenAPI 3.1 / JSON Schema 2020-12, we use type arrays + if nullable, ok := schema["nullable"].(bool); ok && nullable { + if typeVal, ok := schema["type"].(string); ok { + // Convert to type array with null + schema["type"] = []string{typeVal, "null"} + } else if _, hasType := schema["type"]; !hasType { + // nullable: true without type - add "null" to allow null values + schema["type"] = []string{"null"} + } + delete(schema, "nullable") + } + + // Handle exclusiveMinimum/exclusiveMaximum + // In OpenAPI 3.0, these are booleans alongside minimum/maximum + // In JSON Schema 2020-12, they are numeric values + if exclusiveMin, ok := schema["exclusiveMinimum"].(bool); ok { + if exclusiveMin { + if schemaMin, ok := schema["minimum"].(float64); ok { + schema["exclusiveMinimum"] = schemaMin + delete(schema, "minimum") + } else { + delete(schema, "exclusiveMinimum") + } + } else { + // exclusiveMinimum: false means inclusive, which is the JSON Schema default + delete(schema, "exclusiveMinimum") + } + } + if exclusiveMax, ok := schema["exclusiveMaximum"].(bool); ok { + if exclusiveMax { + if schemaMax, ok := schema["maximum"].(float64); ok { + schema["exclusiveMaximum"] = schemaMax + delete(schema, "maximum") + } else { + delete(schema, "exclusiveMaximum") + } + } else { + // exclusiveMaximum: false means inclusive, which is the JSON Schema default + delete(schema, "exclusiveMaximum") + } + } + + // Remove OpenAPI-specific keywords that aren't in JSON Schema + delete(schema, "discriminator") + delete(schema, "xml") + delete(schema, "externalDocs") + delete(schema, "example") // Use "examples" in 2020-12 + + // Recursively transform nested schemas (single schema fields) + for _, key := range []string{ + "additionalProperties", "items", "not", + // OpenAPI 3.1 / JSON Schema 2020-12 fields + "contains", "propertyNames", "unevaluatedItems", "unevaluatedProperties", + "if", "then", "else", "contentSchema", + } { + if val, ok := schema[key]; ok { + if nestedSchema, ok := val.(map[string]any); ok { + transformOpenAPIToJSONSchema(nestedSchema) + } + } + } + + // Transform schema arrays (oneOf, anyOf, allOf, prefixItems) + for _, key := range []string{"oneOf", "anyOf", "allOf", "prefixItems"} { + if val, ok := schema[key].([]any); ok { + for _, item := range val { + if nestedSchema, ok := item.(map[string]any); ok { + transformOpenAPIToJSONSchema(nestedSchema) + } + } + } + } + + // Transform schema maps (properties, patternProperties, dependentSchemas, $defs) + for _, key := range []string{"properties", "patternProperties", "dependentSchemas", "$defs"} { + if props, ok := schema[key].(map[string]any); ok { + for _, propVal := range props { + if propSchema, ok := propVal.(map[string]any); ok { + transformOpenAPIToJSONSchema(propSchema) + } + } + } + } +} + +// validate validates a value against the compiled JSON Schema +func (v *jsonSchemaValidator) validate(value any) error { + if err := v.schema.Validate(value); err != nil { + // Convert jsonschema error to SchemaError + return convertJSONSchemaError(err) + } + return nil +} + +// convertJSONSchemaError converts a jsonschema validation error to OpenAPI SchemaError format +func convertJSONSchemaError(err error) error { + var validationErr *jsonschema.ValidationError + if errors.As(err, &validationErr) { + return formatValidationError(validationErr, "") + } + return err +} + +// formatValidationError recursively formats validation errors +func formatValidationError(verr *jsonschema.ValidationError, parentPath string) error { + // Build the path from InstanceLocation slice + path := "/" + strings.Join(verr.InstanceLocation, "/") + if parentPath != "" && path != "/" { + path = parentPath + path + } else if path == "/" { + path = parentPath + } + + // Build error message using the Error() method + var msg strings.Builder + if path != "" { + msg.WriteString(fmt.Sprintf(`error at "%s": `, path)) + } + msg.WriteString(verr.Error()) + + // If there are sub-errors, format them too + if len(verr.Causes) > 0 { + var subErrors MultiError + for _, cause := range verr.Causes { + if subErr := formatValidationError(cause, path); subErr != nil { + subErrors = append(subErrors, subErr) + } + } + if len(subErrors) > 0 { + return &SchemaError{ + Reason: msg.String(), + Origin: fmt.Errorf("validation failed due to: %w", subErrors), + } + } + } + + return &SchemaError{ + Reason: msg.String(), + } +} + +// visitJSONWithJSONSchema validates using the JSON Schema 2020-12 validator +func (schema *Schema) visitJSONWithJSONSchema(settings *schemaValidationSettings, value any) error { + validator, err := newJSONSchemaValidator(schema) + if err != nil { + // Fall back to built-in validator if compilation fails + return schema.visitJSON(settings, value) + } + + return validator.validate(value) +} diff --git a/openapi3/schema_jsonschema_validator_test.go b/openapi3/schema_jsonschema_validator_test.go new file mode 100644 index 000000000..9cfd874f4 --- /dev/null +++ b/openapi3/schema_jsonschema_validator_test.go @@ -0,0 +1,419 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJSONSchema2020Validator_Basic(t *testing.T) { + t.Run("string validation", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"string"}, + } + + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(123, EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("number validation", func(t *testing.T) { + min := 0.0 + max := 100.0 + schema := &Schema{ + Type: &Types{"number"}, + Min: &min, + Max: &max, + } + + err := schema.VisitJSON(50.0, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(150.0, EnableJSONSchema2020()) + require.Error(t, err) + + err = schema.VisitJSON(-10.0, 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"}}}, + }, + Required: []string{"name"}, + } + + err := schema.VisitJSON(map[string]any{ + "name": "John", + "age": 30, + }) + require.NoError(t, err) + + err = schema.VisitJSON(map[string]any{ + "age": 30, + }) + require.Error(t, err) // missing required "name" + }) + + t.Run("array validation", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"array"}, + Items: &SchemaRef{Value: &Schema{ + Type: &Types{"string"}, + }}, + } + + err := schema.VisitJSON([]any{"a", "b", "c"}, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON([]any{"a", 1, "c"}, 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"}, + } + + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(nil, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(123, EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("nullable conversion", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"string"}, + Nullable: true, + } + + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(nil, EnableJSONSchema2020()) + require.NoError(t, err) + }) + + t.Run("const validation", func(t *testing.T) { + schema := &Schema{ + Const: "fixed-value", + } + + err := schema.VisitJSON("fixed-value", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON("other-value", EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("examples field", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"string"}, + Examples: []any{ + "example1", + "example2", + }, + } + + // Examples don't affect validation, just ensure schema is valid + err := schema.VisitJSON("any-value", EnableJSONSchema2020()) + require.NoError(t, err) + }) +} + +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"}, + Min: &min, + ExclusiveMin: ExclusiveBound{Bool: &boolTrue}, + } + + err := schema.VisitJSON(0.1, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(0.0, 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"}, + Max: &max, + ExclusiveMax: ExclusiveBound{Bool: &boolTrue}, + } + + err := schema.VisitJSON(99.9, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(100.0, 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"}}}, + }, + } + + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(42, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(true, 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"}}}, + }, + } + + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(42, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(true, 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}}, + }, + } + + err := schema.VisitJSON(50.0, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(150.0, EnableJSONSchema2020()) + require.Error(t, err) // exceeds max + }) + + t.Run("not", func(t *testing.T) { + schema := &Schema{ + Not: &SchemaRef{Value: &Schema{Type: &Types{"string"}}}, + } + + err := schema.VisitJSON(42, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON("hello", EnableJSONSchema2020()) + require.Error(t, err) + }) +} + +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"}, + } + + // Should not panic, even if there's an issue + err := schema.VisitJSON("test", EnableJSONSchema2020()) + require.NoError(t, err) + }) +} + +func TestJSONSchema2020Validator_TransformRecursesInto31Fields(t *testing.T) { + // These tests verify that transformOpenAPIToJSONSchema recurses into + // OpenAPI 3.1 / JSON Schema 2020-12 fields. Each sub-test uses a nested + // schema with nullable:true (an OpenAPI 3.0-ism) that must be converted + // 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"}, + Nullable: true, + }}, + }, + } + + err := schema.VisitJSON([]any{"hello"}, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON([]any{nil}, 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"}, + Nullable: true, + }}, + } + + err := schema.VisitJSON([]any{nil}, 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"}, + Nullable: true, + }}, + }, + } + + err := schema.VisitJSON(map[string]any{"x-val": nil}, 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}}, + }, + DependentSchemas: Schemas{ + "name": &SchemaRef{Value: &Schema{ + Properties: Schemas{ + "tag": &SchemaRef{Value: &Schema{ + Type: &Types{"string"}, + Nullable: true, + }}, + }, + }}, + }, + } + + err := schema.VisitJSON(map[string]any{"name": "foo", "tag": nil}, 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"}, + MinLength: 1, + }}, + } + + err := schema.VisitJSON(map[string]any{"abc": 1}, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(map[string]any{"": 1}, 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"}}}, + }, + UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: &Schema{ + Type: &Types{"string"}, + Nullable: true, + }}}, + } + + err := schema.VisitJSON([]any{1, nil}, 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"}}}, + }, + UnevaluatedProperties: BoolSchema{Schema: &SchemaRef{Value: &Schema{ + Type: &Types{"string"}, + Nullable: true, + }}}, + } + + err := schema.VisitJSON(map[string]any{"name": "foo", "extra": nil}, 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"}, + ContentMediaType: "application/json", + ContentSchema: &SchemaRef{Value: &Schema{ + Type: &Types{"object"}, + Nullable: true, + }}, + } + + // contentSchema transform should not crash and should handle nullable + err := schema.VisitJSON("null", 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"}, + } + + err := schema.VisitJSON("hello", EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(123, 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"}}}, + }, + Required: []string{"name"}, + } + + err := schema.VisitJSON(map[string]any{ + "name": "John", + }) + require.NoError(t, err) + + err = schema.VisitJSON(map[string]any{}, EnableJSONSchema2020()) + require.Error(t, err) + }) +} diff --git a/openapi3/schema_types_test.go b/openapi3/schema_types_test.go new file mode 100644 index 000000000..6c86fbb88 --- /dev/null +++ b/openapi3/schema_types_test.go @@ -0,0 +1,241 @@ +package openapi3 + +import ( + "encoding/json" + "testing" + + "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"} + require.False(t, types.IncludesNull()) + + // Type array with null + types = &Types{"string", "null"} + require.True(t, types.IncludesNull()) + + // Multiple types without null + types = &Types{"string", "number"} + require.False(t, types.IncludesNull()) + + // Nil types + var nilTypes *Types + require.False(t, nilTypes.IncludesNull()) + }) + + t.Run("IsMultiple", func(t *testing.T) { + // Single type + types := &Types{"string"} + require.False(t, types.IsMultiple()) + + // Multiple types + types = &Types{"string", "null"} + require.True(t, types.IsMultiple()) + + types = &Types{"string", "number", "null"} + require.True(t, types.IsMultiple()) + + // Empty types + types = &Types{} + require.False(t, types.IsMultiple()) + + // Nil types + var nilTypes *Types + require.False(t, nilTypes.IsMultiple()) + }) + + t.Run("IsSingle", func(t *testing.T) { + // Single type + types := &Types{"string"} + require.True(t, types.IsSingle()) + + // Multiple types + types = &Types{"string", "null"} + require.False(t, types.IsSingle()) + + // Empty types + types = &Types{} + require.False(t, types.IsSingle()) + + // Nil types + var nilTypes *Types + require.False(t, nilTypes.IsSingle()) + }) + + t.Run("IsEmpty", func(t *testing.T) { + // Single type + types := &Types{"string"} + require.False(t, types.IsEmpty()) + + // Multiple types + types = &Types{"string", "null"} + require.False(t, types.IsEmpty()) + + // Empty types + types = &Types{} + require.True(t, types.IsEmpty()) + + // Nil types + var nilTypes *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"}, + } + + data, err := json.Marshal(schema) + require.NoError(t, err) + + // Should serialize as "type": "string" (not array) + require.Contains(t, string(data), `"type":"string"`) + require.NotContains(t, string(data), `"type":["string"]`) + }) + + t.Run("multiple types serialize as array", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"string", "null"}, + } + + data, err := json.Marshal(schema) + require.NoError(t, err) + + // Should serialize as "type": ["string", "null"] + require.Contains(t, string(data), `"type":["string","null"]`) + }) + + t.Run("deserialize string to single type", func(t *testing.T) { + jsonData := []byte(`{"type":"string"}`) + + var schema Schema + err := json.Unmarshal(jsonData, &schema) + require.NoError(t, err) + + require.NotNil(t, schema.Type) + require.True(t, schema.Type.IsSingle()) + require.True(t, schema.Type.Is("string")) + }) + + t.Run("deserialize array to multiple types", func(t *testing.T) { + jsonData := []byte(`{"type":["string","null"]}`) + + var schema Schema + err := json.Unmarshal(jsonData, &schema) + require.NoError(t, err) + + require.NotNil(t, schema.Type) + require.True(t, schema.Type.IsMultiple()) + require.True(t, schema.Type.Includes("string")) + require.True(t, schema.Type.IncludesNull()) + }) +} + +func TestTypes_OpenAPI31Features(t *testing.T) { + t.Run("type array with null", func(t *testing.T) { + types := &Types{"string", "null"} + + require.True(t, types.Includes("string")) + require.True(t, types.IncludesNull()) + require.True(t, types.IsMultiple()) + require.False(t, types.IsSingle()) + require.False(t, types.IsEmpty()) + + // Test Permits + require.True(t, types.Permits("string")) + require.True(t, types.Permits("null")) + require.False(t, types.Permits("number")) + }) + + t.Run("type array without null", func(t *testing.T) { + types := &Types{"string", "number"} + + require.True(t, types.Includes("string")) + require.True(t, types.Includes("number")) + require.False(t, types.IncludesNull()) + require.True(t, types.IsMultiple()) + }) + + t.Run("OpenAPI 3.0 style single type", func(t *testing.T) { + types := &Types{"string"} + + require.True(t, types.Is("string")) + require.True(t, types.Includes("string")) + require.False(t, types.IncludesNull()) + require.False(t, types.IsMultiple()) + require.True(t, types.IsSingle()) + }) +} + +func TestTypes_EdgeCases(t *testing.T) { + t.Run("nil types permits everything", func(t *testing.T) { + var types *Types + + require.True(t, types.Permits("string")) + require.True(t, types.Permits("number")) + require.True(t, types.Permits("null")) + require.True(t, types.IsEmpty()) + }) + + t.Run("empty slice of types", func(t *testing.T) { + types := &Types{} + + require.False(t, types.Includes("string")) + require.False(t, types.Permits("string")) + require.True(t, types.IsEmpty()) + require.False(t, types.IsSingle()) + require.False(t, types.IsMultiple()) + }) + + t.Run("Slice method", func(t *testing.T) { + types := &Types{"string", "null"} + slice := types.Slice() + + require.Equal(t, []string{"string", "null"}, slice) + + // Nil types + var nilTypes *Types + require.Nil(t, nilTypes.Slice()) + }) +} + +func TestTypes_BackwardCompatibility(t *testing.T) { + t.Run("existing Is method still works", func(t *testing.T) { + // Single type + types := &Types{"string"} + require.True(t, types.Is("string")) + require.False(t, types.Is("number")) + + // Multiple types - Is should return false + types = &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"} + require.True(t, types.Includes("string")) + require.False(t, types.Includes("number")) + + types = &Types{"string", "null"} + require.True(t, types.Includes("string")) + require.True(t, types.Includes("null")) + require.False(t, types.Includes("number")) + }) + + t.Run("existing Permits method still works", func(t *testing.T) { + // Nil types permits everything + var types *Types + require.True(t, types.Permits("anything")) + + // Specific types + types = &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 new file mode 100644 index 000000000..79675073b --- /dev/null +++ b/openapi3/schema_validate_31_test.go @@ -0,0 +1,112 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSchemaValidate31SubSchemas(t *testing.T) { + ctx := context.Background() + + // Helper: a schema with an invalid nested schema (pattern with bad regex) + invalidSchema := &Schema{ + Type: &Types{"string"}, + Pattern: "[invalid", + } + + t.Run("prefixItems with invalid sub-schema", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"array"}, + PrefixItems: 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}, + } + 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{ + "^x-": {Value: invalidSchema}, + }, + } + err := schema.Validate(ctx) + require.Error(t, err, "should detect invalid sub-schema in patternProperties") + }) + + t.Run("dependentSchemas with invalid sub-schema", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"object"}, + DependentSchemas: Schemas{ + "name": {Value: invalidSchema}, + }, + } + err := schema.Validate(ctx) + require.Error(t, err, "should detect invalid sub-schema in dependentSchemas") + }) + + t.Run("propertyNames with invalid sub-schema", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"object"}, + PropertyNames: &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}}, + } + 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}}, + } + 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"}, + ContentMediaType: "application/json", + ContentSchema: &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{ + {Value: validSubSchema}, + }, + Contains: &SchemaRef{Value: validSubSchema}, + UnevaluatedItems: BoolSchema{Schema: &SchemaRef{Value: validSubSchema}}, + } + err := schema.Validate(ctx) + require.NoError(t, err, "valid sub-schemas should pass validation") + }) +} diff --git a/openapi3/schema_validation_settings.go b/openapi3/schema_validation_settings.go index 9d70a6191..205d95d8f 100644 --- a/openapi3/schema_validation_settings.go +++ b/openapi3/schema_validation_settings.go @@ -21,6 +21,7 @@ type schemaValidationSettings struct { patternValidationDisabled bool readOnlyValidationDisabled bool writeOnlyValidationDisabled bool + useJSONSchema2020 bool // Use JSON Schema 2020-12 validator for OpenAPI 3.1 regexCompiler RegexCompilerFunc @@ -151,6 +152,13 @@ func WithIntegerFormatValidator(name string, validator IntegerFormatValidator) S } } +// EnableJSONSchema2020 enables JSON Schema 2020-12 compliant validation. +// This enables support for OpenAPI 3.1 and JSON Schema 2020-12 features. +// When enabled, validation uses the jsonschema library instead of the built-in validator. +func EnableJSONSchema2020() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.useJSONSchema2020 = true } +} + func newSchemaValidationSettings(opts ...SchemaValidationOption) *schemaValidationSettings { settings := &schemaValidationSettings{} for _, opt := range opts { diff --git a/openapi3/security_scheme.go b/openapi3/security_scheme.go index bc5bc2112..a27a217c7 100644 --- a/openapi3/security_scheme.go +++ b/openapi3/security_scheme.go @@ -171,6 +171,8 @@ func (ss *SecurityScheme) Validate(ctx context.Context, opts ...ValidationOption if ss.OpenIdConnectUrl == "" { 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 default: return fmt.Errorf("security scheme 'type' can't be %q", ss.Type) } diff --git a/openapi3/server.go b/openapi3/server.go index c3fb44147..847a0288a 100644 --- a/openapi3/server.go +++ b/openapi3/server.go @@ -121,6 +121,7 @@ func (server *Server) UnmarshalJSON(data []byte) error { if len(x.Extensions) == 0 { x.Extensions = nil } + delete(x.Variables, originKey) *server = Server(x) return nil } diff --git a/openapi3/testdata/origin/mapping_fields.yaml b/openapi3/testdata/origin/mapping_fields.yaml new file mode 100644 index 000000000..0cdb2d7d8 --- /dev/null +++ b/openapi3/testdata/origin/mapping_fields.yaml @@ -0,0 +1,27 @@ +openapi: "3.1.0" +info: + title: Mapping Fields Origin Test + version: "1.0" +paths: + /test: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + metadata: + type: object + dependentRequired: + name: + - age + - email + dependentSchemas: + credit_card: + type: object + patternProperties: + "^x-": + type: string diff --git a/openapi3/testdata/schema31-ref-siblings.yml b/openapi3/testdata/schema31-ref-siblings.yml new file mode 100644 index 000000000..87dda65ae --- /dev/null +++ b/openapi3/testdata/schema31-ref-siblings.yml @@ -0,0 +1,29 @@ +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/testdata/schema31_conditional.yml b/openapi3/testdata/schema31_conditional.yml new file mode 100644 index 000000000..1da51a54c --- /dev/null +++ b/openapi3/testdata/schema31_conditional.yml @@ -0,0 +1,32 @@ +openapi: "3.1.0" +info: + title: Test conditional keywords + version: "1.0" +paths: {} +components: + schemas: + StringType: + type: string + NumberType: + type: number + MinLength3: + minLength: 3 + ConditionalField: + if: + $ref: '#/components/schemas/StringType' + then: + $ref: '#/components/schemas/MinLength3' + else: + $ref: '#/components/schemas/NumberType' + PaymentInfo: + type: object + properties: + name: + type: string + creditCard: + type: string + billingAddress: + type: string + dependentRequired: + creditCard: + - billingAddress diff --git a/openapi3/testdata/schema31refs.yml b/openapi3/testdata/schema31refs.yml new file mode 100644 index 000000000..cd3ecb947 --- /dev/null +++ b/openapi3/testdata/schema31refs.yml @@ -0,0 +1,70 @@ +openapi: "3.1.0" +info: + title: Test OpenAPI 3.1 Schema Refs + version: "1.0.0" +paths: + /test: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TupleArray" +components: + schemas: + StringType: + type: string + IntegerType: + type: integer + NamePattern: + type: string + pattern: "^[a-z]+$" + NonNegative: + type: number + minimum: 0 + TupleArray: + type: array + prefixItems: + - $ref: "#/components/schemas/StringType" + - $ref: "#/components/schemas/IntegerType" + ArrayWithContains: + type: array + contains: + $ref: "#/components/schemas/StringType" + ObjectWithPatternProperties: + type: object + patternProperties: + "^x-": + $ref: "#/components/schemas/StringType" + ObjectWithDependentSchemas: + type: object + dependentSchemas: + name: + $ref: "#/components/schemas/NonNegative" + ObjectWithPropertyNames: + type: object + propertyNames: + $ref: "#/components/schemas/NamePattern" + ArrayWithUnevaluatedItems: + type: array + unevaluatedItems: + $ref: "#/components/schemas/StringType" + ObjectWithUnevaluatedProperties: + type: object + unevaluatedProperties: + $ref: "#/components/schemas/StringType" + ObjectWithIfThenElse: + type: object + if: + $ref: "#/components/schemas/StringType" + then: + $ref: "#/components/schemas/IntegerType" + else: + $ref: "#/components/schemas/NonNegative" + StringWithContentSchema: + type: string + contentMediaType: application/json + contentSchema: + $ref: "#/components/schemas/NonNegative" diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index 1d141d40a..ef76eafa5 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -15,6 +15,7 @@ type ValidationOptions struct { schemaExtensionsInRefProhibited bool regexCompilerFunc RegexCompilerFunc extraSiblingFieldsAllowed map[string]struct{} + jsonSchema2020ValidationEnabled bool // Enables JSON Schema 2020-12 compliant validation for OpenAPI 3.1 } type validationOptionsKey struct{} @@ -31,6 +32,14 @@ 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 { + return func(options *ValidationOptions) { + options.jsonSchema2020ValidationEnabled = true + } +} + // EnableSchemaFormatValidation makes Validate not return an error when validating documents that mention schema formats that are not defined by the OpenAPIv3 specification. // By default, schema format validation is disabled. func EnableSchemaFormatValidation() ValidationOption { diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go index 01f6cd14e..46c2d76fe 100644 --- a/openapi3filter/validate_request.go +++ b/openapi3filter/validate_request.go @@ -240,6 +240,9 @@ func ValidateParameter(ctx context.Context, input *RequestValidationInput, param if options.customSchemaErrorFunc != nil { opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc)) } + if input.Route != nil && input.Route.Spec != nil && input.Route.Spec.IsOpenAPI3_1() { + opts = append(opts, openapi3.EnableJSONSchema2020()) + } if err = schema.VisitJSON(value, opts...); err != nil { return &RequestError{Input: input, Parameter: parameter, Err: err} } @@ -349,6 +352,9 @@ 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() { + opts = append(opts, openapi3.EnableJSONSchema2020()) + } // Validate JSON with the schema if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil { diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index 7ab72493e..5c7179325 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -75,6 +75,9 @@ 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() { + opts = append(opts, openapi3.EnableJSONSchema2020()) + } headers := make([]string, 0, len(response.Headers)) for k := range response.Headers {